Compare commits

...

400 Commits

Author SHA1 Message Date
Pat Sukprasert c85a64af22 docs(models): delegate Kimi example default
Remove the release-specific model from the Kimi launcher example so an unoverridden session uses the default already configured in the Kimi CLI.

Document the ownership boundary, assert that the spawn environment omits HARNESS_KIMI_MODEL when no model is declared, and ratchet the retired lint allowance.

Tests: 12 Kimi spawn-environment tests; structural example load; staged pre-commit including YAML and hardcoded-model checks.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 22:32:05 +08:00
Pat Sukprasert 016d6e3d95 feat(models): discover onboarding defaults
Select provider setup defaults from the live catalog after filtering specialty modalities, using stable family preferences for broadly accessible Anthropic and OpenRouter choices instead of release-specific model pins.

When discovery is unavailable, leave onboarding unpinned so the user supplies an explicit model. Add deterministic catalog fixtures for interactive CLI coverage, ratchet three lint allowances, and document the migration.

Tests: 147 onboarding and configure-models tests; staged pre-commit including the hardcoded-model lint.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 22:32:05 +08:00
Pat Sukprasert 70efeb0902 test(models): pin YAML model precedence
Add a direct regression test showing that an executor.model declared in agent YAML remains authoritative over OMNIGENT_MODEL and catalog discovery.

Fail the test if catalog resolution runs, so future changes cannot silently turn an explicit YAML model into a fallback lookup.

Tests: 114 CLI chat tests; targeted pre-commit checks.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 22:31:47 +08:00
Pat Sukprasert ce9047d3f0 feat(models): discover ad-hoc CLI default
Resolve the model for minimal harness-less agent YAMLs from the Databricks OpenAI-family catalog during bundle materialization instead of baking a release-specific endpoint into the CLI.

Preserve --model and OMNIGENT_MODEL precedence, and fail with explicit configuration guidance when discovery is unavailable. Ratchet the removed pin from the hardcoded-model allowlist and document the new behavior.

Tests: 111 discovery-disabled CLI tests; staged pre-commit including the hardcoded-model lint.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 22:07:46 +08:00
Pat Sukprasert b28a5701c7 [models] Discover Kiro picker models from CLI (#3452)
* feat(models): discover Kiro picker catalog

Replace the curated Kiro model picker table with the CLI's JSON model listing so newly released, renamed, or retired Kiro models no longer require an Omnigent source update.

Run discovery on the bound runner, expose it through a dedicated model-options endpoint, and reuse the server's asynchronous single-flight cache so snapshots never block on the CLI process.

Preserve Kiro-provided default, description, context-window, and credit-rate metadata in picker rows, remove four hardcode allowances, and document the discovery boundary.

Tests: 115 Kiro, runner lifecycle, and server snapshot tests; staged pre-commit run; manual validation against kiro-cli 2.10.0 output.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* test(kiro): cover picker discovery failures

Exercise the runner endpoint's retryable 503 path when Kiro CLI model discovery fails so the server keeps its picker cache cold instead of treating failure as an empty successful catalog.

Extend the session snapshot round-trip to verify provider descriptions and rate units survive NativeModelOption's extra-field wire schema alongside context windows and rate multipliers.

Tests: 116 Kiro, runner lifecycle, and snapshot tests passed. Live kiro-cli 2.15.1 discovery returned nine models with auto as the sole default. Targeted pre-commit passed.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* refactor(kiro): narrow discovered picker contract

Remove the unused kiro_base_model_options compatibility alias because its old pure lookup contract became a blocking CLI subprocess and no production caller remains.

Stop emitting isCurrent for Kiro because the CLI discovery response does not provide current-session state and the Web picker derives the selected row from model_override.

Cover missing and mismatched CLI defaults in the discovery mapper and verify that the Web picker falls back to its Default sentinel, leaving Kiro responsible for choosing the actual default. Refresh the Kiro picker E2E fixture and wording to match live discovery.

Tests: 118 focused Kiro/runner/snapshot tests; 4,705 Web tests; targeted pre-commit checks.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 13:43:14 +00:00
Pat Sukprasert 202cf66bd7 refactor(harness): registry-driven native launch dispatch — scaffolding + uniform arms (PR 1.5b-i) (#3500)
First half of the runner launch seam. Wires the provider's auto_create_terminal
field (declared since 1.1, never dispatched) and collapses the 8 uniform
create-session launch arms in runner/app.py onto it. Behavior-preserving.

- orchestration: add NativeLaunchContext (flat dataclass of the inputs the 11
  builders may need, incl. claude's closures), PreLaunchResult (skip /
  force_recreate / needs_terminal for the special arms in 1.5b-ii), 11 thin
  _launch_<x>(ctx) adapters that unpack the context and call the unchanged
  _auto_create_<x>_terminal builder with that harness's exact kwarg subset, and
  the shared shell _launch_native_terminal(harness, ctx, *, ensure_locks,
  pre_launch=None, resolve_agent_spec=None). The shell runs the lock /
  existence-check / pending+error-event mechanics every arm shared and resolves
  the adapter via resolve_hook(provider, "auto_create_terminal").
- Option A (adapters, builders unchanged) keeps the 21 direct-call builder tests
  intact. agent_spec is resolved lazily via resolve_agent_spec inside the create
  block, preserving each arm's error semantics (pi unwrapped; cursor/opencode/
  kimi swallow OmnigentError via _resolve_session_agent_spec_or_none; the rest
  pass no resolver).
- harness_plugins: repoint auto_create_terminal to omnigent.runner.native:_launch_<key>.
- app.py: the 8 uniform arms (pi, cursor, kiro, opencode, goose, hermes, qwen,
  kimi) become one _launch_native_terminal call each, picking the per-harness
  lock dict (kept app-scope so session cleanup can pop by name). Net -256 lines.
- qwen's launch-error label is now "Qwen Code" (uniform display_name) vs the
  former lowercase "qwen" — cosmetic; no test asserted the literal.

Deferred to 1.5b-ii: the 3 special arms (claude/codex/antigravity) and the
turn-path opencode cold-boot, which still use the direct builders.

Tests: unit-cover each adapter's kwarg subset and the shell's branches
(create / existing-skip / force-recreate teardown / skip+needs_terminal /
start-error event / lazy-spec-only-on-create / non-native None). The workflow-
init HTTP suite exercises the real launch path for the uniform arms and stays
green. Pre-existing codex gateway-env failures in events_lifecycle are unchanged
(verified identical on clean main; codex arm untouched here).

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 13:25:42 +00:00
Pat Sukprasert fa96f946ef feat(sessions): Add shared-message attribution (#3422)
- Preserve trusted authorship across history, buffered turns, and native harnesses
- Label model-visible messages while keeping owner credentials authoritative

Refs #2150

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 12:47:01 +00:00
Pat Sukprasert 676b5ef2e2 feat(models): route from discovered catalogs (#3450)
Remove the release-specific smart-routing model table and require the runner worker catalog for routing candidates. When discovery is unavailable, leave the harness on its provider-resolved default instead of selecting a stale fallback.

Order catalog candidates by normalized provider-relative cost tiers while preserving catalog order as the tie-breaker, and express the built-in judge rubric through stable fast, balanced, and powerful intents rather than vendor model-name tiers.

Apply the same discovery-only rule to sys_advise_models, ratchet eight hardcode allowances, and document the remaining wire-compatibility exclusions as a separate migration boundary.

Tests: 67 focused routing/session tests; staged pre-commit run.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 19:15:28 +07:00
Pat Sukprasert 2814871d67 [models] Resolve runtime defaults from catalogs (#3448)
* feat(models): resolve runtime defaults from catalogs

Adapt MLflow provider listings into normalized resolver candidates with tri-state capability metadata, context windows, provider-relative cost tiers, and deterministic family filtering.

Replace release-specific defaults across workflow ucode routing, SDK executors, Databricks execution, and Claude/Codex/Pi/OpenCode native launch paths. Explicit request, spec, ucode, and provider-configured models continue to win; unresolved defaults now use the active provider catalog and fail clearly when discovery has no compatible model.

Improve model-version sorting so provider prefixes, dates, endpoint sizes, and unrelated numeric families do not distort catalog order. Ratchet ten obsolete hardcode allowances and document the runtime migration boundary.

Tests: 168 catalog/workflow tests; 519 executor tests; 449 native tests; staged pre-commit run.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): honor overrides before defaults

Apply per-session and CLI model overrides to the effective executor spec before spawn-environment builders attempt provider default resolution. This keeps explicit request values authoritative when catalog lookup is unavailable.

Preserve an explicit OMNIGENT_MODEL value when --harness selects the runtime, and allow model-only E2E overrides when the YAML owns harness selection. Add deterministic fixture models to unrelated tests so catalog-disabled CI does not depend on discovery.

Tests: 8 catalog-disabled CI regressions; 284 broader CLI/runtime/runner tests; staged pre-commit including the hardcoded-model lint.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): preserve catalog default policy

Route default-intent catalog resolution through the existing general-purpose selection policy after family filtering. This retains specialty-model exclusion and provider pins while leaving non-default intents on metadata ranking.

Require dynamically discovered Databricks defaults to use gateway-routable databricks-prefixed ids, report actionable catalog misses to direct executor callers, and model context capacity from max input tokens rather than input plus output budgets.

Add regression coverage for constrained defaults, lagging provider pins, OpenAI specialty variants, Databricks Claude/OpenAI routing, and context-window normalization.

Tests: 85 focused catalog/provider tests passed; 150 broader tests produced 149 passes plus the documented ambient Claude-login failure. Live Databricks catalog verification found 14 Claude and 16 OpenAI entries, all gateway-prefixed. Pre-commit passed all relevant hooks; repository-wide web-prettier and stale routing protobuf checks remain baseline failures.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): offload catalog discovery

Run cold catalog resolution on the existing dedicated thread helper from async Codex, Databricks, Open Responses, OpenAI Agents, and Pi turn paths. Model-less first turns can now wait for remote discovery without blocking the shared event loop for the catalog timeout.

Keep explicit and configured model precedence synchronous and unchanged. Make Pi's internal model resolver async so its Databricks fallback follows the same non-blocking boundary.

Add a regression that verifies catalog discovery executes outside the event-loop thread and update Pi resolver tests for the async contract.

Tests: 405 affected executor tests passed. Targeted pre-commit passed, including formatting, Ruff, and the hardcoded-model lint.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): offload Claude catalog lookup

Move the remaining Claude SDK Databricks catalog fallback onto the dedicated thread helper so a cold remote lookup cannot block the async turn loop.

Restore direct Pi coverage tying a catalog-selected Databricks default to dynamic models.json registration. This preserves the prior unknown-model invariant even when the selected gateway id is newer than Pi's curated static entries.

Tests: 251 Claude SDK and Pi executor tests passed with the documented macOS path-canonicalization test deselected. Targeted pre-commit passed, including Ruff and the hardcoded-model lint.
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 09:30:31 +00:00
Pat Sukprasert 5555c88944 refactor(harness): route native spawn-env through the provider seam (PR 1.5a) (#3495)
* refactor(harness): route native spawn-env through the provider seam (PR 1.5a)

Collapse the two near-identical 11-arm native spawn-env dispatch chains in
runner/app.py (create-session ~2567 and dispatch ~6092) onto the provider seam.
Each block becomes one guarded call to a registry-driven helper; net -171 lines
in app.py. Behavior-preserving — every native harness produces the identical
spawn env before/after.

- harness_plugins: populate `spawn_env_builder` on all 11 built-in providers
  (uniform `omnigent.<key>_native_bridge:build_<key>_native_spawn_env`) and add
  a `bridge_id_label_key` field, set to `omnigent.<key>_native.bridge_id` for
  the three label-based harnesses (codex/opencode/antigravity). The label key is
  derived (not imported) to keep harness_plugins import-light; a test pins the
  derivation against the real bridge constants.
- runner/native/orchestration: add `_resolve_native_spawn_env(harness, session_id,
  *, server_client, optional_labels)`. It resolves `provider.spawn_env_builder`
  and handles the three shapes — bare (session id only), label (bridge id from
  `bridge_id_label_key`), and two named specials: claude (bridge id via the
  runner helper with a server-side fallback) and hermes (writes its policy-hook
  config before building). Returns None for non-native harnesses so the caller
  keeps its SDK spawn env. Re-exported via runner/native/__init__.
- runner/app: both blocks now call the helper; the per-harness bridge imports and
  label-key reads are gone.

The two special-cases (claude/hermes) stay named branches in the helper rather
than fully data-driven provider fields — their only consumers are single call
sites, and 1.5b's NativeLaunchContext will reshape the right calling convention.

Tests: extend the provider-paths-resolve + required-hooks tests to cover
spawn_env_builder; pin bridge_id_label_key against the real constants; add
`_resolve_native_spawn_env` unit coverage for all four shapes + the non-native
None path. The existing workflow_init codex-bundle-dir spawn-env test (the
end-to-end behavior-preservation proof) stays green unchanged.

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

* test(harness): hoist spawn-env test imports to module level

Move the per-test `_resolve_native_spawn_env` and
`CODEX_NATIVE_BRIDGE_ID_LABEL_KEY` imports (added in 1.5a) up to the module
import block. No behavior change; test-only cleanup.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 09:26:49 +00:00
Zeyi (Rice) Fan c38e174f1a fix(web): resolve jest-dom matcher types under pnpm via packageExtensions (#3494)
## Related issue

N/A

## Summary

- `@testing-library/jest-dom` never declares `vitest` as a (peer) dependency.
  Under pnpm's store layout, jest-dom's `declare module "vitest"` matcher-type
  augmentation can't resolve `vitest`, so it silently fails to merge and `tsc`
  loses every DOM matcher (`toBeInTheDocument`, `toHaveClass`, …) — even though
  they register fine at runtime. See vitest-dev/vitest#10411.
- Declare the missing peer via pnpm `packageExtensions` so pnpm links `vitest`
  into jest-dom's scope and the augmentation resolves. This is a root-cause fix
  at the dependency layer — no hand-written type shim needed.
- Note: `type-check` still has unrelated pre-existing errors and is not yet
  gated in CI; this fix only removes the jest-dom matcher category.

## Test Plan

- `pnpm install --frozen-lockfile --filter web` — lockfile stays consistent.
- `pnpm --filter web run type-check` — jest-dom matcher errors drop from 1589
  to 0 (remaining errors are unrelated and pre-existing).
- `pnpm --filter web run test` (e.g. `src/shell/WorkspacePanel.test.tsx`) —
  15/15 pass under Node 22; runtime is unaffected.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Verified by comparing `pnpm --filter web run type-check` jest-dom error counts
(1589 → 0) and running the existing vitest suite (unaffected). The change is
dependency-resolution config only, with no new runtime code to test.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-29 01:24:38 -07:00
Harry Su c62bfc2fe7 docs(policies): document static YAML registration for cel_policy (#3462)
The module docstring only showed the session policy REST API, implying
CEL policies can't be declared statically. Both static paths work and
are now shown: config.yaml policies (handler + factory_params, parsed
by omnigent.inner.loader) and bundled agent specs (guardrails.policies
with a function {path, arguments} mapping, parsed by
omnigent.spec.parser — which does not read factory_params). Verified
both forms against their parsers.

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>
2026-07-29 06:30:35 +00:00
Tomu Hirata 031924b26a fix(codex-native): replace hook-trust carry machinery with --dangerously-bypass-hook-trust (#3477)
* fix: add codex_cli_version to fake app-servers in tests; fix ruff format

- Add codex_cli_version = None to all _FakeCodexAppServer classes so they
  satisfy the new attribute read in the orchestration bypass_hook_trust gate
- Collapse the multiline boolean in orchestration to satisfy ruff format

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

* fix: symlink hooks.json into private CODEX_HOME so user hooks fire

hooks.json was never symlinked, so user hooks declared there were silently
ignored in private sessions. Add it to _CODEX_HOME_GLOBAL_INSTRUCTION_FILES
so it's symlinked in full sessions but skipped in minimal_config (title
worker) mode. Trust is no longer a concern since --dangerously-bypass-hook-trust
is passed to runner-owned TUI sessions.

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

* fix: merge user hooks.json into policy hooks file instead of clobbering symlink

_write_codex_policy_hooks_file was using os.replace() which destroyed the
hooks.json symlink created by _populate_codex_home_config, silently dropping
all user hooks. Now when the path is a symlink, we read the user's hooks,
merge them after the policy hooks for each event (plus any user-only events),
remove the symlink, and write the merged payload as a regular file.

User hooks from ~/.codex/hooks.json now fire in private sessions.

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

* style: collapse _merge_user_hooks signature to one line (ruff format)

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-29 06:30:16 +00:00
Tomu Hirata c52da1dbcc feat(model-discovery): add max_results and parent params to UC model-services request (#3478)
Scopes the listing to system.ai models only via the parent filter and
raises the result cap to 1000, matching the recommended API call at
/ajax-api/2.1/unity-catalog/model-services?max_results=1000&parent=schemas%2Fsystem.ai.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-29 15:09:39 +09:00
Daniel Lok 1a96952a7f fix(web): scale conversation sidebar text with the font-size setting (#3480)
* fix(web): scale conversation sidebar text with the font-size setting

The sidebar's compact text was pinned to a fixed `--sidebar-font-size:
13px`, so the Appearance font-size setting only moved the surrounding
rem-based padding while the text stayed at 13px. Express the variable in
`rem` (0.8125rem = 13px at the 16px default) so it rides the root
font-size, which already folds in `--ui-font-scale` and the mobile bump.

Drop the explicit `line-height` on `.sidebar-compact-text`: single-line
rows use fixed height + flex centering (line-height inert), and the two
line-clamped previews now inherit the root's unitless 1.5, which scales
with the text for free.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-29 14:07:08 +08:00
Pat Sukprasert 33a06cc0a4 [models] Add intent resolver contracts (#3443)
* feat(models): add intent resolver contracts

Define stable model intents and provider-neutral metadata for capabilities, context windows, cost tiers, and wire APIs. Capability support is tri-state so incomplete provider listings cannot be mistaken for positive support.

Add deterministic resolution precedence for explicit choices, configured defaults, live catalogs, and documented static fallbacks. Catalog order remains the tie-breaker, while provider-specific preference policies can override ranking without changing callers.

Expose normalized metadata through model catalog entries and payloads without changing any executor or routing defaults in this slice.

Tests: 108 focused resolver, catalog, and smart-routing tests; staged pre-commit hooks.

Part of #3426

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* refactor(models): keep resolver intents caller-backed

Limit the public model intent vocabulary to default, fast, balanced, and powerful because those are the only purposes represented by current callers.

Express tool use, image generation, structured output, and similar requirements through explicit capabilities instead of speculative intent-to-capability mappings. Remove the unused large-context ranking path and update resolver tests and migration guidance accordingly.

Tests: uv run --no-sync pytest -q tests/test_model_resolver.py tests/test_model_catalog.py tests/server/test_smart_routing.py; pre-commit run
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* refactor(models): complete wire API contract

Cover every model-endpoint request shape implemented by the provider adapters by adding Bedrock Converse and naming Gemini generateContent explicitly. Keep native CLI and ACP transports outside the model wire protocol vocabulary.

Clarify that explicit model overrides bypass compatibility constraints, intent tiers are best-effort ranking preferences, and uncatalogued explicit resolutions have unknown family and metadata. Add regression coverage for those semantics and for the complete wire API vocabulary.

Tests: uv run --no-sync pytest -q tests/test_model_resolver.py tests/test_model_catalog.py tests/server/test_smart_routing.py tests/llms/test_openai_adapter.py tests/llms/test_anthropic_adapter.py tests/llms/test_gemini_adapter.py tests/llms/test_vertex_adapter.py tests/llms/test_bedrock_adapter.py tests/llms/test_databricks_adapter.py; pre-commit run
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-29 05:18:34 +00:00
Pat Sukprasert 1c23bf77db docs(harness): revise Phase 1 estimates from runner exploration (#3357)
* docs(harness): revise Phase 1 estimates from runner exploration

Reading the runner dispatch surface (not guessing) changed the shape of the
remaining work, so update the proposal's estimates and plan:

- Split PR 1.5 into a serial runner sub-stack: 1.5a spawn-env (bounded, the
  first measurement), 1.5b launch (the epicenter — _auto_create_<x>_terminal
  has 11 divergent signatures, so the seam passes a NativeLaunchContext to a
  uniform provider.auto_create_terminal(ctx) adapter with pre_launch hooks,
  not a single positional call), 1.5c terminal-route.
- Re-scope 1.6 interrupt/stop upward (Med -> Med-High, 2d -> 3-4d): every
  handler closes over app-scope state (server_client, resource_registry,
  _publish_event, module dicts), so extraction needs a DI context, not a move.
- Revise totals: Phase 1 ~17-25 -> ~20-29 eng-days; overall ~26-37 -> ~29-41
  across ~12 -> ~14 PRs; critical path rewritten to the serial runner chain.
- Add a Calibration subsection recording the learning from 1.1-1.3 (additive
  PRs come in under estimate; the real cost is test-shape churn; the runner is
  the back-loaded risk) and settle the "signature uniformity" open question
  with the confirmed finding.

Docs-only; no code paths affected.

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

* docs(harness): record harness-bench compatibility with native plugins

The harness bench's selection + driver layer is already registry-driven:
manifest.py auto-adds every NATIVE_TUI capability as a BenchProfile and the
NativeTuiDriver is selected generically, so a community native plugin
enumerates and gets a profile with zero bench edits. Record the two remaining
gaps and where they close:

- Provisioning needs registry-driven agent seeding — closed for free by PR 1.7
  (the native driver provisions against a pre-seeded <harness>-ui agent).
- Tool-call probe metadata is hardcoded (_NATIVE_TOOL_PROVOCATION) — fold
  optional shell_tool_name / shell_tool_prompt capability fields into PR 1.8 so
  the probe reads off the registry; until then those probes skip (non-fatal).

Add a "Harness bench compatibility" subsection, extend 1.8's scope with the
tool-probe fields, and give 2.4 a benchable acceptance criterion (the example
plugin runs `python -m tests.harness_bench --harness <plugin> --live` green).
No new phase or standalone bench-migration PR.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 12:08:50 +07:00
Pat Sukprasert 14886486d5 refactor(harness): route native resume through the provider seam (PR 1.3) (#3314)
* refactor(harness): route native resume through the provider seam (PR 1.3)

Collapse the two hand-written native-resume dispatch chains onto
native_dispatch.resolve_hook_for_key(key, "run_native"):

- resume_dispatch._dispatch_wrapper: 10 `if native_agent.key == "<x>"` arms →
  one resolved call.
- chat._redirect_native_resume_if_needed: 6 arms + the 6
  _run_<x>_native_resume_redirect helpers → one resolved call that derives the
  redirect notice from the agent row (wrapper_name == agent.harness,
  native_command == agent.key, both verified equal to the old literals) and
  passes auto_open_conversation. Deletes the helpers.

Behavior change (intended fix): routing through the seam covers all 11 natives,
closing two latent coverage gaps that double-posted each user turn (the exact
hazard the cursor/kimi docstrings warned about):
- chat redirect covered only 6 of 11 — goose/hermes/antigravity/qwen/opencode
  resumes fell through to the Omnigent REPL.
- resume_dispatch covered only 10 of 11 — opencode fell through the same way.
No test pinned either old fall-through; added a chat goose regression test, a
chat unknown-wrapper → False test, and a resume_dispatch opencode test.

Also:
- native_dispatch.resolve is no longer cached — dispatch happens once per
  resume/launch/seed, import_module already caches the module, and caching the
  resolved attribute silently defeats monkeypatch.setattr("...:run_x", ...),
  which the resume/CLI tests rely on. Dropped reset_resolve_cache_for_tests.
- Normalize the cli.py _NativeTerminalDispatchSpec launch table to
  args_param="extra_args" (finishing 1.2's spelling migration into the launch
  hub) and update the tests that captured the old <x>_args kwarg.

Net -261 lines. Full resume/chat/cli/native suites green; new-failure delta vs.
the clean tree is zero.

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

* docs(harness): record PR 1.3 in the progress ledger

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 04:43:37 +00:00
Pat Sukprasert d9bc1a3040 🔒 fix(sessions): Restrict approvals to owners (#3416)
- Gate both approval event and resolve URL paths at owner access
- Prevent shared editors from authorizing tools using owner credentials

Refs #2150

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 10:44:25 +07:00
Pat Sukprasert 6a32587bfe refactor(harness): normalize native launcher pass-through args (PR 1.2) (#3244)
* refactor(harness): normalize native launcher pass-through args (PR 1.2)

The 11 run_<x>_native launchers each spelled their pass-through arg
differently (claude_args, pi_args, ...). The provider seam needs one uniform
spelling to call them generically. Introduce extra_args as that spelling and
keep <x>_args as a back-compat alias.

- Add native_terminal.normalize_extra_args(): reconciles extra_args vs the
  legacy <x>_args alias — extra_args wins, the legacy alias emits a
  DeprecationWarning (removal targeted for 0.9.0), neither yields ().
- Give all 11 run_<x>_native entry points a keyword-only extra_args and make
  <x>_args an optional deprecated alias, normalizing at the top of each body
  so the deep internals keep using the existing local variable unchanged.
- Migrate the internal callers (resume_dispatch ×10, chat resume-redirect ×6,
  cli_native ×11) to extra_args so nothing in core trips the new warning; the
  alias exists purely for external back-compat.
- Tests: unit-cover the four normalize_extra_args branches. Existing native
  tests that still call <x>_args= now double as back-compat coverage.

No behavior change: with default warning filters the full native + hub suite
is green (verified the failure set is byte-identical to the clean tree; the
handful of red tests are pre-existing gateway-env artifacts unrelated to this
change).

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

* docs(harness): record PR 1.2 in the progress ledger

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-29 03:22:09 +00:00
Dhruv Gupta 210adf0dad fix(ci): exclude dev/pre tags from the backcompat version matrix (#3473)
The scheduled server-compat matrix builds its default version set from
all tags, filtering only rcN. Dev/pre tags are snapshots of main, so
main-vs-them cells add no compat signal, and under the 256-job matrix
cap they evict the oldest final releases — the coverage the workflow
exists for. A stray v0.4.0.dev0 tag is already in the live matrix
today, and a nightly prerelease lane would add ~25 such tags a month.
Explicit VERSIONS dispatch overrides still accept prerelease tags.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-29 01:19:21 +00:00
Andrew Peltekci 11c0fef152 fix(repl): don't report an Omnigent credential for ACP-backed sessions (#3431)
* fix(repl): don't report an Omnigent credential for ACP-backed sessions

acp / acp:<slug> / goose / qwen aren't in _HARNESS_FAMILY, so
default_provider_for_harness treats them as unmapped and falls through to
the configured anthropic/openai default. describe_active_credential then
hands back that provider's default_model and credential source, and both
the /model readout and the startup header render it as the active model.

But an ACP agent carries its own auth and picks its own model — the
executor only forwards a model at session/new when send_model_in_session_new
is set. So `omnigent run --harness acp:<agent>` confidently names a model
and an API key the session never touches.

Declines these harnesses at the resolver rather than the readout, so the
startup header stops fabricating too. The predicate reads the declared
capability record (ACP_SUBPROCESS + OWN_AUTH) instead of a hardcoded list,
so community ACP plugins are covered without further edits.

Signed-off-by: apeltekci <andrew@peltekci.com>

* fix(repl): scope the own-auth credential decline to acp/goose and keep overrides visible

The own-auth predicate wrongly included qwen: a harness mapped in
_HARNESS_FAMILY is provider-routed at spawn (_build_qwen_spawn_env injects
the configured openai-family default via
configure_agent_harness_with_provider, and QwenExecutor exports
OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL into the qwen subprocess —
see test_qwen_uses_openai_global_default), so its readout naming that
provider was truthful, and declining it fabricated "own auth" in the other
direction. The decline now applies only to unmapped ACP_SUBPROCESS +
OWN_AUTH harnesses (acp/acp:<slug>, goose, unmapped community ACP plugins).
The predicate is public now, so the REPL stops importing a private name,
and the manual acp:<slug> split is gone (canonicalize_harness already folds
it).

The own-auth readout also no longer claims an Omnigent-side /model override
does not reach the agent — model_env_keys() covers acp and goose, the
process manager respawns on a model change, and goose applies the override
as GOOSE_MODEL — and a live override is shown instead of hidden.

Tests: the resolver-level case now uses a key-kind openai default, the kind
the unmapped fallback actually fabricated (a subscription default was
already declined before the fix, so the previous case pinned nothing), and
new cases pin override visibility and qwen's provider-routed readout.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: apeltekci <andrew@peltekci.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-29 00:56:07 +00:00
Nikhil Chakre 2e6cde9303 fix(accounts): enforce the last-admin invariant atomically on delete (#3304)
DELETE /auth/users/{user_id} checked whether another admin existed and
deleted the target in two separate, unlocked transactions. Two
concurrent deletes of two different admins could each observe the
other as the remaining admin, both pass, and both apply, leaving
the deploy with zero admins and no in-app recovery path.

Lock the current admin set before counting it (BEGIN IMMEDIATE on
SQLite, SELECT ... FOR UPDATE on other dialects) so the check and
the delete happen in one transaction. A concurrent delete of a
different admin now blocks until the first commits and re-observes
the up-to-date count instead of a stale one.

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
2026-07-29 00:07:08 +00:00
Corey Zumar badd76a75a fix(web): align sidebar primary nav icons on one column (#3468)
* fix(web): align sidebar primary nav icons on one column

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

* fix(web): correct stale gap-1 reference in nav comment

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

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-28 16:41:37 -07:00
Zeyi (Rice) Fan e1f409245b feat(android): target Android 16 (API level 36) (#3470)
## Related issue

N/A

## Summary

Bump the Android module's `compileSdk` and `targetSdk` from 35 to 36 to meet
Google Play's requirement that apps target API level 36 by August 30, 2026.
This required updating the full Android toolchain:

- AGP 8.6.1 → 9.1.1 (AGP 9 has built-in Kotlin support)
- Gradle wrapper 8.9 → 9.3.1
- Gradle Play Publisher 3.12.1 → 4.0.0
- AndroidX dependencies to versions compatible with compileSdk 36 (e.g.,
  `androidx.core` 1.18.0, `androidx.activity` 1.12.4, `androidx.webkit` 1.15.0)
- Robolectric 4.14.1 → 4.16.1

The `org.jetbrains.kotlin.android` plugin is no longer applied because AGP 9
bundles Kotlin compilation support. Build-script helper tasks that previously
used the Gradle `exec { }` DSL were switched to `ProcessBuilder` to stay
compatible with the new Kotlin/Gradle DSL scope, and `android.sdkDirectory`
was replaced with `androidComponents.sdkComponents.sdkDirectory`.

## Test Plan

Ran the full local Android build pipeline:

```bash
cd web/android
./gradlew :app:assembleDebug :app:lintDebug
./gradlew :app:bundleRelease
./gradlew :app:assembleDebugAndroidTest
```

All completed successfully and produced a debug APK, release AAB, and androidTest
APK with zero lint errors.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Verified by running `:app:assembleDebug`, `:app:lintDebug`, `:app:bundleRelease`,
and `:app:assembleDebugAndroidTest` locally. The existing CI `android-bundle.yml`
workflow uses the Gradle wrapper and JDK 17, both compatible with the updated
toolchain.

## Changelog

Android app now targets Android 16 (API 36) to stay compliant with Google Play's
latest target API level policy.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 23:32:04 +00:00
Zeyi (Rice) Fan 2d17ee7b9b fix(build): migrate setup.py web UI build from npm to pnpm (#3467)
The repo migrated to a pnpm workspace (pnpm-workspace.yaml and
pnpm-lock.yaml at the root, packageManager: pnpm@11.15.1) but
setup.py's _build_web_ui still shelled out to 'npm install' / 'npm
run build' from inside web/. That path looked for a package-lock.json
that doesn't exist there (the lockfile is pnpm-lock.yaml at the
workspace root), so npm re-resolved from package.json alone and
hard-failed on the @lobehub/fluent-emoji@4.1.0 peer range
(react@^19 vs the pinned react@18.2.0) with ERESOLVE.

Migrate _build_web_ui to pnpm, matching deploy/databricks/build.sh
and the CI workflows (.github/workflows/e2e-ui.yml):

- Resolve pnpm via shutil.which('pnpm'), falling back to
  'corepack pnpm' (corepack ships with Node 22+ and auto-pins the
  version from package.json's packageManager field).
- Run from the workspace root (cwd=root), not web/, so pnpm uses
  the committed pnpm-lock.yaml.
- 'pnpm install --frozen-lockfile --filter web' then
  'pnpm --filter web run build' — exactly the CI commands.
  --frozen-lockfile guarantees the build is reproducible and
  resolves @lobehub/fluent-emoji against react@18.3.1 under the
  workspace's strictPeerDependencies: false, avoiding the peer
  conflict that broke npm.

Also enforce the Node.js 22 LTS floor up front via a new
_require_node_22 helper that fails fast with a dedicated, actionable
message if 'node' is missing or reports < 22 — instead of failing
deep inside the toolchain with an opaque error.

All existing skip/force env vars are preserved:
OMNIGENT_SKIP_WEB_UI=true (opt out), OMNIGENT_BUILD_WEB_UI=1
(force rebuild), skip-when-bundle-exists, skip-when-web-absent.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 15:52:36 -07:00
Zeyi (Rice) Fan 815cdbef43 refactor(sandboxes): split host-launch contract from exec transport (#3337)
## Related issue

N/A

## Summary

- Split `SandboxLauncher` into a layered hierarchy: `SandboxLifecycle`
  (lifecycle + capabilities), `SandboxExecTransport` (run/put/stream/exec),
  `SandboxHostLauncher` (abstract start_host), and `ExecModelHostLauncher`
  (default start_host + run_background + materialize_workspace).
- `SandboxLauncher` is now a backward-compat alias for `ExecModelHostLauncher`.
- Migrated Kubernetes to inherit `SandboxHostLauncher` directly — it no
  longer needs a fake `run()` that raises; the entrypoint-as-host model
  (Pod boots running the host) has no exec transport at all.
- All 8 providers now declare an explicit `capabilities` property instead
  of relying on class-var derivation.
- Updated the registry's `isinstance` guard to check `SandboxLifecycle`
  (the common base) so both exec-model and entrypoint-as-host providers pass.
- Updated the Kubernetes test that asserted `run()` raises to assert the
  method does not exist instead.

## Test Plan

```bash
uv run pytest tests/onboarding/sandboxes tests/sandbox tests/cli/test_cli.py tests/server -k managed -q
pre-commit run --files <all changed files>
```

All 780 selected tests pass and pre-commit is clean.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Existing provider and CLI tests pass unchanged, confirming backward
compatibility. The Kubernetes test was updated to reflect that `run()` no
longer exists on the launcher. The registry test was updated for the
`SandboxLifecycle` guard message.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 14:51:55 -07:00
Dhruv Gupta e70f46578c fix(cli): resolve conversation ids pasted with stray punctuation in omni resume (#3465)
A conversation id pasted with surrounding punctuation (e.g. a trailing
period) crashed `omni resume` with a raw StatementError traceback from
the local store's Uuid16 bind. Strip the punctuation a paste drags
along — none of it can be part of a valid id — and resume the id the
argument contains, canonicalized to bare hex so downstream consumers
never see a legacy spelling. Error only when no valid id remains.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-28 21:40:44 +00:00
Zeyi (Rice) Fan 43f58d74cc feat(android): instrumented screenshot capture with Gradle-managed servers (#3389)
Add a `./gradlew recordScreenshots` task that captures four real-WebView
screenshots of the Android shell on a device/emulator, with zero manual
setup — Gradle starts and stops both the Vite dev server and an isolated
omnigent backend automatically.

Screens captured (app/build/screenshots/):
  - server_select.png — native ConnectActivity (server-entry screen)
  - home.png           — SPA landing page (sidebar closed)
  - session_list.png   — SPA home with sidebar drawer open (?sidebar=open)
  - session.png        — session/chat page with a real seeded user message

How it works:
  - startBackendServer: launches `omnigent server` in a throwaway mktemp
    data dir (OMNIGENT_DATA_DIR/CONFIG_HOME/DATABASE_URI isolated from
    ~/.omnigent, no-auth on loopback), pre-registers examples/kimi_hello.yaml.
  - seedDemoSession: POST /v1/sessions with an initial user message so the
    session screenshot has real content.
  - startWebDevServer: launches `node vite --host 127.0.0.1 --port 5173`
    directly (avoids spawning npm/pnpm whose grandchild is hard to kill),
    reuses an existing server if present. Vite proxies /v1 to the backend.
  - Per screen: pm clear + pre-grant POST_NOTIFICATIONS, then drive the real
    ConnectActivity → MainActivity flow via UI Automator (am instrument, not
    AGP's connectedDebugAndroidTest which auto-uninstalls and deletes the
    screenshot before we can pull), then adb pull the PNG.
  - stopWebDevServer / stopBackendServer: tear down both + clean temp dir.

The test (ScreenshotTest.kt) is pure UI Automator (out-of-process, black-box):
it launches the app from the launcher, types the server URL (base + route
path) into ConnectActivity, taps Connect, waits for the floating switch pill
as the "shell is up" signal, then captures via UiDevice.takeScreenshot. The
session-list screen uses the ?sidebar=open query param (AppShell reads it on
mount to open the conversation drawer) since uiautomator can't see inside the
WebView to tap the toggle button.

Dependencies added (pinned to the AGP 8.6 / compileSdk 35 toolchain):
  androidx.test:runner 1.6.2, :rules 1.6.1, ext:junit 1.2.1
  androidx.test.espresso:espresso-core 3.6.1
  androidx.test.uiautomator:uiautomator 2.4.0
Also sets testInstrumentationRunner = AndroidJUnitRunner.

Usage:
  ANDROID_SERIAL=emulator-5554 ./gradlew recordScreenshots
  open app/build/screenshots/*.png

Requires an emulator or unlocked device. The backend/Vite are fully managed
— no separate terminals needed.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 14:12:01 -07:00
Dhruv Gupta ef8423b3ab fix(release): harden finalize, cut, and homebrew against the 0.7.0-cycle failure modes (#3381)
* fix(release): harden finalize, cut, and homebrew against the 0.7.0-cycle failure modes

- finalize: docs sweep is advisory (never blocks publish), untagged drafts
  are rebound automatically, tag input is normalized
- release: bump-main gates in shell so CLI-dispatched boolean inputs cannot
  silently skip the post-release main bump
- update-homebrew: defer inside PyPI's 24h --uploaded-prior-to window and
  add a nightly catch-up that no-ops when the formula is current
- uv.lock: gitpython 3.1.50 -> 3.1.55 (clears 8 OSV advisories that tripped
  the Security Scan on every lock-touching PR)

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* fix(images): serialize image builds and raise the build timeout to 120m

At the v0.7.0 cut the rc1 (21:51) and final (21:57) tag builds ran
concurrently under SHA-keyed concurrency, raced each other's layer cache
cold, and the final build died on the 60m job timeout — no v0.7.0 or
latest images until a manual re-run a day later. A single serialized
group lets the later build reuse the earlier one's layers; 120m gives a
genuinely cold build headroom.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-28 14:04:50 -07:00
Zeyi (Rice) Fan 239e9cd36b chore(pnpm): migrate editors/vscode and deploy/cloudflare to the root workspace (#3390)
N/A

This is the final npm -> pnpm migration step for the OSS repo.

- Adds `editors/vscode` and `deploy/cloudflare` to `pnpm-workspace.yaml` so
  they use the root `packageManager: pnpm@11.15.1` and the shared
  `pnpm-lock.yaml`.
- Removes the per-package `package-lock.json` files and deletes the now-obsolete
  `scripts/normalize_package_lock_registry.py` hook/script.
- Merges the three remaining categories of build-script approvals into
  `pnpm-workspace.yaml` (`@vscode/vsce-sign`, `esbuild`, `keytar`, `sharp`,
  `workerd`) so `pnpm install` works at the workspace root.
- Migrates VS Code and release workflows to `setup-pnpm`:
  - `.github/workflows/vscode-extension-release.yml`
  - `.github/workflows/vscode-release-pr.yml`
  - `.github/workflows/release-omnigent.yml`
- Updates the lockfile regen workflows to refresh `pnpm-lock.yaml` instead of
  the old web-only `package-lock.json`:
  - `.github/workflows/oss-regenerate-and-smoke.yml`
  - `.github/workflows/oss-regen-on-comment.yml`
- Updates `editors/vscode/README.md`, `editors/vscode/PUBLISHING.md`, and
  `deploy/cloudflare/README.md` to reference pnpm commands.
- Removes the deprecated `.github/actions/setup-node` composite action.

- `pnpm install --frozen-lockfile --filter omnigent-vscode` passes locally.
- `pnpm install --frozen-lockfile --filter omnigent-cloudflare` passes locally.
- `uv run pre-commit run --all-files` passes (after dropping the package-lock
  registry hook).
- Inspected remaining `npm install` occurrences in workflows; the only survivors
  are transient agent CLI installs (`@anthropic-ai/claude-code`,
  `@openai/codex`) that are intentionally not tracked in the lockfile.

N/A

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

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

Verified the new workspace packages install from the frozen pnpm lockfile and
that the pnpm-only lockfile regen scripts produce a valid lock. The VS Code
workflow commands were checked against the package names/filters from
`pnpm-workspace.yaml`.

N/A

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 13:44:52 -07:00
Thomas Garnier eeb750c85e feat(sandbox): bind /proc in bwrap on Lakebox hosts (#3258)
The linux_bwrap sandbox mounts a fresh procfs under --unshare-pid, but a
Lakebox microVM masks /proc so that mount returns EPERM and the sandbox
fails to start. That blocked linux_bwrap — and the L7 egress management
built on top of it — on the Lakebox backend.

Bind the existing /proc instead of mounting a fresh one, but only on
outer sandbox backends known to be safe for it (allow-list: lakebox).
The backend is read from OMNIGENT_HOST_SANDBOX_BACKEND when set, else
autodetected via the /run/lakebox marker. Everywhere else the fresh-proc
mount and its fail-closed behavior stay unchanged.

Binding /proc exposes the outer process list and world-readable per-proc
files (cmdline/comm/stat/status). The retained user namespace still
blocks ptrace-gated files (environ/mem/maps/fd) and --unshare-pid still
contains signalling, so the leak is acceptable on a single-tenant
Lakebox microVM.

Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
2026-07-28 12:11:53 -07:00
Andrew Peltekci fa11e1ccf7 fix(kimi): report terminal status so a parent orchestrator is woken (#3166)
The kimi forwarder mirrored wire content but never posted an
external_session_status edge — the only native forwarder that didn't
(claude/codex/opencode/cursor all do). A kimi sub-agent therefore finished,
delivered its answer to the transcript, and left the parent waiting on it
forever: _mark_subagent_terminal_and_wake was never reached, so no result
ever landed in the parent's inbox.

kimi's wire has no turn.end row; its agent loop steps while step.end carries
finishReason 'tool_use' and stops on 'end_turn' (1:1 with turn.prompt across
every recorded session). Map that edge to external_session_status: idle,
carrying the turn's final assistant text — the runner delivers an empty
result when an idle edge forwards none.

Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:03:08 +00:00
Pat Sukprasert c41d40454e feat(harness): add NativeHarnessProvider seam foundation (PR 1.1) (#3239)
* feat(harness): add NativeHarnessProvider seam foundation (PR 1.1)

First, additive step of Phase 1 of the modular native-harness registry
(designs/harness-modular-registry-proposal.md). Introduces the behavior
side-channel that later PRs will dispatch through; no hub is rewired yet, so
this changes no runtime behavior.

- Add `NativeHarnessProvider` (frozen dataclass of dotted import-path strings
  for a native harness's lifecycle hooks) and the `native_providers` field on
  `HarnessContribution`, plus `native_providers()` / `native_provider_for_key()`
  accessors.
- Populate 11 built-in provider rows uniformly from the `omnigent.<key>_native`
  module layout (`run_<key>_native`, `_materialize_<key>_agent_spec`, and the
  `_auto_create_<key>_terminal` builder re-exported from `omnigent.runner.native`).
  Hooks that are still runner closures / inline dispatch (interrupt, stop,
  spawn-env, bridge-dir) stay None until those hubs migrate onto the seam.
- Add `omnigent/native_dispatch.py`: a lazy, per-path-cached resolver over the
  existing `load_object`, with `resolve` / `resolve_hook` / `resolve_hook_for_key`
  so hubs resolve a hook instead of branching on `key == "<x>"`. Import hygiene
  preserved — provider rows hold strings; only the resolver imports the target
  modules, and only at dispatch time.
- Tests: provider rows cover every native agent 1:1, required hooks are set, and
  every populated built-in path actually resolves to a callable (guards against
  a typo'd path or renamed symbol); resolver colon/dot forms, caching, and
  unset-hook / unknown-key None paths.

The validator still rejects community native metadata (Phase 2 flips it).

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

* docs(harness): add implementation-progress ledger (PR 1.1)

Add an append-only "Implementation progress" ledger to the modular-registry
proposal so each PR in the stack records its own status without editing the
plan tables (which would conflict across the 1.1→1.2→1.3 stack on every
rebase). Seed it with 1.1 (#3239, in review).

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 14:31:06 +00:00
Pat Sukprasert c7d7cedb91 [runner] Preserve sub-agent wake attribution (#3409)
* fix: preserve sub-agent wake attribution

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

* fix: harden runner event attribution

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

* fix: retry child dispatch without stale attribution

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

* fix: validate subagent send before actor lookup

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

* test: expect forwarded created_by field

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

* test: avoid escape closing codex config modal

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 14:28:17 +00:00
Pat Sukprasert 341652d6f8 [lint] Block hardcoded model pins (#3425)
* 🔧 chore(lint): Block hardcoded model pins

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* 🔧 chore(lint): Tighten model baseline guard

- Reject duplicate path/model rows so baseline allowances cannot silently accumulate.
- Document heuristic false-negative and multiline-config gaps, plus the bounded full-scan tradeoff.
- Add focused coverage for duplicate baseline validation.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* 🔧 chore(lint): Guard model scan configuration

- Cross-check the pre-commit trigger against the scanner's tracked roots, extensions, exclusions, and allowlist path to prevent silent drift.
- Share the source-extension set across path discovery and scanning.
- Report malformed allowlist counts with consistent path and line context; cover both review cases with focused tests.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 14:22:40 +00:00
Pat Sukprasert e093f56d82 fix(codex): persist permission mode across host resume (#3411)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 13:25:11 +00:00
Tomu Hirata 627c8ee59e fix(ui): sub-agent sessions never show reconnect modal when runner dies (#3414)
* fix(ui): sub-agent sessions never show reconnect modal when runner dies

A sub-agent session with a dead runner classified as local_stranded,
which disabled the composer and showed the CLI reconnect modal — a
flow designed for top-level host-bound sessions. Sub-agents have no
host binding and can't be relaunched from a CLI command; they recover
via their parent's live runner (server-side heal, #3151).

- Add kind field ("default" | "sub_agent") to Session type and
  map it from the wire in sessionFromWire
- Thread kind through LivenessRow and livenessRowFromSession
- Add row 7a in useSessionLiveness: sub_agent with dead runner →
  runner_asleep (composer open) instead of local_stranded

Fixes #3413

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

# Conflicts:
#	web/src/hooks/useSessionLiveness.ts

* fixup: add kind and backgroundTaskCount to sessionsApi test fixture

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

* test(e2e_ui): sub-agent dead runner keeps composer open, no reconnect modal

Regression test for #3413: a sub-agent session with a dead runner was
classified as local_stranded, showing the CLI reconnect modal and
disabling the composer. After the fix (kind=="sub_agent" → runner_asleep)
the composer stays enabled and the "Agent disconnected" banner is absent.

Creates a real child session (parent_session_id set → kind="sub_agent"),
patches the browser's health poll to report runner offline, and asserts
the composer is usable and no reconnect banner appears.

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

* fixup: splice kind from session snapshot into livenessRow when sidebar conv present

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

* fixup: expose kind in SessionResponse so the UI can detect sub_agent sessions

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

* fixup: don't re-initialize session on heal — parent runner already hosts the child

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

* fixup: re-init session for native sub-agents, skip for SDK sub-agents

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

* fixup: regenerate openapi.json for kind field in SessionResponse

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

* fixup: update heal docstring + add SDK sub-agent no-init test

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 21:53:08 +09:00
Tomu Hirata d16ad3c7c0 fix(server): heal sub-agent stale runner_id on direct message-send (#3151)
* 🐛 fix(server): heal sub-agent stale runner_id on message-send

A sub-agent copies its parent's runner_id at creation and is never
repointed when the parent's runner is relaunched. The message-send path
returned a permanent 503 for any sub-agent whose runner had
idle-timed-out, even while the parent's replacement runner was healthy
(host_id is None short-circuits all existing relaunch paths).

- Extract _heal_subagent_runner_binding_via_parent from
  _recover_subagent_status_forward_via_parent: walks the ancestor chain
  (immediate parent → root), waits for the live runner tunnel, calls
  replace_runner_id on the child, returns the live client
- Wire the heal into the message-send path after the managed-launch
  rendezvous, guarded to kind=="sub_agent"; sets
  _runner_needs_session_init=True so the child's harness is initialized
  on the healed runner before dispatch
- Refactor _recover_subagent_status_forward_via_parent to delegate
  binding repair to the shared helper (no behavior change for the
  status-forward path)
- Add regression tests: heal succeeds, no-live-ancestor preserves 503,
  top-level sessions not treated as recoverable children

Fixes #3067

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

# Conflicts:
#	omnigent/server/routes/sessions.py

* fixup: rebase onto main, apply heal to routes_events.py, fix lint

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

* fixup: fix test payload format and monkeypatch targets for routes_events

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 18:20:34 +09:00
Pat Sukprasert b7ab0ba548 test: stabilize codex model metadata e2e (#3410)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 16:07:25 +07:00
Pat Sukprasert fe55ad2cf2 Import OpenClaw acpx agents during setup (#3354)
* Import OpenClaw acpx agents during setup

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

*  feat(cli): Add one-shot OpenClaw launch

- Resolve one registered agent into a temporary ACP launcher
- Keep user config unchanged and fail clearly on unknown agents

Refs #3351

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

* 🐛 fix(openclaw): Harden config bridge imports

- Parse wrapped configs with a real JSON5 implementation
- Deduplicate mirrored registries and preserve slug collisions
- Reject malformed ephemeral ACP payloads with clear errors

Refs #3351

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

* 🐛 fix(openclaw): Handle invalid config sources

- Treat filesystem and parser recursion failures as soft discovery errors
- Preserve valid sibling agents when one entry has malformed args
- Quote executable paths so ACP argv parsing handles spaces

Refs #3351

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

*  feat(openclaw): Let users choose import source

Always show the OpenClaw import action during setup, offer detected registries or a user-selected file, and reject unrelated files without changing Omnigent config.

Refs #3351

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

* 🐛 fix(openclaw): Unify registry parsing

Parse both acpx and wrapped OpenClaw registries as JSON5 regardless of discovery path, and document why the setup status-width floor must follow available terminal space.

Refs #3351

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 08:48:19 +00:00
Pat Sukprasert 624411a334 fix(ci): install CLIs under RUNNER_TEMP so a repo-root package.json can't hoist them (#3412)
The AI-agent workflows install the Claude Code / Codex CLIs with a bare
`npm install` after `cd`-ing into a workspace subdir (`.cc-cli` / `.codex-cli`)
that has no package.json of its own. npm then walks up to the nearest ancestor
package.json to resolve the project root.

Once a repo-root package.json was added, that ancestor became the repo root, so
the install landed in `${GITHUB_WORKSPACE}/node_modules` instead of the subdir.
The follow-up `node node_modules/@anthropic-ai/claude-code/install.cjs` (run from
the empty subdir) then failed with MODULE_NOT_FOUND, breaking Polly review,
issue/security triage, doc-sync, and the run-omnigent-agent action. The
`added 2 packages` line (claude-code has zero deps) was the tell that npm had
reconciled the root tree rather than an isolated install.

Install into `${RUNNER_TEMP}/omnigent-{cc,codex}-cli` instead — outside the
checked-out tree, so no ancestor package.json can ever capture the install. This
matches the pattern e2e-ui.yml and flake-stress-ui.yml already use.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 15:19:49 +07:00
Tomu Hirata d0450f8d7e ci: bump @anthropic-ai/claude-code 2.1.170→2.1.212 (#3404)
* ci: bump @anthropic-ai/claude-code 2.1.170→2.1.212

v2.1.170 has a corrupted npm cache entry on GitHub Actions runners
causing install.cjs to be missing after `npm install`. Bumping to the
current stable (2.1.212) forces a fresh fetch and clears the bad entry.

Also bumps the ci-deps/package.json pin (was 2.1.163) and the
run-omnigent-agent action default to keep everything consistent.

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

* ci: update pnpm-lock.yaml for claude-code 2.1.212

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 16:47:38 +09:00
Tomu Hirata 2cff2cea11 fix(codex-native): share plugins/cache into per-session homes (#3401)
Codex materializes its versioned plugin store (openai-curated templates,
browser, presentations, ...) into $CODEX_HOME/plugins/cache on session
start. Because codex-native points CODEX_HOME at a private per-session
home, codex re-materializes ~44 MB of identical plugin data into every
session — the dominant on-disk cost once the upstream logs_2.sqlite TRACE
bloat (openai/codex#28224) is fixed in codex >= 0.142.0.

Symlink plugins/cache from the shared source home into each private home,
mirroring the existing skills-symlink pattern. The cache is content-
addressed read-only reference data (verified byte-identical to the shared
copy), so unlike config.toml it needs no per-session isolation. Skipped in
minimal (title-sidecar) mode, which runs no plugins. Best-effort: a symlink
failure logs and lets codex repopulate its own copy.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 16:04:26 +09:00
Pat Sukprasert 23465441d5 feat(setup): Add Antigravity sign-in (#3391)
- Launch bare agy for Google OAuth and verify with agy models\n- Keep Gemini API-key setup available alongside native sign-in

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 13:44:36 +07:00
Zeyi (Rice) Fan 31183fbe92 chore(pnpm): approve build scripts for ci-deps dependencies (#3386)
Running a full workspace install without filters complained about ignored
build scripts for @anthropic-ai/claude-code, @google/genai, and protobufjs.
These come from the .github/ci-deps package and are legitimate; approving
them lets Scope: all 4 workspace projects
Already up to date
Done in 194ms using pnpm v11.15.1 / undefined
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command "dev" not found at the workspace root run scripts
instead of erroring.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 17:37:55 -07:00
Sabhya Chhabria 3e139dab57 fix(claude-native): never launch a bare family alias a gateway rejects (#3378)
* fix(claude-native): never launch a bare family alias a gateway rejects

A family alias (opus/sonnet/haiku/fable) selected on a provider config
whose tier has no ANTHROPIC_DEFAULT_*_MODEL pin is canonicalized by
Claude Code to an Anthropic id (e.g. claude-opus-4-8) that gateways
404, failing session start with "There's an issue with the selected
model". Resolve unpinned aliases to the provider's default model in
resolve_claude_native_model_selection, which launch, sticky handoff,
and /model injection all route through.

Also stop offering the static subscription alias rows to provider
configs with no pins: the picker now lists the one model the config is
known to route.

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

* refactor: trim the unpinned-alias fix to its minimal form

Shorten the resolver docstring and the pin-less catalog fallback, drop
e2e assertions already implied by the single-row count, and fold the
three alias-passthrough regression tests into one.

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

* fix(claude-native): scope alias remap to endpoints that reject canonical ids

Review feedback on the unpinned-alias guard:

- Only rewrite an unpinned family alias when the config routes through a
  gateway/Bedrock endpoint; the Anthropic API (api.anthropic.com or no
  endpoint override) resolves aliases natively, so API-key providers keep
  their alias routing and the static picker catalog.
- Respect managed-settings tier pins: Claude Code applies them to the
  spawned process, so a managed pin means the alias still routes.
- The runner's /model handler now resolves the session launch config
  instead of reading the in-memory cache, so alias resolution survives a
  runner restart (cold cache previously skipped the remap).

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

---------

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 17:29:07 -07:00
Zeyi (Rice) Fan 355556dff4 chore(ci): migrate .github/ci-deps to pnpm and update docs for pnpm dev workflow (#3379)
- Add .github/ci-deps to the root pnpm workspace so it uses the shared
  pnpm lockfile and install machinery.
- Regenerate pnpm-lock.yaml entries for the e2e-ci-deps package.
- Replace npm install --ignore-scripts in ci.yml and flake-stress-e2e.yml with
  pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps.
- Update electron-build.yml to use setup-pnpm and filter installs for web and
  web/electron.
- Update omnidev source so the local dev supervisor installs and runs Vite
  with pnpm.
- Update developer docs (README.md, CONTRIBUTING.md, web/README.md,
  web/electron/README.md, dev/omnidev/README.md, tests/e2e_ui visual/README.md
  and COVERAGE_GAPS.md) to reference pnpm commands.
- Add a minimal root package.json with packageManager: pnpm@11.15.1 and remove
  the explicit version from .github/actions/setup-pnpm so CI uses the same
  source of truth.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 16:58:33 -07:00
Elliot Sun 38cc498b75 fix(onboarding): detect agy settings.json as login fallback on macOS (#3289)
* fix(onboarding): detect agy settings.json as login fallback on macOS

On macOS, agy 1.1.7+ stores OAuth credentials in Keychain and writes
only ~/.gemini/antigravity-cli/settings.json (no oauth_creds.json).
The existing gemini_auth_has_credential() missed this and falsely
reported 'harness antigravity-native is not configured'.

Accept the existence of settings.json as a fallback signal when no
token files are found. This is safe because the caller
(resolve_native_antigravity_launch) uses it only for an informational
warning — agy always re-drives OAuth on first run regardless.

- Update gemini_auth_has_credential() with settings.json fallback
- Update docstrings to document the third detection path
- Update warning message in antigravity_native_launch.py
- Add unit test for settings.json-only detection
- Fix _GEMINI_DIR isolation in existing test

Signed-off-by: ElliotSun <elros1109@gmail.com>

* fix(onboarding): prove agy login via CLI, not settings.json existence

The macOS lockout this fixes is real: agy 1.1.7+ keeps OAuth in the
Keychain and writes no token file, so the file-only check reported
antigravity-native as unconfigured and connect.py refused to spawn a
runner for a user who was in fact signed in.

Accepting the bare existence of ~/.gemini/antigravity-cli/settings.json
as the fallback signal does not work, because omnigent creates that file
itself: the CLI launch path calls ensure_agy_feedback_survey_disabled
under the real home before agy starts, and build_agy_launch emits no HOME
override. One `omni antigravity` run therefore satisfied the credential
gate forever, on every platform — turning a hard launch gate into a
no-op and letting a runner spawn that dies on its first turn. That is
worst on headless hosts, where agy's OAuth prompt has no TTY.

Ask the CLI instead. `agy models` exits 0 only when signed in and reads
the credential wherever agy stored it, Keychain included, so nothing
omnigent writes can satisfy it. This mirrors ambient._claude_login_detected,
which already solves the identical Keychain split for Claude Code, and
reuses the probe harness_install already wires as the gemini family's
status command.

The fallback is gated on macOS: Linux writes a real token file, so its
absence is a true negative there and the fallback would only add a
subprocess while weakening a signal that works. Failures — missing
binary, non-zero exit, timeout, unreadable home — all read as False,
because readiness must never raise.

Content inspection of settings.json was the alternative considered. It
was rejected as unverifiable from here: no key in that file is known to
mark a completed sign-in on 1.1.7, so keying on one risks reintroducing
the very lockout being fixed.

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

* docs(skills): note agy's macOS Keychain credential in the e2e pre-flight

The pre-flight tells the reader agy's token lives under ~/.gemini, which
leaves a Mac developer on agy 1.1.7+ hunting for a file that is never
written. Name the Keychain case and the `agy models` fallback that
gemini_login_detected() now uses there.

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

---------

Signed-off-by: ElliotSun <elros1109@gmail.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:49:59 -07:00
samarmstrong 08056475b0 fix(cursor-native): auto-accept lingering tool gates under --yolo (#2338)
* fix(cursor-native): auto-accept lingering tool gates under --yolo

cursor-agent's Run Everything mode still sometimes leaves pendingToolCall
markers long enough for Omnigent to mirror ApprovalCards and stall a
piloted parent. When the session launched with --yolo/--force/-f, accept
those tool gates in-pane instead of parking a web card; AskQuestion still
surfaces as deliberate human input.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): satisfy ruff format and PIE810 on yolo args

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): make yolo auto-accept bounded and fail-closed

Auto-answering a tool-approval gate is a safety boundary, so the accept path
now refuses to act on anything it cannot confirm, and always has a way out.

The accept was previously a blind keystroke loop: it never checked that a
prompt was on screen, recorded a send to a dead pane as a success, and had no
attempt cap or fallback. A gate that `y` does not clear therefore degraded from
a visible stall into a literal `y` typed into cursor's composer every two
seconds for the life of the session, with no card ever surfaced.

The accept key now goes out only while `capture_cursor_pane` shows cursor's
parenthesised accept hint, at most three times, and at most once per poll pass
(cursor renders one prompt at a time). A dead pane, a send tmux rejects, or a
gate still pending after the budget all fall back to the same ApprovalCard the
non-yolo path shows, so the worst case is the visible stall we have today.
Because a call accepted this way is never seen by a human, the INFO line now
carries an argument preview: it is the only record Omnigent approved the call.

`cursor_launch_args_enable_yolo` was failing open in the same spirit —
`--yolo=false` and `--force=false` both read as enabled, because only the
presence of the `=` form was checked. Explicit off-values are now honoured, and
a bare `--` ends the flag scan so a `-f` in the prompt text that follows is
text rather than a request to bypass approvals.

Tests cover the bounded retry, the fallback to a card, an idle pane, a dead
pane, an undelivered keystroke, an explicit non-yolo session, and the
off-value / end-of-flags argv cases. The design doc gains a section on the
fail-closed contract and drops its claim that Omnigent never sends a verdict of
its own initiative; its stale `Code:` pointer at the runner wiring is refreshed
to where that wiring now lives.

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

* fix(cursor-native): re-apply yolo wiring where auto-create now lives

`_auto_create_cursor_terminal` moved out of `omnigent/runner/app.py` into
`omnigent/runner/native/orchestration.py`, which left `app.py` a re-export
shell and this branch's wiring hunk applying to code that no longer runs.
Derive `auto_accept_approvals` from `launch_config.terminal_launch_args` at the
live call site instead.

This kwarg is the only thing that turns the in-pane auto-accept on, and it is
one line inside a large function, so a future move can drop it and leave the
feature inert with the whole suite green. Pin it: the auto-create harness now
captures the elicitation supervisor's kwargs, and a parametrized test asserts
the derived stance for `--yolo`, `--force`, `-f`, `--yolo=false`,
`--auto-review`, and no args. Deleting the kwarg fails all six.

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

---------

Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:48:10 -07:00
Yi Lyu 2f39f04e1f fix(codex-native): surface launch routing in the thread-startup-timeout error (#2745) (#2843)
* fix(codex-native): surface launch routing in the thread-startup-timeout error (#2745)

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>

* Fix checks

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>

---------

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
2026-07-27 23:42:24 +00:00
Zeyi (Rice) Fan 7367654b47 fix(android): resolve adb from SDK dir in runDebug task (#3376)
The runDebug, listDevices, and reverseProxy Exec tasks called
`commandLine("adb", ...)`, relying on adb being on PATH. The Gradle
daemon is long-lived and may have been started from an environment
whose PATH doesn't include platform-tools (e.g. homebrew's
android-commandlinetools), so the spawn fails with
"A problem occurred starting process 'command 'adb''" — even though
AGP's own installDebug succeeds because it resolves adb from the
SDK directory internally.

Resolve adb from android.sdkDirectory instead, mirroring AGP, so
the custom launch tasks are independent of the daemon's PATH.
2026-07-27 23:30:24 +00:00
Harry Su 4c3fcdb4f6 docs(DBSPEC): remove stale DBOS/tasks references (#2329)
* docs(DBSPEC): remove stale DBOS/tasks references

The tasks table and DBOS were removed (migration
b9c1d2e3f4a5_drop_tasks_table), but DBSPEC.md still described the
old DBOS-backed workflow design: the tasks table schema, the
try_deliver/close_inbox steering handshake, and the TaskStore
method mapping. Updated the doc to match current state — turn
state now lives in-memory in the runner (_active_turns,
_session_message_buffers), and conversation_items.response_id is
just an app-generated grouping id with no backing table.

Also added the created_by column to conversation_items, which
existed in code but was missing from the doc.

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>

* docs(DBSPEC): correct FK section — no DB-enforced FKs, cleanup is explicit app code

Addresses the blocking review: the previous revision claimed an ON DELETE
CASCADE FK on conversation_items.conversation_id, but
p1a2b3c4d5e6_remove_all_fks dropped every FK (Rule R032) and
delete_conversation cleans up children before parent explicitly. Also
precision-fix response_id as harness- or app-generated per review.

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>

* docs(DBSPEC): correct table count, deletion order, and position allocator

The accuracy pass left five claims that don't match the code:

- The opening line said four tables in the default schema. There are 17
  in `db_models.py`, and none sets an explicit schema — the same doc names
  labels, comments, and policies as tables a hundred lines later. Scope the
  sentence to the four tables this doc covers and point at the models as the
  full list.
- `delete_conversation` was described as deleting comments and policies
  before the conversation rows. It uses two transactions: the AP one drops
  FTS rows, items, labels, and the conversation rows; a second best-effort
  transaction then cleans up comments, policies, session permissions,
  conversation metadata, and session-scoped agents *after* the conversation
  is gone. The doc also omitted three of those tables and hid the
  best-effort tradeoff the method's own docstring calls out.
- "Turn state is not persisted to this schema at all" was overstated. The
  authoritative state is in-memory, but `persist_live_status` mirrors
  `live_status` / `pending_elicitation_count` onto
  `omnigent_conversation_metadata` so any replica can render session status.
- The "Delete agent" row documented cancelling in-flight turns for the
  agent's live sessions. No such mechanism exists: `AgentStore.delete` is a
  bare row delete with no production caller and no HTTP route, and
  session-scoped agent rows are removed by `delete_conversation`.
- The position allocator no longer runs `SELECT MAX(position) + 1`.
  `append()` reads and advances the `conversations.next_position` counter
  under `_lock_conversation`, keeping allocation O(1); the `MAX(position)`
  scan survives only as a one-time backfill for pre-counter conversations.

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

---------

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:24:17 -07:00
Zeyi (Rice) Fan dc97ade9f5 chore(web): migrate web and electron to root pnpm workspace (#3328)
- Define a root pnpm-workspace.yaml with web/ and web/electron/ packages.
- Move npm overrides from web/package.json into workspace overrides, using a
  shared catalog: for react, react-dom, and shiki.
- Preserve 7-day dependency cooldown via settings.minimumReleaseAge: 10080.
- Delete web/package-lock.json and web/electron/package-lock.json; add the
  generated root pnpm-lock.yaml.
- Update web/electron/package.json scripts to use pnpm --filter web run build:overlay.
- Remove web/.npmrc and web/electron/.npmrc; no committed .npmrc (CI forces the
  public registry via env var).
- Add .github/actions/setup-pnpm so all workflows can share a pinned pnpm
  11.15.1 + Node setup.
- Convert lint.yml and web-tests.yml to pnpm; update ui-snapshot and e2e-ui
  workflows.
- Update the web-prettier pre-commit hook to run web/node_modules/.bin/prettier
  directly when present.
- Update justfile to prefer pnpm for Electron recipes and lockfile normalization.
- Ensure remaining npm-based workflows (editors/vscode/, .github/ci-deps/,
  deploy/cloudflare/) are untouched and continue to work.
- Add pdfjs-dist worker URL import so Vite emits the worker asset under pnpm's
  hoisted node_modules layout.
- Force shiki and its first-party packages into a single build chunk to avoid a
  Cyclic top-level import that produced a 'flatMap' runtime error in Monaco.
- Pin build-tool versions to the legacy npm lockfile (vite 8.1.0, tailwindcss
  4.3.1, jiti 2.7.0, lightningcss 1.32.0, postcss 8.5.15) so bundler behavior
  stays consistent with the pre-migration builds.
- Update tests/e2e_ui/test_pwa_build.py to omit the now-incorrect -- separator
  when forwarding --outDir to pnpm run build:embed.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 16:22:30 -07:00
Edwin He 1f66a0914b fix(native): apply routed model with the message, not a racing event (#3257)
On a claude-native session with intelligent routing on, the routed model
was selected but the user's first message was silently dropped — the model
switched, no error surfaced, but no turn ran.

The server issued TWO unsynchronized writes to the same tmux pane: a
standalone model_change event (which typed /model <routed> into the pane)
AND, separately, the user's message (typed in via inject_user_message).
These raced. The message keystrokes landed mid-switch, inject_user_message
never saw its draft, hit its submit-blind fallback, and returned without
error. Model applied, message gone.

Fix: remove the second writer by folding the switch into the message turn,
mirroring how the SDK/pi path already applies the routed model as one
operation.
- Executor (ClaudeNativeExecutor.run_turn): the routed model already
  arrives in ExecutorConfig.model and was being discarded. It is now
  applied: when config.model differs from the pane's model, type /model
  then inject the message — both under the existing _inject_lock, in
  order, exactly once. inject_user_message's prompt-ready gate + verified
  submit then guarantee delivery. _applied_model is seeded lazily from
  read_launch_model so turn 1's routed pick is compared against the spawn
  model rather than blindly re-issued.
- Server (_sessions/orchestration.py): the routed model rides in-band on
  the message (model_override, an extra field the harness MessageEvent
  forwards into ExecutorConfig.model), and the separate racing model_change
  POST is dropped. The manual composer /model picker path (PATCH ->
  model_change) is untouched.

Adds three executor tests: /model precedes the message in order under one
lock; no /model without a routed model; no /model when already on the
routed model. The ordering test fails against the prior discard-config
behavior.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-07-27 23:22:15 +00:00
omnigent-ci[bot] 326bd5939f Bump version to 0.8.0.dev0 (#3377)
* Bump version to 0.8.0.dev0

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore(release): keep uv.lock at main's shape, stamp workspace versions only

The bump workflow's full relock rewrites every entry with new-uv metadata
churn; restoring main's lock and stamping just the workspace versions keeps
the PR reviewable. Workspace package blocks verified identical to the
relocked version.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-27 23:20:42 +00:00
Sabhya Chhabria 19ca227bc7 feat(polly): launch supported children in goal mode (#3362)
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-27 16:00:19 -07:00
SatoTaiga 50aa69420b fix(codex): attribute per-model usage for turns with no pinned model (#3287)
* fix(codex): attribute per-model usage for turns with no pinned model

codex_executor's TurnComplete.usage never carried a "model" field, unlike
every other relay executor (claude-sdk, cursor, copilot, openai-agents,
pi). For a codex-harness agent that pins no llm.model (e.g. Debby's
gpt head, which deliberately defers to the harness/provider default),
_accumulate_session_usage's model-resolution fallback chain had nothing
to resolve to, so the turn's flat token/cost totals still accumulated
but session_usage.by_model silently never got an entry for it.

Stamp the turn's resolved model (already in scope as run_turn's `model`
argument) onto the usage dict extracted from tokenUsage/updated, mirroring
claude_sdk_executor's observed_model pattern.

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>

* test(sessions): add regression test for codex per-model usage attribution

Exercises the real _accumulate_session_usage and GET /v1/sessions/{id}
API against a codex-harness agent with no pinned llm.model (Debby's gpt
head's exact shape): a usage delta with no "model" key still accumulates
the flat total but leaves by_model empty (the bug), while one carrying
"model" (as codex_executor.py now stamps it) gets a by_model entry that
also surfaces through the session snapshot the web UI's cost panel reads.

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>

---------

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>
2026-07-27 15:38:08 -07:00
omnigent-ci[bot] 40ad8b73ee docs(changelog): record v0.7.0 (#3373)
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 15:12:23 -07:00
Zeyi (Rice) Fan 92b1e10e53 feat(onboarding): enforce supported CLI version ranges for native harnesses (#3335)
N/A

- Added `min_version` and `max_version_exclusive` to `HarnessInstallSpec` and made `harness_cli_installed` probe `--version` when bounds are declared, so setup and dispatch fail loud for outdated CLIs.
- Implemented generic `--version` parsing + PEP 440 comparison with date-version normalization so Cursor and Hermes calendar-version strings compare correctly.
- Wired code- and changelog-derived version floors for all CLI-backed native harnesses (e.g. Claude >=2.1.161, Codex >=0.137.0, Cursor >=2026.06.02, Kimi >=1.47.0, Hermes >=2026.06.05).
- Updated the CLI setup overview and install prompt to show "Needs upgrade" and the detected/declared versions instead of claiming a present-but-outdated CLI is "not installed".
- Added the `version-too-low` readiness reason and surfaced it in the web UI badge/notice; also made Cursor native auth-aware so it now reports `needs-auth` when installed but not logged in.
- Fixed the readiness-layer lookup so `version-too-low` correctly surfaces for all native harnesses that declare a version floor (Claude, Cursor, OpenCode, Kiro, etc.) instead of falling back to `binary-missing`.
- Preserved the existing `antigravity-native` credential gate: an installed `agy` CLI without a stored Gemini credential still reports not-ready.
- Added E2E UI coverage for the new `version-too-low` warning and updated readiness unit tests for version-bound and credential-bound behavior.

```bash
uv run pytest tests/onboarding/test_harness_install.py \
              tests/onboarding/test_harness_readiness.py \
              tests/cli/test_configure_models.py \
              tests/test_codex_native.py -q

npm run --silent test -- --run src/lib/harnessSetup.test.ts src/shell/NewChatDialog.test.tsx
```

N/A — the change is mostly backend/UX copy; no new visual components.

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

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

Manual verification: ran targeted backend/web test suites after each change and confirmed `omnigent setup`/`harness_cli_installed` now report “installed (vX) but not supported” rather than “missing” for outdated CLIs.

Native harness setup now checks the installed CLI version and prompts to upgrade if it is too old.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 14:22:06 -07:00
David O'Keeffe 7048f7a38b fix(hermes): introspect state.db schema to survive cross-version column drift (#2774)
Signed-off-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
Co-authored-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
2026-07-27 20:55:17 +00:00
nhsdb 2c5d50b68b egress proxy: trust loose capath CAs, not just the cafile bundle (#3264)
The MITM egress proxy verifies upstream TLS against the system trust
store built by _system_ca_bundle(). It read only the consolidated
cafile (get_default_verify_paths().cafile/openssl_cafile) and ignored
the capath directory. Corporate MDM / IT-managed roots are commonly
installed as loose files under capath (with hashed symlinks) rather than
merged into the cafile, so they were missing from the proxy's trust
store. Any upstream host whose chain relies on such a root then failed
verification (e.g. a corp-intercepted github.com returned 502 from the
proxy) even though the host's own tools trusted it.

Read capath too: concatenate the loose PEM certs from the capath
directory onto the cafile bundle (dedup by resolved path, skip non-PEM
entries), keeping the certifi fallback when neither yields any certs.

Added tests: a CA present only as a loose capath file lands in the
bundle, and non-PEM files in capath are skipped.

Signed-off-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
Co-authored-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
2026-07-27 13:34:51 -07:00
nhsdb 3d58649e77 bwrap sandbox: bind /etc/alternatives so update-alternatives tools resolve (#3263)
Tools invoked by generic name (awk, python3, editor, pager, ...) resolve
through /usr/bin/<name> -> /etc/alternatives/<name> -> real binary. The real
binaries already live under the mounted /usr, but /etc/alternatives was not
bound, so the intermediate symlink node was missing inside the jail and the
lookup failed with 'command not found'.

Bind /etc/alternatives read-only in the default _DEFAULT_ETC_DIRS list,
alongside the existing /etc/ssl and /etc/ca-certificates dir binds. It is a
directory of symlinks (no secrets); read-only means the mapping cannot be
repointed, and every target is a binary already exposed under /usr, so this
grants no new capability -- it only restores standard name resolution.

Linux (bwrap) backend only; darwin_seatbelt is unaffected by this mechanism.

Signed-off-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
Co-authored-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
2026-07-27 20:29:27 +00:00
Anthony Ivan 638df430be fix(codex-native): keep task plans out of chat (#3249)
CI / gate (push) Failing after 1s
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 10:57:57 -07:00
Jakub Majorek 09a035ebb3 🐛 fix(usage): attribute native per-model cost by delta, not cumulative total (#3223)
Native harnesses (claude-native / codex-native) report a cumulative
SESSION total, not a per-model split. `_persist_native_cumulative_usage`
SET each active model's `by_model` bucket to the whole running total, so a
session that switched models mid-run double-counted the shared baseline:
the previous model kept its last cumulative snapshot while the new model
was set to the full total, and summing the buckets exceeded the session
total (e.g. total $11.91 but opus $10.80 + sonnet $11.91).

Attribute only each report's growth (new - old) to the currently-active
model instead, mirroring the relay path's per-model delta accumulation.
Per-model token and cost buckets now hold each model's own usage and sum
to the flat session total across model switches. Deltas are clamped >= 0
so a lowered / rebased report never claws usage back out of a bucket (the
flat totals are likewise monotonic-clamped).

Read-only reporting (`omni usage`, the web session sidebar) needs no
change — it reads `by_model` verbatim, so corrected data flows through.
Existing sessions keep their already-stored buckets; this corrects
attribution for turns recorded after it ships (not backfillable).

Co-authored-by: Isaac

Signed-off-by: Jakub Majorek <majorek.jakub@gmail.com>
2026-07-27 16:00:46 +00:00
Cathy Yin 7dbdb821d3 feat(web): add a harness credential from the New Chat setup dialog (M3 frontend) (#3090)
* feat(web): add a harness credential from the New Chat setup dialog (M3 frontend)

Frontend for Setup From the Web UI — turn a yellow needs-setup harness
green from the browser (Claude/Codex/Pi) via an inline equal-weight auth
form (adopt / subscription signpost / API key / gateway), plus the setup
dialog UX cleanups. Gated behind the existing harness_install_enabled cap.

Rebased onto latest main (the M3 backend #3088 is now upstream, so only
web/ + follow-up backend fixes remain) and folded in the Polly review
notes: stable option keys, clear secret fields on save, and a note that
default_model/wire_api are backend-accepted but reserved for a follow-up.

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

* fix(web): scope useHosts refocus-refetch to the setup flow (Polly review)

staleTime:0 + refetchOnWindowFocus was app-wide across ~8 useHosts
consumers, bumping /v1/hosts volume on every refocus. Make it an opt-in
refetchOnFocus flag; only the setup dialogs (NewChatDialog, HarnessSetupDialog)
that need live readiness recovery pass it. Others keep the 30s stale window.

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

* fix(web): guard the credential form against double-submit + close test gaps

Address Pat's review:
- Gate both form onSubmit handlers on !busy so hitting Enter in the field
  during an in-flight save can't re-POST the secret (the Save button was
  already disabled, but the keyboard path wasn't guarded).
- Add a double-submit-guard test, plus direct hook tests for
  useStoreCredential (path/body split, JSON detail + non-JSON error parse,
  cache patch + detect invalidation) and useDetectedCredentials
  (GET/parse, empty-body fallback, enabled/host gating).

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

* fix(web): let Pi adopt an openai-family credential too (Polly review)

Pi consumes both anthropic and openai and the daemon adopts a detected
credential under its OWN family, so a host with only $OPENAI_API_KEY could
back Pi — but the adopt filter scoped to Pi's single write-default family
(anthropic), hiding that affordance. Add harnessCredentialAdoptFamilies
(Pi -> both families) and filter the adopt row on it; the paste/gateway
paths and the cross-family guard for Claude/Codex are unchanged.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-27 17:33:29 +07:00
Yi Lyu c1acaf885f fix(policies): scan text attachments for PII at the request gate (#2927)
Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 10:19:38 +00:00
Jackson Zheng cd23178ad4 Polish sidebar header spacing (#3346)
* Polish sidebar header spacing

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui-snapshot): update visual baselines

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-27 10:15:48 +00:00
Tomu Hirata 0e0bc901b5 fix(codex-native): carry hook trust across private CODEX_HOME copy (#3343)
* fix(codex-native): carry hook trust across private CODEX_HOME copy

When codex-native provisions a per-session private CODEX_HOME and copies
config.toml into it, the [hooks.state] keys inside the copy still reference
the global ~/.codex/ paths. Codex keys trust records by the absolute path of
the hooks file, so every key misses and Codex opens an interactive "Hooks need
review" prompt on every launch. Headless sub-agents can never answer it, so
the app-server never emits thread/started and the run dies on the 15s timeout.

Fix: two changes to _populate_codex_home_config:

1. Symlink hooks.json from the global home into the private home (alongside
   auth.json). This makes the user's hooks reachable at the private path.

2. After copying config.toml, rewrite [hooks.state.*] key path prefixes from
   source_dir to target_dir. The hash values are left untouched, so trust is
   neither widened nor weakened — it is only carried across the copy that
   Omnigent itself performs.

Fixes #3268.

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

* fix: gate hooks.json symlink on not minimal_config; drop redundant re import

The minimal_config path rebuilds config.toml from scratch with only
model_provider/model_providers/profiles — no [hooks.state] entries.
Symlinking hooks.json there with no trust state re-introduces the
interactive trust prompt for the title worker. Gate the symlink (and
the trust-key rewrite that gives it meaning) on not minimal_config.

Also remove the redundant `import re as _re` inside
_retarget_codex_hook_trust_keys; re is already imported at module level.

Addresses Polly review feedback on #3343.

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

* fix(codex-native): flush accepted hook trust back to global config on close

When a user accepts the hook-trust prompt inside a session, Codex writes
[hooks.state] entries into the per-session private config.toml — but those
are discarded when the session ends because the private CODEX_HOME is
ephemeral. So the prompt reappears on every launch.

Fix: in CodexNativeAppServer.close(), call _merge_codex_hook_trust_back to
read [hooks.state] from the private config.toml, translate the path keys
from the private home back to the global ~/.codex/ prefix, and upsert them
into ~/.codex/config.toml atomically. The next session's _populate_codex_home_config
copies the global config (now with the trust entries), and
_retarget_codex_hook_trust_keys translates the paths forward to the new
private home — so Codex sees the hooks as already trusted and skips the prompt.

The write is best-effort: any failure is logged as a warning rather than
raised, since the session has already ended.

Fixes #3268.

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

* fix: assign tmp before try block to avoid unbound variable warning

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

---------

Signed-off-by: Tomu Hirata <tomu@omnigent.ai>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 10:08:54 +00:00
Tomu Hirata 54d8e61c01 fix(claude-sdk): surface ResultMessage is_error as ExecutorError, not assistant text (#3342)
* fix(claude-sdk): surface ResultMessage is_error as ExecutorError, not assistant text

When the Claude SDK reports a harness-level failure (e.g. an expired
login or unauthenticated session), the terminal ResultMessage carries
is_error=True and the failure text in result. The executor was ignoring
is_error and assigning result directly to response_text, so the error
appeared in the conversation as though the model had said it — with no
error item, no harness attribution, and no log line.

Fix: check is_error before touching response_text. When true, set
terminal_error (the existing path that yields ExecutorError and returns)
and log an error line naming the agent. When false, the existing
response_text assignment runs unchanged.

Also add is_error to _ResultMessageObj so the Protocol matches the
SDK's actual shape (it was only declared on _ToolResultBlockObj before).

Closes #3282

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

* fix(claude-sdk): use getattr for is_error, handle null result, add unit test

Address Polly review feedback on #3342:

- Use getattr(result_msg, 'is_error', None) instead of direct attribute
  access so that existing test doubles that only set session_id/result
  don't raise AttributeError (matching the sibling getattr calls for
  session_id and usage in the same block).

- When is_error=True but result is None/empty, fall back to a generic
  'claude-sdk harness error' message rather than silently dropping the
  failure.

- Add test_result_message_is_error_yields_executor_error: verifies that
  a ResultMessage with is_error=True is routed to ExecutorError and does
  not appear in TurnComplete.response.

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

* fix(test): wrap long assertion string to satisfy ruff E501

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 19:01:10 +09:00
Pat Sukprasert fdeac467eb fix(web): stop short links collapsing table columns in chat markdown (#3350)
* fix(web): stop short links collapsing table columns in chat markdown

Streamdown styles links with `wrap-anywhere` (overflow-wrap: anywhere),
which also drops the element's min-content width to a single character.
Inside its `table-layout: auto` table that let a link-only column be
squeezed to ~2ch, so a short link like "#3090" stacked one or two
characters per line while the prose columns took all the width.

Narrow links inside table cells to `break-word`: overlong URLs still
soft-wrap, but min-content stays at the longest unbreakable run so the
column can no longer be squeezed below it. Prose links keep `anywhere`.

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

* test(e2e-ui): guard markdown table link column width in the browser

The CSS fix for the collapsing "PR #" column is only observable with a
layout engine, so the vitest companion can pin the rule and its selector
scoping but not the width. This adds the browser-side half: a seeded
assistant message renders the table shape that triggered the bug — a
link-only `#` column, wide prose columns, and a full-URL column — and
asserts the short link stays on one line box, its cell is at least as
wide as the link, and a long URL still soft-wraps inside its cell.

Verified against the pre-fix stylesheet: `#3090` stacks across 5 line
boxes without the `overflow-wrap: break-word` narrowing, 1 with it.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-27 09:58:46 +00:00
Anthony Ivan 3f357d0f0e fix(openai-agents): honor explicit Databricks profiles (#3288)
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 09:42:23 +00:00
Tomu Hirata ee2b14a35a fix(seatbelt): resolve two-hop proxy symlink when detecting CPython install root (#3344)
* fix(seatbelt): resolve two-hop proxy symlink when detecting CPython install root

When a Python interpreter is installed via `uv tool install`, the
executable is a two-layer symlink:

  ~/.local/share/uv/tools/<pkg>/bin/python  →  (proxy)
      ~/.local/share/uv/python/cpython-3.12.X-.../bin/python3.12

The literal proxy path grandparent (`tools/<pkg>/`) has no CPython
`lib/python*` markers, so `_interpreter_install_root` returned None.
`_add_topmost` then raised OSError before ever checking the resolved
path, causing every session to fail with:

  darwin_seatbelt: helper interpreter at '.../uv/tools/omnigent/bin/python'
  resolves under the unsafe ancestor '/Users'; ...

Fix: in `_add_topmost`, when the literal path yields no install root,
resolve it one level and retry `_interpreter_install_root` on the
resolved path before giving up. The resolved CPython install root
(which does carry the canonical markers) is then granted as the narrow
subpath, matching the existing behaviour for direct uv-python installs.

Also update the OSError message to say 'CPython install root' and note
that both the literal and resolved path were tried, and fix the
matching assertion in the existing test.

Fixes #3237.

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

* fix(seatbelt): grant pi_dir and $TMPDIR write root so sandboxed pi can boot

Two follow-up fixes found by running `omnigent run --harness pi` with
darwin_seatbelt enabled end-to-end:

1. with_additional_read_roots silently dropped pi_dir

   When the spec declares no read_paths, resolve_sandbox returns
   read_roots=None (meaning 'no spec-supplied grants').
   with_additional_read_roots bailed early on None, so the pi node_modules
   dir granted by _try_sandbox_pi was never added to the policy. Result:
   pi failed with 'Cannot find package .../pi-ai/index.js' because the
   seatbelt profile had no subpath rule for the nvm install tree.

   Fix: treat None as an empty list rather than 'already unrestricted' —
   the caller is explicitly widening the policy and must be honoured even
   when the spec has no grants of its own.

2. PI_CODING_AGENT_DIR was created under $TMPDIR, which wasn't granted

   _try_sandbox_pi granted /tmp as a write root, but on macOS $TMPDIR is
   /var/folders/.../T/ (not /tmp). PI_CODING_AGENT_DIR is created with
   tempfile.mkdtemp() which uses $TMPDIR, so pi got EPERM trying to write
   its extension/settings. Fix: also grant tempfile.gettempdir() alongside
   /tmp.

With all three fixes (two-hop symlink detection, read-roots None handling,
TMPDIR grant) `omnigent run /tmp/pi-sandbox-bundle --harness pi` boots and
completes a full turn end-to-end under darwin_seatbelt.

Fixes #3237.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 18:41:53 +09:00
Tomu Hirata 505b4f821a fix(pi): migrate Pi to Databricks v2 gateway endpoints (#3307)
* fix: route kimi and inkling through Responses API via system.ai.* ids

Kimi and inkling never send finish_reason in /chat/completions streaming
responses, causing Pi to throw 'Stream ended without finish_reason'.

These models work correctly via the Responses API at /ai-gateway/codex/v1
using their system.ai.* model ids (system.ai.kimi-k2-7-code,
system.ai.inkling).

- Add system.ai.kimi-k2-7-code and system.ai.inkling to
  _DATABRICKS_RESPONSES_MODELS in the executor
- Add _DATABRICKS_TO_SYSTEM_AI mapping in pi_native_credentials so live
  endpoint fetch translates databricks-* ids to system.ai.* and routes
  them to the gpt_responses bucket (openai-responses at /ai-gateway/codex/v1)
- Update _pi_needs_responses_api to treat system.ai.* models as responses
- Update _pi_provider_for_model to route system.ai.* to databricks-openai

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

* fix(review): restore substring reasoning fallback and fix run-path translation

Addresses Polly's review of #3307:

1. Restore 'kimi'/'inkling' to substring reasoning check in _fetch_pi_model_lists
   so unmapped variants (renamed/versioned endpoints not in _DATABRICKS_TO_SYSTEM_AI)
   still get reasoning:true — preventing silent regression.

2. Translate databricks-* model ids to system.ai.* in the executor run path
   (_build_env_and_dir) so model_override='databricks-kimi-k2-7-code' correctly
   routes to the databricks-openai (Responses API) provider, not databricks-completions.

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

* fix: move GLM to Responses API via system.ai.glm-5-2

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

* fix: move Qwen3 to Responses API via system.ai.* ids

Qwen3 returns array content with tool calls via /chat/completions causing
[object Object] errors. system.ai.qwen3-next-80b-a3b-instruct and
system.ai.qwen35-122b-a10b work correctly via the Responses API.

Also removes qwen3 from _unsupported_in_pi since it's now handled.

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

* refactor: replace hardcoded system.ai map with keyword-based detection

- Replace _DATABRICKS_TO_SYSTEM_AI exact-id dict with _databricks_to_system_ai()
  function that detects by keyword (kimi, inkling, glm-5, qwen3, qwen35) and
  derives system.ai.* id by stripping 'databricks-' prefix. Handles future model
  variants automatically without needing to update an exact-id map.

- Apply the same swap in model_catalog._fetch_databricks_listing so sys_list_models
  returns system.ai.* ids directly, letting the LLM use the correct id immediately.

- Use specific fragments (glm-5 not glm) to avoid false-positives like
  zai-org-glm-4-7 which has no system.ai.* alias.

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

* fix(review): fix _ensure_rpc selector translation; revert GLM to completions path

Addresses Polly's blocking issues:

1. Normalize model id to system.ai.* at the top of _ensure_rpc so that both
   models.json and the provider/model selector see the same id. Previously only
   _build_env_and_dir translated the id but _ensure_rpc still built the selector
   from the untranslated databricks-* id, causing 'Model not found' in Pi.

2. Revert GLM (databricks-glm-5-2) back to the completions path. GLM works fine
   via /chat/completions with finish_reason=true — moving it to the Responses API
   was unnecessary and undocumented. Removed from _SYSTEM_AI_MODEL_KEYWORDS and
   _DATABRICKS_RESPONSES_MODELS.

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

* refactor: use Unity Catalog model-services API for Pi model discovery

Replace /api/2.0/serving-endpoints with /api/2.1/unity-catalog/model-services
which returns system.ai.* model ids directly with supported_api_types metadata.

Benefits:
- No databricks-* → system.ai.* translation needed
- Authoritative API capability info: models with 'openai/v1/responses' in
  supported_api_types go to the Responses provider; others to completions
- Embeddings excluded cleanly via has_embedding check
- sys_list_models returns system.ai.* ids directly via _fetch_databricks_uc_listing

Also add _ensure_rpc id normalization so databricks-* model_override values
are translated before building the provider/model selector.

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

* fix: route all system.ai.* models through AI Gateway (omnigent-openai)

system.ai.* ids are not valid at /serving-endpoints — they only work
via the AI Gateway at /ai-gateway/codex/v1. Previously, system.ai.*
models without openai/v1/responses in UC metadata (kimi, inkling,
qwen3) were routed to omnigent-completions at /serving-endpoints,
causing 404 errors.

Route all system.ai.* models to omnigent-openai regardless of UC
supported_api_types.

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

* fix(test): update test to expect all system.ai.* models in gpt_responses

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

* fix(pi-native): surface Pi model errors as visible error items in web UI

When Pi's API call fails (e.g. 404 for unknown model id, 400 for
unsupported API type), the extension was silently returning from
message_end with no output, leaving users with an empty turn.

Post an external_conversation_item of type 'error' when message.stopReason
is 'error', so the error appears in the web UI chat.

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

* fix(tests): update model_catalog tests for Unity Catalog API format

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

* fix: revert Qwen3 from responses API - Pi sends fields that Qwen3 rejects

/ai-gateway/codex/v1/responses rejects Pi's standard Responses API fields
(parallel_tool_calls, temperature:null, top_p:null) for Qwen3, causing 400.
Route Qwen3 back to omnigent-completions until either:
- Pi adds compat flags to suppress these fields for non-standard providers
- The upstream array-content fix (earendil-works/pi#7062) lands to fix [object Object]

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

* fix: restore Qwen3 to Responses API path via system.ai.*

Pi only sends store:false in requests - the earlier 400 was from a stale
session before the routing fix. Confirmed minimal Pi request works fine
for Qwen3 via /ai-gateway/codex/v1/responses.

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

* fix(review): scope UC listing to pi path only; fix test fixtures

Polly's review correctly identified that using _fetch_databricks_uc_listing
for all Databricks providers leaks system.ai.* ids to non-pi harnesses
(claude-sdk, codex, openai-agents) that only understand databricks-* ids.

Revert model_catalog.py to use _fetch_databricks_listing (serving-endpoints)
for sys_list_models. _fetch_databricks_uc_listing remains available but is
only used internally by pi_native_credentials._fetch_pi_model_lists.

Also fix test_model_catalog.py fixtures to use the correct serving-endpoints
payload shape (databricks-* ids) rather than the UC model-services shape
(system.ai.* ids) which the non-pi listing never emits.

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

* test(model_catalog): update pi tests for UC model-services API

Pi harnesses now call `/api/2.1/unity-catalog/model-services` and return
`system.ai.*` model ids instead of `databricks-*` ids. Update the test
fixtures and expected ids to match:

- `_databricks_transport`: now serves both the serving-endpoints page
  (non-pi) and a UC model-services page (pi harness calls).
- `test_databricks_listing_filters_to_chat_llms`: expect `system.ai.*`
  ids and matching family assertions.
- `test_databricks_listing_skips_explicitly_non_ready_endpoints`: rewrite
  to use UC format (UC has no per-service readiness flag).
- `test_listing_failure_reported_and_not_cached`: switch to codex-native
  harness to test generic failure/retry without UC routing complexity.
- `pi-everything` parametrize: update expected ids to `system.ai.*`.
- `model_catalog.py`: add TTL cache for UC listings (same `_listing_cache`
  with a `"uc:"` prefixed key) so pi harness calls cache-hit correctly.

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

* fix(model_catalog): fix ruff RUF005 and E501 lint errors

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

* fix(test_model_catalog): shorten docstring to fix E501

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

* fix(pi_executor): scope system.ai.* Responses-API routing to kimi/inkling/qwen only

system.ai.claude-* and system.ai.meta-llama-* ids should route to their own
providers (Anthropic surface and completions respectively), not the Responses
API. Previously _pi_needs_responses_api returned True for *all* system.ai.*
ids, which would have routed llama to the Responses endpoint.

Fix: check _SYSTEM_AI_MODEL_KEYWORDS in the system.ai.* branch so only kimi,
inkling, and qwen3 variants return True. Claude is already caught upstream by
the "claude" substring check in _pi_provider_for_model.

Also update stale docstrings in _needs_responses_api and _unsupported_in_pi
that still mentioned qwen3 as excluded (it was re-enabled via the Responses API).

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

* feat(pi): route GLM via Responses API (system.ai.* ids)

GLM has the same finish_reason issue as Kimi/inkling on /chat/completions.
Route it through the AI Gateway Responses API by adding "glm-" to
_SYSTEM_AI_MODEL_KEYWORDS (uses "glm-" not bare "glm" to avoid matching
"zai-org-glm-4-7" which has no system.ai.* alias).

- Remove GLM from _PI_REASONING_MODEL_FRAGMENTS (reasoning:true is a
  completions-path flag; not needed for Responses API).
- Remove GLM from the reasoning:true assignment in _fetch_pi_model_lists.
- Update test: kimi no longer gets reasoning:true (Responses API path).

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

* fix(pi): remove gpt-oss from _unsupported_in_pi; it routes via Responses API

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

* fix(pi): exclude all Gemini models from Pi, not just gemini-2-5

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

* fix(pi): exclude only gemini-2-5 from Pi; other Gemini models use completions

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

* fix(pi): drop redundant qwen35 keyword; qwen3 already matches qwen35 ids

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

* refactor(pi): remove _databricks_to_system_ai; catalog always returns system.ai.* for pi

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

* fix(pi): remove reasoning:true from kimi/inkling static model entries

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

* feat(pi): route Gemini via /ai-gateway/mlflow/v1/chat/completions using system.ai.* ids

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

* fix(pi): fix _unsupported_in_pi to only exclude gemini-2-5; gemini-3+ route via mlflow

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

* refactor(pi): remove static kimi/inkling/qwen3 entries from _DATABRICKS_RESPONSES_MODELS

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

* fix(pi): route system.ai.* llama/other models to mlflow gateway; rename provider to databricks-mlflow

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

* fix(pi): use generic base URL for non-Databricks providers (OpenAI API key, LiteLLM)

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

* fix(pi): address Polly review — fix 4-tuple annotation, system.ai.gpt routing, gpt-oss exclusion, UC listing filter

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

* fix(model_override): strip system.ai.* prefix for vendor-direct providers (OpenAI key, etc.)

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 17:48:41 +09:00
Serena Ruan 2a60e17d89 fix(pi-native): surface unresolved Databricks credentials instead of a silent dead session (#3336)
A native Pi session routed through a Databricks gateway whose OAuth token
can't be resolved (expired refresh token) launched fine but every message
silently failed to reach the model — no reply, no error. `_databricks_pi_provider`
caught all failures in one try/except and still returned a provider whose
`!databricks auth token` apiKey fails at request time; because pi-native
dispatches turns fire-and-forget, the failure never round-tripped back as an
Omnigent error.

Split credential resolution from the (benign) model-list fetch so a genuine
auth failure carries a `credential_warning`. At terminal auto-create, surface
that warning as an `error` item via `external_conversation_item`: it renders as
the web UI's distinct error banner (not a misleading assistant bubble),
persists across reload, is a non-content item type so it never enters the next
turn's context, and posts without queuing an agent turn (safe on a session
whose model is unreachable).

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-27 15:43:32 +08:00
Abdullah Said 3c64d66aa5 feat(catalog): add claude-opus-5 to curated claude subscription models (#3275)
Claude Opus 5 (released 2026-07-24) was missing from the curated
_SUBSCRIPTION_STATIC_MODELS["claude"] list. Verified empirically against
Claude Code 2.1.220: 'claude-opus-5' -> is_error:false; the dated form
'claude-opus-5-20260724' and a 'claude-opus-5-fast' variant both return
is_error:true, so neither is added.

Placement follows the existing convention: tiers descend
fable -> opus -> sonnet -> haiku, newest version first within a family
(matching claude-sonnet-5 ahead of claude-sonnet-4-6), so opus-5 slots
between fable-5 and opus-4-8.

The web mirror (web/src/lib/claudeNativeModels.ts) needs no change: it
lists version-agnostic aliases ('opus' resolves to the latest Opus) by
design, not pinned ids.

Signed-off-by: Abdullah Said <abdullahsaid89@gmail.com>
Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-27 14:29:13 +07:00
Serena Ruan 5e62d0e44b ci(ui-snapshot): make the visual-baseline gate merge-blocking + regenerate baselines (#3338)
* ci(ui-snapshot): make the visual-baseline gate merge-blocking

The UI Snapshot visual-regression check was advisory ([non-blocking]) and
not in the required-checks set, so a UI change could land without
regenerating the committed baselines — which is how the baselines drifted
stale on main (every PR since #3311 fails the gate identically).

Register it as a required merge gate:
- Drop the "[non-blocking]" suffix from the job name.
- Add "UI Snapshot (visual baselines)" to REQUIRED and ALLOW_SKIP in
  merge-ready/required.sh, plus a workflow_for mapping. It's safe as a
  required check: a PR touching no render input skips the render via the
  `detect` job's `if` gate, and an if-skipped job reports success — so
  non-UI PRs satisfy the check instead of sitting pending. ALLOW_SKIP +
  workflow_for let the gate tell that genuine skip from a still-pending run.
- Add "UI Snapshot" to merge-ready.yml's workflow_run triggers so the gate
  re-evaluates when the snapshot workflow completes.
- Update the visual README's merge-blocking section.

This PR edits ui-snapshot.yml (a render input), so the gate runs here and
fails on the stale baselines; the `update-ui-snapshot` label regenerates
them onto this branch to turn it green.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 15:19:33 +08:00
Serena Ruan 2de2d3f888 fix(sessions): fall back to localStorage when pinning against an old server (#3332)
* fix(sessions): fall back to localStorage when pinning against an old server

A pin created in the new UI while the server is still pre-upgrade was lost
on the server upgrade. The pin toggle PATCHes `omnigent.pinned`; an old
server (no per-user pin concept) stores it as a bare label, but the
upgraded server's read path (`_labels_for_viewer`) drops every bare/
`omnigent.pinned.*` key and only surfaces the caller's own
`omnigent.pinned.<user>` key — so the bare-key pin silently vanishes. The
localStorage→server migration couldn't recover it either, since that pin
was never in localStorage.

This complements the earlier migration-gate fix (which protected pins made
*before* the UI upgrade). Now the toggle also checks `filterHonored`: when
the server can't store pins, it writes the pin to localStorage (the same
store the pre-upgrade UI used) instead of PATCHing a doomed bare key. The
pin renders immediately (sidebar unions localStorage pins) and later
migrates through `useMigrateLocalPinsToServer` like any pre-upgrade pin.
Once the server can store pins, the toggle uses the server as before.

- Move the legacy-pin localStorage helpers from Sidebar.tsx to the leaf
  sidebarNav module (+ a single-id `setLegacyPinnedConversationId`) so the
  toggle hook can use them without an import cycle.
- Tests: unit coverage for the toggle's old-server fallback (pin/unpin to
  localStorage, no PATCH; normal PATCH path once honored), and an
  end-to-end case in the backwards-compat suite that pins DURING the
  UI-before-server window and asserts it survives the server upgrade.

Co-authored-by: Isaac

* fix(sessions): surface local-write failures in the old-server pin fallback

Addresses a review note: the old-server pin toggle's localStorage write is
the pin's only persistence, but it went through the best-effort
`writeLegacyPinnedConversationIds`, which swallows write errors (e.g.
storage quota exceeded). So a failed write let the mutation report success
and the optimistic patch show the pin, while it silently vanished on reload
— with no rollback.

Split out a throwing `...OrThrow` raw write. The old-server fallback
(`setLegacyPinnedConversationId`) now uses it, so a failed write rejects the
mutation → `onError` rolls back the optimistic patch and the UI honestly
shows the pin didn't take, matching the server PATCH path. The migration's
best-effort write is unchanged (a failed write there just retries next load).

Test: the fallback rolls back the optimistic pin when the local write throws.

Co-authored-by: Isaac
2026-07-27 14:57:37 +08:00
Zeyi (Rice) Fan a7ef194c4f refactor(sandboxes): introduce contribution-based provider registry (#3330)
## Related issue

N/A

## Summary

- Add `omnigent/onboarding/sandboxes/types.py` with shared dataclasses
  (`SandboxCapabilities`, `SandboxSpec`, `SandboxInfo`, `HostContext`) and the
  new `SandboxError` exception hierarchy.
- Add `omnigent/onboarding/sandboxes/registry.py` with a contribution-based
  provider registry that mirrors `omnigent/harness_plugins.py`: built-in
  providers are declared as a `SandboxProviderContribution`, community
  packages register via the `omnigent.sandbox_providers` entrypoint group, and
  broken plugins are recorded in `load_errors` without breaking core startup.
- Add `omnigent/community/sandbox/__init__.py` as a namespace package so
  third-party providers can ship code under `omnigent.community.sandbox.*`.
- Validation enforces that community provider code lives under the community
  namespace, rejects name collisions, and checks metadata consistency.
- Add a `capabilities` property to `SandboxLauncher` that derives feature flags
  from existing class variables and overridden transport methods.
- Migrate CLI and managed-host call sites from direct class-var reads
  (`supports_cli_bootstrap`, `can_resume`, `supports_local_port_forward`) to
  the new `capabilities` object.
- Add unit tests for types, registry behavior, validation, and entrypoint
  discovery.

No provider implementations were changed; this is purely a surface-layer
refactor toward a pluggable sandbox provider interface.

## Test Plan

```bash
uv run pytest tests/onboarding/sandboxes tests/sandbox tests/cli/test_cli.py tests/server -k managed -q
pre-commit run --files omnigent/onboarding/sandboxes/types.py omnigent/onboarding/sandboxes/registry.py omnigent/onboarding/sandboxes/base.py omnigent/onboarding/sandboxes/__init__.py omnigent/onboarding/sandboxes/bootstrap.py omnigent/community/sandbox/__init__.py omnigent/cli_sandbox.py omnigent/server/managed_hosts.py tests/onboarding/sandboxes/test_types.py tests/onboarding/sandboxes/test_registry.py
```

All 779 selected tests pass and the targeted pre-commit hooks pass.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

New unit tests in `tests/onboarding/sandboxes/test_types.py` and
`tests/onboarding/sandboxes/test_registry.py` exercise the registry,
contribution validation, types, and capabilities derivation. Existing
provider and CLI tests pass unchanged, confirming backward compatibility.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 06:44:36 +00:00
Rahul Ravindranathan c1bedeaabd feat(automations): model + reasoning-effort selectors on automations (#3331)
* feat(scheduled): add Model + Reasoning-effort pickers to the task dialog

The scheduled-task create/edit dialog previously omitted model and effort,
sending only agent_id so tasks always ran with the agent's configured
defaults. Add lightweight Model + Reasoning-effort controls, gated by the
selected agent's capability exactly like the interactive New Chat dialog:
they render only for native coding agents that carry the model/effort
surface (Claude Code) and are hidden for agents without it (Codex, plain
SDK agents, etc.).

- New scheduled-local ModelEffortFields component reuses the shared option
  lists (CLAUDE_NATIVE_MODELS + the version-agnostic aliases, and
  CLAUDE_NATIVE_EFFORTS) rather than importing the 26-prop
  HarnessConfigModal, which is bound to smart-routing / cost-control /
  per-turn model loading and disproportionate for a saved task. When a host
  is pinned it uses that host's live model options; with none pinned (the
  common case) it falls back to the static Claude aliases.
- Hoist CLAUDE_NATIVE_EFFORTS into the shared HarnessConfigControls module
  so both dialogs share one source of truth.
- Wire modelOverride + reasoningEffort through create and update (both
  already round-tripped by scheduledTasksApi.ts — no client/API change).
  Unselected ("Default") omits the field on create so the fire path uses
  the agent's defaults; on edit, Default sends null to clear a prior
  override. Edit mode prefills both controls from the loaded task.

No permission/approval/cursor mode picker and no new API field: this is a
pure frontend change.

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

* test(automations): e2e for model + effort selectors

Extend tests/e2e_ui/scheduled/test_scheduled_tasks_page.py with UI journeys
for the model + reasoning-effort selectors added to the scheduled-task
create/edit dialog:

- controls visible + default to "Default" for a capability-gated agent
  (Claude Code)
- controls hidden (with the "uses defaults" hint) for a non-capable agent
  (seeded Codex task, asserted via the edit dialog)
- create persists a concrete Model + Effort pick (asserted via the REST API)
- create with both controls left on Default persists null overrides
- edit prefills the controls from a seeded task's stored overrides

LLM-free like the sibling tests: exercises only the dialog, REST, and the
rendered row. Uses Playwright expect() auto-waiting, no sleeps.

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-26 23:36:49 -07:00
Zeyi (Rice) Fan dbfc7565e5 feat(web): remove sidebar font-size control and add appearance reset dialog (#3326)
## Related issue
N/A

## Summary
- Remove the dedicated Settings → Appearance → Sidebar → Font size card and the `lib/sidebarFontPreferences` module, since users should use the global Interface font size control instead.
- Clear the legacy `omnigent:sidebar-font-size` localStorage key on app boot so anyone who previously changed the sidebar font size falls back to the default 13px.
- Add a "Reset to defaults" button at the bottom of the Appearance section that opens a confirmation dialog and resets all appearance choices: mode, terminal theme, color palette/custom theme, workspace panel default, hide-unconfigured-harnesses toggle, and interface/code font size and family.

## Test Plan
- Updated unit tests in `web/src/pages/SettingsPage.test.tsx` covering the reset flow and the absence of the sidebar font size control.
- Added a Playwright E2E test in `tests/e2e_ui/sessions/test_appearance_reset.py` to verify the sidebar card is gone and the reset dialog restores defaults.
- To verify locally after installing web dependencies:
  - `cd web && npm run type-check`
  - `npx vitest run src/pages/SettingsPage.test.tsx`
  - `pytest tests/e2e_ui/sessions/test_appearance_reset.py`

## Demo
N/A — UI change; a screen recording of the reset confirmation dialog is recommended before merge.

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

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

## Coverage notes
local web dependencies are not installed in this environment, so the local type-check and vitest runs could not be executed. CI will run the web test suite on the PR branch.

## Changelog
Settings → Appearance no longer has a separate Sidebar font size control, and a new "Reset to defaults" button restores every appearance preference after confirmation.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-26 23:16:30 -07:00
Jackson Zheng 8b3856fefa Align sidebar project icons (#3317)
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-26 23:07:06 -07:00
Serena Ruan a86fba610d feat(projects): name the project in the new-session hero, drop the tray chip (#3327)
* feat(projects): name the project in the new-session hero, drop the tray chip

When starting a session from within a project (a `?project=` landing), the
composer used to show the project as a pill in the footer tray while the hero
kept its generic "What should we do?" prompt. Move that context into the hero
instead: the heading shows the project name and Otto's eyes are swapped for the
same folder icon the sidebar uses for a project. The footer project chip
(`LandingProjectPicker`) is removed — filing on create still uses the same
`selectedProject` state, just without the redundant chip.

The folder icon renders in a fixed-height (`h-18`) box matching Otto so the
vertically-centered composer doesn't shift when toggling between the plain and
in-project landings.

Co-authored-by: Isaac

* fix(projects): clamp long project name in the new-session hero

A 100-char project name (the server-side cap) rendered at text-3xl overflowed
the centered container: the icon+heading flex row sized to its content with no
width bound, so the h1's min-w-0/line-clamp had nothing to act against. Give the
row w-full and keep the heading min-w-0 + line-clamp-2 + break-words so a long
name wraps to two lines and ellipsizes instead of overflowing. Add a test
asserting the clamp class contract on a 100-char name.

Co-authored-by: Isaac
2026-07-27 13:41:37 +08:00
Andrew Peltekci c4df88c712 fix(crash-handler): stop same-second crash reports overwriting each other (#3173)
The same-second filename collision was disambiguated by pid alone. A pid is
only unique across processes — a process that crashed more than twice within
one second reused its own pid, so every report after the first collision was
written to the same path and silently destroyed its predecessor. Saving five
reports in one second left two files on disk with three crash reports lost,
with rotation held wide enough that nothing should have been pruned.

Keep counting past the pid-suffixed name until the path is free.

test_save_report_writes_and_rotates encoded the bug: it asserted all five
returned paths still existed while rotation kept only two, which could only
hold when the collision collapsed them onto two names. It now asserts the
newest report survives its own rotation pass, and a new test pins the
no-overwrite guarantee with rotation held wide.

Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 05:36:43 +00:00
Anthony Ivan 96b2f6c97b docs: recommend omnidev for worktree testing (#3277)
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 05:34:56 +00:00
Serena Ruan 9835d09c1f fix(web): use lucide files icon for Files workspace tab (#3329)
Swap the Files right-rail tab glyph from FilePenLineIcon (pen-on-page) to
FilesIcon (stacked pages) to better convey the panel's contents.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-27 13:34:07 +08:00
Serena Ruan 1dd0c49e71 fix(sessions): don't wipe local pins when UI upgrades before server (#3323)
* fix(sessions): don't wipe local pins when UI upgrades before server

The one-time localStorage→server pin migration (#3189) trusted an
ambiguous success signal. A pre-upgrade server silently ignores the
unknown `?pinned=true` param and returns the normal (unfiltered) session
page, so the UI saw ~100 "server pins", computed an empty to-migrate set,
and cleared localStorage without ever writing a pin. After the server was
upgraded, its per-user key filter found nothing and every pin read as
unpinned — the reported data loss for UI-before-server upgrades.

Fix, entirely client-side:

- `fetchPinnedConversations` now returns `{ conversations, filterHonored }`.
  It keeps only rows actually carrying the `omnigent.pinned` label and
  reports `filterHonored: false` when the server returned unpinned rows —
  the tell-tale of an old server that ignored the filter.
- The migration is gated on `filterHonored`: it stays inert (localStorage
  untouched) against an old server and re-runs after the eventual upgrade.
  A legacy id is dropped only after its write is confirmed.
- Pinned membership is the union of the server's pins and any leftover
  localStorage pins, so a not-yet-migrated pin keeps rendering instead of
  vanishing during the UI-before-server window.

Tests: new filter-honored detection cases, a migration-gate suite, and an
end-to-end backwards-compat test that drives the real hooks across an
old→new server upgrade and asserts the pin is never lost.

Co-authored-by: Isaac

* docs(sessions): address Polly review notes on pin migration

- Document the empty-page ambiguity in `filterHonored` and why it's safe
  (an old empty page means a zero-session account; the migration PATCH to a
  deleted session 404s and the pin is retained, not lost).
- Note the window-scoped caveat that a legacy-only pin outside the loaded
  paginated window may not render a row until loaded.
- Add a regression test: a failed (404) migration write keeps the legacy
  pin in localStorage for retry.

Co-authored-by: Isaac
2026-07-27 13:20:50 +08:00
Rahul Ravindranathan f85452e4f3 feat(automations): relative next-run label + card rows (#3324)
* feat(automations): absolute next-run time + card rows

Change 1: the Automations list now shows the next run as an absolute
wall-clock time ("Next run Tomorrow at 8:00 AM" / "Today at 2:30 PM" /
"Jul 26, 8:00 AM") instead of a relative delta ("in 15h"). Adds
formatNextRunAtAbsolute() in scheduleText.ts, which only FORMATS the
server-authoritative next_run_at (rendered in the task timezone,
Today/Tomorrow bucketed in that same zone) and never recomputes which
instant is next on the client. The old relative formatNextRunAt() is
kept intact.

Change 2: each ScheduledTaskRow now renders as a card (rounded-xl
border bg-card, internal padding), and TasksPage stacks them with a
gap. All existing behavior and data-testids preserved; paused rows are
not dimmed.

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

* feat(automations): relative full-word next-run label

Reverses the earlier absolute-time next-run display back to a
server-sourced relative delta in full words ("Next run in 3 hours",
"Next run in 8 mins", "Next run in 2 days") per user feedback.

formatNextRunAt now emits full-word, pluralized buckets ('soon' /
'in N min(s)' / 'in N hour(s)' / 'in N day(s)'); the delta is still
computed only from the server's authoritative next_run_at, so the
"no client countdown" rule is unaffected. Removes the now-dead
formatNextRunAtAbsolute and its private helpers (safeFormat,
civilDayInZone). Card-row styling is unchanged.

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

* fix(automations): live-tick the relative next-run label

The relative next-run label was frozen at its first-render `now` and
only refreshed on remount. A shared 30s useNow() clock (a module-level
singleton via useSyncExternalStore) now drives live re-renders, so the
delta counts down while the page stays open. TasksPage owns the one
ticker and passes `now` to each row, keeping the row a pure function of
props.

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

* fix(automations): round next-run label to nearest unit

Flooring understated the relative next-run label near a unit boundary:
a task 1h49m away read "in 1 hour". formatNextRunAt now rounds to the
nearest minute/hour/day and promotes on carry (each threshold tests the
already-rounded value), so 1h49m reads "in 2 hours" and a delta that
rounds up to a full unit shows "in 1 hour"/"in 1 day" rather than
"in 60 mins"/"in 24 hours".

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

* test(automations): e2e for live-ticking next-run countdown

Adds a Playwright test to the scheduled-tasks page suite proving the
relative next-run label re-renders on its own as time passes (the shared
useNow() ticker), with no navigation. Uses clock mocking for determinism:
pins the browser clock 40 min before the server's next_run_at, asserts
"Next run in 40 mins", fast-forwards 35 min past many 30s ticks, then
asserts the same row updated to "Next run in 5 mins". LLM-free.

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-26 22:01:01 -07:00
Zeyi (Rice) Fan a4a73ffe7f feat(cli): include auth override env var in non-loopback bind warning (#3320)
## Related issue

N/A

## Summary

- When `omnigent server` binds a non-loopback interface, it auto-enables accounts (login) mode and prints a warning.
- The warning now explicitly names `OMNIGENT_AUTH_ENABLED=0` as the override to keep single-user mode.
- Kept the warning to the canonical env var; removed any mention of the deprecated alias.
- Improved the rendered indentation so the override sentence starts on its own line.

## Test Plan

- `uv run ruff check omnigent/cli.py`
- `uv run pytest tests/cli/test_bind_auth_defaults.py -q`

Both pass.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

The existing `tests/cli/test_bind_auth_defaults.py` already exercises the non-loopback auto-enable path and the explicit `OMNIGENT_AUTH_ENABLED=0` override. This change only updates the warning copy.

## Changelog

`omnigent server` now tells users they can set `OMNIGENT_AUTH_ENABLED=0` to override automatic multi-user mode when binding to a non-loopback interface.
2026-07-26 20:44:11 -07:00
Zeyi (Rice) Fan f2b2f80948 refactor(server)!: remove deprecated OMNIGENT_ACCOUNTS_ENABLED env alias (#3322)
## Related issue

N/A

## Summary

- Remove the long-deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment-variable alias for the multi-user auth enable switch. The canonical name `OMNIGENT_AUTH_ENABLED` has existed since the repository was open-sourced.
- Strip the alias logic from `omnigent/server/auth.py::_auth_enabled()`, the explicit-auth check in `omnigent/cli.py::_apply_bind_auth_defaults()`, and the runner env-propagation allowlist in `omnigent/host/connect.py`.
- Delete the tests that exercised the alias and the obsolete comment in `tests/conftest.py`.

## Test Plan

- `uv run ruff check omnigent/server/auth.py omnigent/cli.py omnigent/host/connect.py tests/conftest.py tests/cli/test_bind_auth_defaults.py tests/server/test_accounts.py tests/e2e/test_local_server_lifecycle_e2e.py` passed.
- `uv run pytest tests/cli/test_bind_auth_defaults.py tests/server/test_accounts.py -q --no-header` passed (95 items).
- `uv run pytest tests/server/test_accounts.py::test_resolve_auth_source_defaults_to_header tests/server/test_accounts.py::test_resolve_auth_source_opt_in_selects_accounts tests/server/test_accounts.py::test_factory_defaults_to_header_when_env_unset tests/cli/test_bind_auth_defaults.py -q --no-header` passed (15 items).
- Verified no remaining references with `grep -R "OMNIGENT_ACCOUNTS_ENABLED" . --exclude-dir=.git --exclude-dir=.venv`.

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

## Coverage notes

Removed the tests that specifically covered the deprecated alias; remaining tests continue to validate `OMNIGENT_AUTH_ENABLED` behavior. The refactor does not change the `OMNIGENT_AUTH_ENABLED=1 | =0` semantics.

## Changelog

[Breaking] The deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment variable has been removed; use `OMNIGENT_AUTH_ENABLED` instead.

BREAKING CHANGE: Users and deploys still setting `OMNIGENT_ACCOUNTS_ENABLED` must rename the variable to `OMNIGENT_AUTH_ENABLED` before upgrading; the old name is no longer read or propagated.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 03:35:35 +00:00
Serena Ruan c53c631bae docs(projects): update PRD status — Phase 2 done, Phase 3 & 4 postponed (#3321)
Bring the Projects PRD implementation-status section in line with what has
shipped and what remains:

- Mark backend `config` hardening (size bound + non-dict coercion) as done —
  both already landed in the project store.
- Move the completed Benchmark (#3094) and Phase 2 (project defaults) items out
  of TODO into their own "Done" sections.
- Correct a stale claim that the new-session prefill machine still reads the
  `omni_project` label — it was collapsed to config-only in Phase 2. The one
  remaining UI label reader (the Settings archived-project picker) is folded
  into the Phase 4 retire-label-path step instead.
- Postpone Phase 3 (memory & context) and Phase 4 (label consolidation) with
  distinct triggers: Phase 3 waits for customer demand; Phase 4 waits until
  telemetry shows most clients have migrated to a version that writes
  `project_id`.

Co-authored-by: Isaac
2026-07-27 11:25:21 +08:00
Zeyi (Rice) Fan 5169c918c6 fix(claude-native): escape unsupported Claude Code slash commands (#3319)
## Related issue
N/A

## Summary
- Updated `inject_user_message()` in `omnigent/claude_native_bridge.py` so user messages that start with a Claude Code UI-only/unsupported slash command (`/help`, `/exit`, `/quit`, `/doctor`, `/cost`, etc.) are escaped before being pasted into the TUI.
- Escaping inserts an invisible zero-width no-break space before the leading `/`, causing Claude Code to treat the input as regular user text while the user still sees their slash.
- Supported slash commands (`/clear`, `/compact`, `/effort`, `/model`, `/ultrareview`, `/branch`, `/fork`) and unknown skill commands pass through unchanged.

## Test Plan
- Added parametrized unit test for `_escape_unsupported_slash_command`.
- Added payload test verifying `/help` gets the escape prefix and `/clear` does not.
- Ran targeted injection tests and pre-commit:
  - `uv run pytest tests/test_claude_native_bridge.py::test_escape_unsupported_slash_command tests/test_claude_native_bridge.py::test_inject_user_message_escapes_unsupported_slash_command_payload -q`
  - `uv run pytest tests/test_claude_native_bridge.py -k "inject_user_message" -q`
  - `uv run ruff check omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py`
  - `uv run ruff format omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py --check`
  - `uv run pre-commit run --files omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py`

## Demo
N/A

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

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

## Coverage notes
N/A — new unit tests directly cover the escaping decision and the payload path.

## Changelog
Unsupported Claude Code slash commands are now escaped and sent as regular user messages instead of leaving the native terminal in an undriveable state.
2026-07-27 03:16:44 +00:00
Serena Ruan 35a83b6be6 fix(projects): guard settings inputs during config load; correct worktree doc (#3316)
* fix(projects): disable settings inputs during config load; correct worktree doc

Two non-blocking follow-ups from the PR #3221 review:

- Gate the worktree toggle, workspace Browse trigger, and path input on
  `isLoading`, matching the host Select. Previously an edit made in the load
  window would be clobbered by the seeding effect once the fetch settled.
- Rewrite the `use_worktree` docstring to match the opt-in implementation
  (only `true` is written; `false` is never stored and treated as unset).

Co-authored-by: Isaac

* docs(projects): mark backend config hardening as done in PRD

The two #3108 config-hardening follow-ups (size bound + non-dict coercion)
already landed in the project store; move them from "deferred" to a  bullet
so the PRD status matches the code.

Co-authored-by: Isaac
2026-07-27 10:48:46 +08:00
Serena Ruan 77ed2a2c83 feat(projects): project settings editor + config-driven composer prefill (Phase 2) (#3221)
* feat(projects): project settings editor + config-driven composer prefill (Phase 2)

Add a "Project settings" dialog to set a project's stored session defaults
(host, working directory, agent, opt-in random worktree) and wire the new-chat
composer to prefill from that stored config, retiring the newest-session
inference so stored config is the single source of truth.

- ProjectSettingsDialog: edit + persist config {host_id, workspace, agent_id,
  use_worktree}; worktrees opt-in (default OFF, store true when on). Reuses the
  composer's host/agent pickers and filesystem browser.
- projectPrefill: collapse to config-only seeding; unset fields fall through to
  the composer's generic defaults. Honor a stored sandbox default via
  selectSandbox (gated on managed sandboxes). Remove useNewestProjectSession.
- Extract the nested-dropdown dismiss guard into a dependency-free module shared
  by the settings and scheduled-task dialogs.

Co-authored-by: Isaac

* fix(projects): repair CI — Sidebar test mocks, e2e rewrites, retire inference e2e

- Add useProjectConfig/useUpdateProjectConfig to all 10 Sidebar test mocks
  (Sidebar now mounts ProjectSettingsDialog, which calls them).
- Rewrite the settings-dialog e2e to create the project via POST /v1/projects
  instead of the flaky row-kebab move-to-project flow.
- Fix the composer-prefill e2e to stub GET /v1/sessions/projects (bare array),
  the real endpoint useProjects hits.
- Remove test_start_session_project_prefill — it exercised the newest-session
  inference path this PR retired; config-driven prefill replaces its coverage.

Co-authored-by: Isaac

* fix(projects): address review — no data-loss on failed config load; fresh prefill after save

Blocking issues from the PR review:

1. Data loss: saving the settings dialog after a failed config GET sent `{}`,
   which the server reads as "clear stored defaults". Now `useProjectConfig`'s
   isError is surfaced; a first-class project whose config failed to load blocks
   Save (with a notice), the seed effect skips a blank draft, and onSubmit bails.

2. Stale prefill after save: useUpdateProjectConfig only invalidated, so the
   composer's one-shot prefill could latch onto a stale cached config (30s
   staleTime) and drop just-saved defaults. It now setQueryData's the fresh
   config and upserts the projects list (so a promoted label-only folder
   resolves to its new id immediately).

Tests: dialog load-error blocks Save; hook seeds config + upserts list on
success; useProjectConfig disabled on null id and surfaces isError.

Co-authored-by: Isaac
2026-07-27 10:14:46 +08:00
Anthony Ivan 6c42dfe26b feat(Policy): Make dangerous shell command gating configurable, fix UI-created global policies getting skipped by default (#3297)
* Make dangerous shell command gating configurable

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

* Clarify dangerous shell policy settings

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

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 02:12:21 +00:00
Zeyi (Rice) Fan 96935e03b4 fix(web): center sidebar header buttons and soften session row hover (#3311)
* fix(web): center sidebar header buttons and soften session row hover

## Related issue
N/A

## Summary
- Vertically center section header action buttons (Projects `+`, Sessions kebab, etc.) with their titles by using `top-1/2 -translate-y-1/2` instead of `top-0.5`.
- Remove the 1 px lift on session row hover (`motion-safe:hover:-translate-y-px`) so rows stay visually anchored.
- Calm the hover flash by dropping the bouncy Otto-token transition on rows and reducing the global `--sidebar-hover` tint from 5% to 3%. Rows now use the same plain `transition-colors` pattern as the rest of the sidebar hover surfaces.
- Make `SIDEBAR_ACTIVE_HIGHLIGHT` also specify `:hover` styles so active items (current page, selected session, drop target) keep their active background on hover instead of switching to the hover tint.

## Test Plan
- `cd web && npm install && npm run dev`
- Hover over Projects/Sessions headers and confirm action buttons are vertically centered with the title text.
- Hover over active items (e.g., current page in the top nav, selected session row, current Inbox) and confirm the background stays in the active state and does not flash.
- Hover over inactive session rows and confirm the row no longer shifts up and the background highlight is subtler.

## Demo
Subtle hover/positioning polish. Verify by hovering items in the sidebar — buttons align with title baselines, rows stay still on hover, and active items don't flash.

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

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

## Coverage notes
Visually verified by inspecting the relevant Tailwind classes and CSS variables. No test coverage changes; the existing `Sidebar.projectHeaderChevron.test.tsx` covers header layout, and the hover behavior is primarily CSS.

## Changelog
Sidebar header action buttons are now vertically centered with section titles. Session row hover is smoother, and active items no longer flash when hovered.

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 01:56:48 +00:00
Zeyi (Rice) Fan 6a0e09ed42 refactor(theme): drive shell night mode from selected theme source (#3309)
## Related issue

N/A

## Summary

- Replace the web-resolved-theme bridge with a single cross-shell contract, `setThemeSource(theme)`, so the web app only reports the user's chosen theme source and each shell drives its own OS-level dark mode.
- Android: `MainActivity` now extends `AppCompatActivity`; `OmnigentBridgeListener` maps `setColorScheme` to `AppCompatDelegate.setDefaultNightMode`; system-bar icon contrast is derived from `resources.configuration.uiMode`. Removes `ResolvedColorScheme.kt`, the root-class MutationObserver, and the top-level navigation reset on init.
- iOS: Add a `ThemeSource` enum and `ThemeController` singleton inside the existing `OmnigentWebView.swift` target file to avoid `.pbxproj` edits; wire `setColorScheme` through the JS bridge and apply it via `.preferredColorScheme(...)` and `window.overrideUserInterfaceStyle`.
- Web: Update `nativeBridge.setThemeSource`, remove the `omnigent-native-ready` queue, and update `ThemeProvider`/`nativeBridge` unit tests.
- Android and web unit tests are updated to match the new contract.

## Test Plan

- iOS: `cd web/ios && xcodebuild -project Omnigent.xcodeproj -scheme Omnigent -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -configuration Debug -only-testing:OmnigentTests test`
- Android: `cd web/android && ./gradlew :app:testDebugUnitTest`
- Web: `cd web && npm install && npm run type-check && npm run test -- ThemeProvider.test.tsx nativeBridge.test.ts`

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manual verification completed for iOS: the app builds and `OmnigentTests` passes in the iPhone 17 Pro simulator. Android and web test suites were not run end-to-end in this session, but the affected unit tests were updated in the same change.
2026-07-26 18:53:49 -07:00
Zeyi (Rice) Fan ba241c3592 feat(dev): add justfile and mobile simulator lanes (#3310)
## Related issue

N/A

## Summary

- Add a top-level `justfile` that groups common local dev tasks (`run-ios`, `run-android`, `dev`, `electron-dev`, `lint`, `normalize-locks`, etc.) with hidden `_ensure-*` / `_check-*` prerequisites.
- Add an iOS `simulator` Fastlane lane that builds the Debug .app, installs it on an already-created iOS Simulator, and launches it.
- Add Android Gradle tasks (`runDebug`, `reverseProxy`) for launching the debug APK and running `adb reverse`.
- Fix the Fastlane `xcodebuild` invocation to use camel-case `derivedDataPath` so the built `.app` is written where the lane expects it.
- Export `FASTLANE_SKIP_UPDATE_CHECK=1` in the justfile.
- Document the new `justfile` recipes concisely in `AGENTS.md`.

## Test Plan

- `just --list` shows grouped recipes.
- `just run-ios` built/launched the iOS app in the iPhone 17 Pro Simulator.
- `pre-commit` passes on the touched files.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Verified manually by running `just run-ios` and watching the Omnigent app launch in the iOS Simulator.

## Changelog

Add a top-level `justfile` with recipes for launching the iOS Simulator, running the Android debug build, starting the omnigent dev pod, and running pre-commit/lockfile normalization.
2026-07-27 01:20:34 +00:00
Bryan Li d287e7c903 feat(web): 3D model preview for STL / 3MF / OBJ files (#3007)
* feat(web): 3D model preview for STL / 3MF / OBJ files

Selecting an .stl / .3mf / .obj file in the Files browser now renders an
interactive WebGL preview (orbit/zoom/pan) instead of the "Preview not
available for binary files" placeholder.

- Add `isModelFile()` to codeViewerHelpers (MIME-first, extension fallback),
  scoped to exactly STL/3MF/OBJ.
- New lazy-loaded `ModelViewer` component (three.js STLLoader/3MFLoader/
  OBJLoader) with camera + OrbitControls, lighting, auto-fit, loading/error
  states, and full scene teardown on unmount.
- Dispatch models before the binary-rejection branch in CodeViewer; treat
  them like images in FileViewer (diff/source-mode suppressed).
- three.js pinned at 0.185.1 and code-split into its own chunk so it stays
  out of the main bundle.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): address model-viewer review — unified resolver, recovery, teardown

Resolve the four blocking issues from cross-vendor review of the 3D model
preview:

1. Unified format interface: add one shared `getModelFormat(path, contentType)`
   resolver (MIME-first, extension fallback) used by BOTH `isModelFile`
   dispatch and `ModelViewer`'s loader selection, so a MIME-matched file with
   an unknown extension parses via the correct loader instead of erroring.
   `isModelFile` is now `getModelFormat(...) !== null`.
2. Error state no longer unmounts the canvas: the container is always mounted
   and the error is an overlay on top, keeping the ref alive so an
   invalid→valid prop change recovers.
3. Single idempotent `teardownScene()` called from both the init failure path
   and the effect cleanup, so a partial init (renderer/controls/context/RAF)
   can't leak on failure.
4. Empty/degenerate models (e.g. comment-only OBJ) are validated for a
   non-empty, finite bounding box before fitting; invalid bounds route to the
   error UI instead of a blank canvas.

Adds ModelViewer.test.tsx (MIME-only loader selection, malformed/empty/NaN →
error, invalid→valid recovery, failure-path + unmount teardown) and
getModelFormat unit tests.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(web): theme-aware 3D model preview (light/dark)

ModelViewer previously hardcoded a neutral STL material, fixed light
intensities, and a transparent canvas, so the 3D preview ignored the app
theme. Make it theme-aware off the SAME next-themes source Monaco and the
terminal use (`useTheme().resolvedTheme`), so it tracks light/dark and
updates live when the user toggles the theme with a model open.

- Add a pure `modelViewerTheme(resolved)` map in codeViewerHelpers (mirrors
  `resolvedThemeToMonaco`): background clear color, STL default material, and
  ambient/key light intensities per mode — brighter lights in dark so the
  mesh stays legible. Shared across STL/3MF/OBJ in the one unified pipeline.
- ModelViewer seeds the scene from the active mode and keeps light/material
  handles on its resource bag so a theme toggle recolors the live scene in
  place (clear color + intensities + STL color) with no reload/reparse.
- Drop the transparent (alpha) canvas in favor of a theme-derived opaque
  background so the preview sits flush with the panel in both themes.
- Tests: three theme-awareness cases (light build, dark build, live toggle
  without rebuild) mirroring the next-themes mock pattern in
  MonacoCodeEditor.test.tsx, plus modelViewerTheme unit tests.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(web): 3MF MIME-only dispatch + prune package-lock churn

Add MIME-only 3MF coverage mirroring the existing STL/OBJ tests: a file
with an absent/unrecognized extension but a `model/3mf` content type must
resolve to the 3MF loader in ModelViewer and route to <ModelViewer> in
CodeViewer, exercising the shared getModelFormat() resolver.

Regenerate web/package-lock.json so the diff vs origin/main is limited to
the `three` dependency subtree — dropping unrelated resolved-URL
normalization churn from an earlier regen.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): dispose material textures in ModelViewer teardown

disposeObject() freed each mesh's geometry and material but not the
textures the material references (map, normalMap, roughnessMap, …), so a
textured 3MF leaked its GPU textures every time the viewer unmounted.
three.js frees neither the material nor its textures automatically.

Add disposeMaterial(), which disposes every texture slot on a material
(detected via the three.js `isTexture` flag, robust to multiple three
copies) before disposing the material itself. Extend the ModelViewer
teardown unit test with a textured-material mesh and assert its textures
are released on unmount.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(e2e): cover 3D model preview in the Files browser

Add a Playwright e2e test that seeds an ASCII STL and an OBJ file, opens
each in the Files browser, and asserts the ModelViewer mounts: the
`3D preview of …` canvas host renders a <canvas>, the "Unable to render
3D model" overlay never shows (so parsing and WebGL both succeeded), and
the flow does NOT fall through to the binary placeholder or a source
view. STL exercises MIME-based routing (application/vnd.ms-pki.stl); OBJ
exercises the extension fallback. Seeded via the filesystem PUT endpoint
(no agent run), mirroring the existing image/pdf rendering e2e tests.

This satisfies the E2E UI Required gate for the model-preview feature.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): resolve the 3D-viewer deps from the public npm registry

The three.js stack this PR added (three, @types/three,
@dimforge/rapier3d-compat, @tweenjs/tween.js, @types/stats.js,
@types/webxr, fflate, meshoptimizer) was locked with `resolved` URLs
pointing at an internal mirror (npm-proxy.dev.databricks.com), while the
rest of package-lock.json resolves from registry.npmjs.org. Public CI
can't reach that mirror, so `npm ci` timed out fetching
three-0.185.1.tgz (ETIMEDOUT) and failed the install-dependent checks.

Repoint just those eight `resolved` URLs to the canonical
registry.npmjs.org form. Integrity hashes are unchanged (the mirror
served identical tarballs), so this only changes where the tarballs are
fetched from, not what is installed. `npm ci --legacy-peer-deps` now
succeeds from a clean node_modules, and `npm install --package-lock-only
--legacy-peer-deps` produces no further diff, so the lockfile-up-to-date
gate stays green.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <bryan.li@gmail.com>
2026-07-26 18:15:42 -07:00
Bryan Li bf5b3c3a61 fix(android): honor system dark mode (#3006)
* fix(android): honor system dark mode

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): sync system bar contrast

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): harden resolved theme sync

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* refactor(android): decode theme at bridge boundary

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(android): tighten theme bridge coverage

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): drop WebView algorithmic darkening

Algorithmic darkening inverts the SPA when the user forces light mode
while the OS is dark: the page's root color-scheme is then 'light', so
WebView treats it as dark-unaware and darkens it algorithmically,
leaving dark status-bar icons over a darkened page. With targetSdk >= 33
the DayNight host theme alone makes prefers-color-scheme track the OS,
so the darkening flag added nothing for the system-mode path and only
broke the forced-light path. Verified on an API 34 emulator across the
OS-light/dark x app-System/Light/Dark matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): keep Electron on the selected theme, not the resolved one

Reporting only resolvedTheme regressed Electron system mode: an explicit
Light selection under a light OS changes no resolved value, so no report
fired and themeSource stayed 'system' — the shell chrome then flipped
dark with the OS while the app was forced light. Report the resolved
scheme first (Android system-bar contrast) and follow with 'system'
while that is the selection: Electron keeps the last report, so it
tracks the OS in system mode and pins to explicit selections, including
ones that leave resolvedTheme unchanged. Android drops 'system' at the
bridge, so its behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix: route native themes by consumer

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): harden system bar theme sync

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(android): clean up theme bridge state

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): install theme bridge at document start

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): resync system bars on live theme changes

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* style(android): format theme test

Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:10:16 -07:00
Zeyi (Rice) Fan 6d32e8fdbd ci(release): skip GitHub releases for rc/dev/pre tags (#2962)
github-release.yml fired on every v[0-9]* tag push and created an
unpublished DRAFT release for rc/dev/alpha/beta tags. Nothing downstream
depended on those drafts — draft-release-notes.yml already skips rc,
finalize-release.yml refuses rc, and the Docker/homebrew/changelog
workflows fire on the tag push / release:published directly. The drafts
just accumulated (and rehearsal rcs had to be gh-release-deleted during
cleanup).

Add a guard that skips the draft-release job for rcN/devN/preN tags
(trailing digit required so a substring like 'dev' in a mistyped tag can't
trip it). Drop the now-dead alpha/beta arms — this repo only cuts rc
pre-releases — and align the same rc/dev/pre pattern + comments across
the other release-adjacent workflows for consistency. Update release.yml's
Next-steps text and RELEASING.md accordingly.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-26 15:59:43 -07:00
Zeyi (Rice) Fan b0cabcb336 fix(ios): block cross-origin redirects in the post-consent workspace probe (#3115)
## Related issue

Closes #[F-CR-7]

## Summary

- After a user consents to an unknown server (deep link) or types a server URL, `WorkspaceURLExpander.expandIfNeeded` issued a HEAD probe via `URLSession.shared` with no redirect policy, so the consented host could 3xx-redirect the probe to a different origin — including a local-network service — breaking the consent alert's promise that the app only talks to the host the user approved.
- The probe now defaults to a dedicated `URLSession` backed by `SameOriginRedirectHandler`, a `URLSessionTaskDelegate` that follows only same-origin redirects (scheme + host + port match) and blocks any cross-origin redirect by returning `nil` from `willPerformHTTPRedirection`.
- As defense in depth, `expandIfNeeded` additionally verifies `response.url`'s origin matches the approved origin, so a cross-origin response is never trusted even if a caller supplies a bare session without the redirect delegate.
- Rebased onto #3179 (F-CR-6) and deduped: removed my `--omnigent-deep-link` test hook (subsumed by #3179's `--omnigent-open-url` / `--omnigent-reset-state` seam), and consolidated the two `MockHTTPServer` copies into one shared file compiled into both test targets.

## Test Plan

- Unit: `WorkspaceURLExpanderTests.testRejectsResponseFromDifferentOrigin` returns a `server: databricks` 200 whose `url` is a different origin and asserts the URL is left unchanged.
- Integration (simulator, real local HTTP network): `WorkspaceURLExpanderRedirectTests.testBlocksCrossOriginRedirect` / `testFollowsSameOriginRedirect` assert a cross-origin redirect is blocked (response stays 302 on the approved port) and a same-origin redirect is followed. Confirmed meaningful: the cross-origin test fails when the delegate is reverted to follow-all-redirects (the vulnerable behavior).
- UI (simulator): `RedirectConsentUITests.testDeepLinkConsentOpensApprovedServer` drives the deep-link consent flow via #3179's `--omnigent-open-url` + `--omnigent-reset-state` seam and asserts the alert appears, "Open" loads the approved server's WebView.
- Ran on iPhone 17 simulator: all 8 expander/redirect tests + the UI smoke test + all 26 F-CR-6 deep-link tests pass; full project builds.
- Note: the UI test cannot exercise the redirect itself — a localhost deep link infers `http`, and the probe is https-only, so the probe never fires for loopback. The redirect policy is verified over a real local network by the integration test instead.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manual verification: built and ran the new unit, integration, and UI tests on the iPhone 17 simulator; confirmed all pass and that the integration test fails against the vulnerable (follow-all-redirects) baseline, proving it is a meaningful regression test. Also ran all F-CR-6 tests after the dedup to confirm no regression from #3179's shared seam.

## Changelog

The iOS app no longer follows cross-origin redirects when probing a newly approved server for the Databricks workspace mount, so a consented host can't redirect the probe to a different origin.
2026-07-26 15:57:44 -07:00
Rahul Ravindranathan 61fd72350e feat(automations): rename Scheduled Tasks UI to Automations (UI only) (#3260)
CI / Pytest (runtime-core) (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
Lint / Pre-commit checks (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
UI Preview / notify (push) Has been cancelled
UI Preview / build (push) Has been cancelled
UI Preview / cleanup (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
web Tests / npm test (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
UI Preview / deploy (push) Has been cancelled
CI / gate (push) Failing after 1s
Lint / gate (push) Failing after 1s
web Tests / gate (push) Failing after 1s
* feat(automations): rename Scheduled Tasks UI to Automations (display copy only)

UI-facing name is now 'Automations'; internal name (DB/CRUD/API/components/
comments/route) remains 'scheduled task'. Changes limited to user-visible
display strings in 5 source files + 2 test files.

Changed:
- TasksPage.tsx: h1, search placeholder, load error, loading text, empty states
- Sidebar.tsx: nav label "Scheduled" → "Automations"
- CommandPalette.tsx: "Go to Scheduled tasks" → "Go to Automations"
- CreateScheduledTaskDialog.tsx: dialog titles + error messages
- Test assertions updated to match new copy

No component names, file names, types, data-testids, routes, or backend
paths were altered.

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

* test(e2e-ui): regenerate visual baselines for Automations rename

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

* docs(scheduled-tasks): document Automations (UI) vs scheduled-task (internal) naming

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

* test(ui-snapshot): regenerate visual baselines after main merge

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-25 11:02:53 -07:00
Pat Sukprasert 4788a77d54 test: stabilize two known flakes (dictation close, agent-info popover) (#3224)
Lint / gate (push) Failing after 1s
web Tests / gate (push) Failing after 0s
CI / gate (push) Failing after 1s
Doc sync / Classify and draft docs (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
Lint / Pre-commit checks (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
UI Preview / notify (push) Has been cancelled
UI Preview / build (push) Has been cancelled
UI Preview / cleanup (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
web Tests / npm test (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
UI Preview / deploy (push) Has been cancelled
* test: stabilize two known flakes (dictation close, agent-info popover)

Two load-timing flakes that recur across PRs:

- Pytest (server-rest) test_dictation.py::test_stream_closes_take_on_
  abrupt_disconnect: on an abrupt disconnect the route offloads
  handle.close() to a thread. During teardown the loop's thread-pool
  executor may already be shutting down, so the offload raises and the
  old contextlib.suppress swallowed it — the take (and, for the remote
  engine, a worker slot) leaks. Fall back to a direct close() on the
  loop; it's a quick non-blocking free for every engine.

- E2E UI test_agent_info_popover.py: _open_popover single-clicked the
  trigger, but the button hover-opens on the click's own pointer arrival
  and the click's Radix toggle can flip it back shut past the
  HOVER_CLICK_GRACE_MS window under load, so the panel never mounts.
  Confirm the panel opened and retry the click from a closed state.

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

* test: stabilize scheduled-tasks time-picker flake

test_scheduled_task_create_edit_modal_and_time_picker had two coupled
races in the time-picker step (6/10 failures reproduced, no artificial
load needed):

- The picker is a Radix popover nested in the create-task dialog. The
  dialog's focus management can fire an interaction-outside that closes
  it the instant it mounts, so the minute cells unmount between the
  visibility check and the click (element-not-found / click timeout).
- Selecting a minute leaves the popover open, and an open floating-ui
  popover keeps recomputing its position — so the submit button (and,
  later, the edit-phase time input) stays perpetually "not stable" and
  detaches mid-click.

Extract a _pick_minute() helper that opens from a known-closed state and
retries until the cell is present, then dismisses the picker via a
click-outside (not Escape, which would bubble to the Radix Dialog and
close it) and waits for it to unmount so the layout settles before
submit. 0/12 clean + 0/8 under load after the fix.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-25 18:17:13 +07:00
Pat Sukprasert 7a73bc30a7 ci: clear stale waiting labels after author activity (#3242)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-25 17:24:54 +07:00
Jackson Zheng 0b4153548f Prevent inline base64 from leaking into replay context (#3267)
* fix: redact base64 from compaction history

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Harden inline base64 redaction

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-25 00:04:14 -07:00
Tomu Hirata 4fb449187b fix(web): hide Smart Routing from native terminal sessions (#3259)
The in-session "Configure" model dropdown offered a "Smart Routing" option on
native terminal sessions (Claude Code, Codex, Pi, …). It's meaningless there:
a native CLI bakes its model into the launch argv once and can't per-turn
route, so picking it did nothing useful.

Add isNativeTerminalSession() (mirrors the server's
_native_coding_agent_for_session: native by omnigent.wrapper label OR resolved
harness) and exclude such sessions from costRoutingEligible in ChatPage, so the
Smart Routing option no longer appears in their Model dropdown. Brain-harness
sessions (claude-sdk / codex / pi, and the polly orchestrator) keep it.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-25 03:30:17 +00:00
Jackson Zheng 568d620da2 Polish sidebar session row layout (#3208) 2026-07-24 19:28:34 -07:00
Rahul Ravindranathan 039fa67089 feat(scheduled tasks): Run now, relative next-run, and Tasks-list row polish (#3218)
* feat(scheduled tasks): add windowed latest-run-status store query

Add ScheduledTaskStore.list_latest_run_status_for_tasks(ids) -> {id: status},
a single row_number()-windowed query (scheduled_at DESC, id DESC — same order
as list_runs) returning each task's most-recent run status. Powers the Tasks
list completion badge in one query instead of N per-row /runs fetches, and is
correct under overlapping run-now runs (unlike a denormalized last_run_status
column). Tasks with no runs are absent from the map.

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

* feat(scheduled tasks): run-now endpoint + status/next-run serializer fields

Backend for three Tasks-list run controls:

- last_run_status: _to_response now carries the task's most-recent run status
  (from the windowed store query), populated on list/get/patch. Force-fail of
  stale orphans runs BEFORE the status read so a dead run reports failed, not a
  stuck running.
- next_run_at: _to_response carries the live scheduler's authoritative next-fire
  ISO timestamp (scheduler.next_run_at) on list/get/create/patch — server-
  sourced, never client-recomputed (paused/unarmed → null).
- POST /v1/scheduled-tasks/{id}/run: an immediate manual fire that REUSES the
  shared fire path via build_run_now (same _run_fire_for_task body, dispatch/
  preflight seams, and in-flight overlap guard as the scheduler). Paused tasks
  are runnable (manual override); fire-and-forget → 202 Accepted. 409 when a
  fire is already in flight, 404 for a non-owned task, 503 when the scheduler
  subsystem is not running. Wired via app.state.scheduled_task_run_now.

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

* feat(scheduled tasks): status pill, run-now menu, next-run on rows

Wire the three run controls into the Tasks list UI:

- last_run_status → a completion pill on each row (Failed/Skipped/Running/
  Queued). Succeeded and never-run render NO pill (success is not noise);
  Failed is destructive, Skipped muted — matching the Paused pill styling.
- next_run_at → "Next: <time>" on the schedule subline, formatted in the
  task timezone via a new formatNextRunAt() that only FORMATS the server's
  ISO value (never client-recomputes; paused/unarmed → nothing).
- Run now → a "⋯ menu" item + useRunScheduledTaskNow mutation (POST
  /{id}/run) that invalidates the list + that task's runs so the pill
  updates. Runnable for paused tasks; row busy-disables while in flight.

scheduledTasksApi gains lastRunStatus + nextRunAt (interface + wire map)
and runScheduledTaskNow(). Unit tests: pill per status, no-pill cases,
next-run formatting (tz + calendar-day boundary), run-now mutation wiring.
e2e: new run-controls journey (Run now → recorded run + pill flips);
existing schedule-line assertions relaxed to to_contain_text now that the
server next-run renders on the same line.

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

* style(scheduled tasks): bump task title size/weight on rows

Make the scheduled-task row title slightly larger and bolder: text-sm →
text-base and font-semibold → font-bold. Subline, pills, and spacing are
unchanged. Updates the one TasksPage sort-order test that located the title
by its .font-semibold class to .font-bold.

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

* Revert "style(scheduled tasks): bump task title size/weight on rows"

This reverts commit e0195ce3c4977abdaeba5316426029f808edeef6.

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

* style(scheduled tasks): title 15px, metadata 13px on rows

Trim the row title to exactly 15px and the metadata subline to exactly 13px
using arbitrary-px classes (text-[15px] / text-[13px]) — the app root scales
rem ~1.125×, so the standard text-sm/text-xs would render 15.75/13.5px and
can't hit the exact target. Weights unchanged: title font-semibold (600),
subline no weight class (inherits 400). Pills, spacing, next-run text, and the
⋯ menu are untouched.

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

* style(scheduled tasks): lighten metadata subline on rows

Soften the row metadata subline one notch to a lighter gray via an opacity
step on the same theme token: text-muted-foreground → text-muted-foreground/80.
Theme-aware (works in light + dark), size unchanged (13px), and the next-run
<span> keeps inheriting the same color (no own color class). Title, pills,
spacing, and the ⋯ menu are untouched.

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

* style(scheduled tasks): tighten row spacing 2px, remove run-status pill

- Row vertical padding py-3 → py-[11px]: trims each side 1px so the gap
  between adjacent rows drops from 24px to 22px (the list is flex-col with no
  gap, so the row padding is the whole inter-row spacing).
- Remove the last-run status pill (Failed/Skipped/Running/Queued) entirely per
  design: drop the render block, the RUN_STATUS_PILL map, the statusPill local,
  and the now-unused ScheduledTaskRunStatus import. The Paused pill is kept
  as-is. The lastRunStatus API/store field is left in place (harmless data;
  only the visual is removed). Subline, next-run text, and the ⋯ menu unchanged.

Drops the per-status pill test cases in ScheduledTaskRow.test.tsx (that UI is
gone); keeps the paused-pill, next-run, and run-now tests.

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

* style(scheduled tasks): relative "Next run in Xh" on rows

Switch the row next-run display from an absolute label ("Next: Today 9:00 AM")
to a compact relative delta ("Next run in 15h" / "in 6d" / "soon"):

- formatNextRunAt now returns a delta (nextRunAt − now): <60m → "in Xm" (min
  "in 1m"), <24h → "in Xh", else "in Xd", all floored; a delta below 1 min
  (imminent / clock skew) → "soon"; null/unparseable iso → null. The `timezone`
  param is dropped (a pure delta needs no zone) — call site + useMemo deps
  updated. This only formats HOW FAR AWAY the server's authoritative next_run_at
  is; it never recomputes WHICH instant is next on the client, so the old
  "no client-recomputed countdown" rule still holds.
- Row prefix "Next: " → "Next run " so it reads "Next run in 15h".

Tests: rewrote the formatNextRunAt unit tests for the relative buckets +
boundaries + "soon" + null; updated the row test to the "Next run in …" prefix;
reconciled the e2e (the old count==0 "Next run" guard flips to positively
asserting the server-derived relative label — its real intent, no client
recompute, is unchanged).

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

* style(scheduled tasks): darken row hover background

Bump the full-row hover tint one notch: hover:bg-muted/50 → hover:bg-muted/70
(same theme-aware `muted` token, higher opacity). The color-mix stays
`var(--muted) N% transparent`, so in light the effective tint goes ~2.9% → 4.1%
black and in dark the alpha goes 0.5 → 0.7 — visibly stronger but still subtle.
Comment updated to match. Nothing else changes.

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-24 18:20:45 -07:00
Jackson Zheng 397aeeb293 Support background titles for native Codex (#3199) 2026-07-24 17:59:02 -07:00
Jackson Zheng 9a354b6700 fix(claude-native): durably persist compaction boundary on resume/replay (#3118) 2026-07-24 17:40:58 -07:00
Thomas Garnier 3b9d8d55a4 Add databricks_cli secretless credential proxy type (#3080)
Adds a 'databricks_cli' credential_proxy type so sandboxed tools can use
the Databricks CLI without the real OAuth/PAT token ever entering the
sandbox. The operator lists which ~/.databrickscfg profiles to proxy;
each is materialized into the sandbox as a placeholder-only .databrickscfg
(oa_cred_* token), and the L7 egress proxy swaps the placeholder for the
real token on the way out.

- Refreshing token provider (DatabricksProfileTokenProvider) re-mints
  short-lived OAuth tokens via the databricks SDK for long sessions;
  CredentialRewriteRule gains an optional secret_provider and the proxy
  resolves secrets per-swap (offloaded via run_in_executor).
- Placeholder-only files are materialized into the sandbox scratch dir
  and pointed at via DATABRICKS_CONFIG_FILE / DATABRICKS_CONFIG_PROFILE.
- Requires the 'databricks' extra and linux_bwrap (the Go CLI ignores
  SSL_CERT_FILE on macOS, so darwin_seatbelt is rejected at parse time).
- Egress stays operator-listed: the workspace host must be named in
  egress_rules, consistent with the other credential_proxy types.

Signed-off-by: mxatone <mxatone@gmail.com>
2026-07-24 17:00:52 -07:00
Zeyi (Rice) Fan e1a3fdb82f chore(release): bump omnigent-slack to 0.7.0.dev0 and add it to the lockstep version cycle (#3207)
## Related issue

N/A

## Summary

- Bring `omnigent-slack` into the lockstep release cycle (now four packages, not three): its `[project].version` was stuck at `0.1.0` while the rest of the repo moved to `0.7.0.dev0`, so the extra pin and lockfile drifted.
- Pin `omnigent-slack==0.7.0.dev0` in the root `slack` optional-dependency extra, mirroring the existing `omnigent-client==` / `omnigent-ui-sdk==` sibling pins so a published `omnigent[slack]` always pairs with the matching `omnigent-slack` release.
- Teach `scripts/update_versions.py` (the engine behind `.github/workflows/bump-version.yml`) about the 4th package: rewrite the slack `[project].version` and the extra `==` pin on every bump, and scan `[project.optional-dependencies]` (not just `[project.dependencies]`) when verifying sibling pins. Regenerate `uv.lock`.

## Test Plan

- `uv run --no-project --python 3.12 --with packaging python scripts/update_versions.py check` → prints `0.7.0.dev0` (all four packages agree, all sibling `==` pins present).
- `uv lock` → "Updated omnigent-slack v0.1.0 -> v0.7.0.dev0".
- `uv run ... python -m pytest tests/scripts/test_update_versions.py` → 13 passed (updated the test fixture + assertions for the 4th package).
- `tests/test_version.py::test_version_matches_pyproject` still passes (root pyproject == `omnigent/version.py` at `0.7.0.dev0`).

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Updated `tests/scripts/test_update_versions.py` to include `integrations/slack/pyproject.toml` in the `repo_copy` fixture and adjusted the lockstep assertions (5 changed files, 4 `9.9.9` occurrences in root pyproject, 1 in slack). Verified the full suite (13 tests) passes. Also ran `update_versions.py check` and `uv lock` manually to confirm lockstep + lockfile consistency.
2026-07-24 14:54:07 -07:00
Dhruv Gupta 86463e6129 docs(contributing): add Developer Certificate of Origin language and DCO file (#3252) 2026-07-24 20:27:37 +00:00
Cathy Yin 76281b9438 feat(onboarding): write a harness provider credential from the UI (M3 backend) (#3088)
CI / gate (push) Failing after 6s
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
* feat(onboarding): report the installed-but-unconfigured harness state

Add the credential axis to the picker-facing readiness map so Claude, Codex,
and Pi report the yellow "installed but no credential" state — the signal the
web setup dialog needs to know when to offer an "Add credential" action
(the M2 keystone of Setup-From-the-UI). Purely additive: the values
("needs-auth" / "binary-missing") already exist in HarnessAvailability, and
the launch gate (harness_is_configured) stays binary-only, so a not-yet-authed
harness is never blocked from launching.

- New _family_provider_configured(): whether an omnigent-managed provider
  (API key / gateway) serves the harness's family, reading the same config
  omni setup's overview does. Subscription-kind is excluded (that lives in the
  CLI's own login, judged by harness_cli_logged_in). Local, side-effect free,
  never raises (fails to "no credential").
- Claude: ready when a provider is configured OR the CLI subscription login is
  present (was CLI-login only — an API-key-only user wrongly showed yellow).
  Checks config first to avoid the status subprocess on the common path.
- Pi: gains the axis it lacked entirely (was binary-only → always green once
  installed). No CLI login, so binary + provider: installed-but-no-provider is
  now "needs-auth".

Codex already had this (unchanged, the template). Qwen/OpenCode env-auth
unchanged.

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

* feat(onboarding): write a harness provider credential from the UI

Second PR of Setup-From-the-UI (M3, security-sensitive). Adds the path that
turns a yellow "installed but not configured" harness green from the browser
for Claude / Codex / Pi, host-agnostic (local or remote), reusing the
credential-write logic omni setup already uses.

Design: the server is an authz'd pass-through. It validates ownership + the
UI-auth allowlist and forwards the secret over the (TLS) tunnel; the host DAEMON
does the write on the runner. The server never persists the secret, and the
frame's secret_value field is redaction-named so it never lands on a telemetry
span. Gated behind OMNIGENT_HARNESS_INSTALL_ENABLED (default off) exactly like
the install route (404 when disabled).

- New non-interactive core omnigent/onboarding/harness_auth.py: store a key /
  gateway (secret → keychain, else ~/.omnigent/secrets.json; a providers: entry
  referencing keychain:<name>, never the raw key), adopt an existing host env
  var by reference (env:<VAR>, value never read), and detect adoptable env
  credentials (non-secret descriptors only). First provider on a family becomes
  the default; unsupported families/kinds are refused.
- New host.store_secret / _result frame pair; host daemon handler resolves the
  harness→family, calls the core, and re-reports readiness so the badge flips
  without a reconnect. Pi maps to its preferred anthropic family.
- New route POST /v1/hosts/{id}/harnesses/{harness}/credential (owner-scoped,
  allowlisted, flag-gated) + registry pending_secret_writes plumbing + tunnel
  result resolution.
- Regenerated openapi.json.

Tests: core unit tests (incl. the no-raw-secret-in-config invariant), frame
round-trip + telemetry-redaction, host-handler unit tests, and a full route
integration test over a fake tunnel (ownership, flag-off, allowlist, failure
mapping). 243 pass across the affected suites.

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

* feat(onboarding): detect adoptable credentials on the host (adopt flow)

Adds the read side of the adopt flow: a host.detect_credentials frame pair +
GET /v1/hosts/{id}/credentials/detected that reports the credentials already
present on the host as NON-secret descriptors (family + source label + env var
name), so the UI can offer a one-click "adopt" instead of asking the user to
paste a key they already have. The value is never read or sent — adopt writes
an env:<VAR> reference via the existing store_secret path.

Owner-scoped + flag-gated like the credential-write route. Decode drops
malformed entries so a garbled payload can't inject a non-string field the UI
would trust. Adds frame round-trip (+ malformed-drop), host-handler, and route
integration (+ flag-off) tests; regenerated openapi.json.

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

* fix(onboarding): tighten the credential route + adopt guard (Polly review)

Two review fixes on the credential-write path:

- The route gated on ui_installable_harnesses(), which includes the env-auth
  opencode/qwen — the host handler then rejected them, turning a client/allowlist
  problem into a confusing 502. Add ui_credential_configurable_harnesses() (the
  Claude/Codex/Pi families the host can actually write) and gate on it, so
  opencode/qwen get a clean 400 with no frame forwarded.
- adopt_env_credential now refuses an env var that isn't set on the host —
  adopting an unset var would persist a provider entry that resolves to nothing
  at the first turn. (Runs on the runner, so os.environ is the host's env.)

Tests: opencode/qwen added to the 400-rejection parametrize; an unset-env-var
adopt rejection case.

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

* fix(server): serialize concurrent credential writes to one host (Polly review)

Polly non-blocking note: unlike the install route (which coalesces via
inflight_installs), the credential route had no guard against overlapping
writes. The daemon's write is a non-atomic load→merge→save of config.yaml
(twice — entry, then default), so two writes to one host in quick succession
(a double-click, or key + gateway) could interleave and clobber a sibling
providers: entry.

Add a per-connection credential_write_lock held around the store-secret
round-trip so writes to one host serialize. A gateway/local host still
processes different hosts concurrently (the lock is per HostConnection).
Adds an integration test that holds the first reply and asserts the second
frame only reaches the host after the first completes.

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

* fix(onboarding): make Pi's auth step UI-authable and trackable

Pi's setup_steps auth descriptor was still the M1 shape (action="setup",
command="omnigent setup", status_key=None). Two consequences surfaced in
manual testing: (1) status_key=None made the step "unknown", so the setup
dialog dropped it and wrongly showed "Pi is ready" with no action even though
readiness reported needs-auth; (2) even rendered it was a CLI signpost, not
the credential form.

Pi is UI-authable now (PR A gave it the needs-auth readiness axis; the UI has
the credential form), so its auth step becomes action="auth" (opens the inline
form, keyed on kind=="auth"), command=None (Pi has no subscription CLI login),
status_key="authed" (trackable, so it's not dropped and the dialog reflects
the real state). Qwen stays the untracked env-auth signpost (not UI-authable).
Updates the pi test and adds a qwen-stays-signpost test.

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

* chore: use the `omni` CLI alias (omni setup) in setup guidance

Rename user-facing "omnigent setup" → "omni setup" in the harness setup-step
descriptors, the setup hint, and their doc-comments. `omni` is the installed
console entry point (pyproject: omni = omnigent.cli:main) and is already used
elsewhere in the codebase, so the shorter alias is correct and consistent.

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

* test: fix CI drift on the M3 backend branch (omni setup + auth action)

Two "Pytest (misc)" failures on this branch were stale test expectations, not
product bugs:

- tests/host/test_connect.py asserted the unconfigured-launch error names
  "omnigent setup", but the earlier `omni` CLI-alias rename made the runtime
  message say "omni setup". Update the positive assertion and the cursor
  test's negative assertion (which guards that Cursor points at its own
  installer, not the generic setup command) to the new spelling.
- tests/test_harness_capabilities.py restricted setup-step actions to
  ("install", "command", "setup"), but Pi's UI-authable step uses action
  "auth" (added when Pi's credential step became a form). Add "auth" to the
  allowed set; codex's own two-step assertion is unchanged.

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

* test: harden the install-flow e2e against a slow picker render

test_install_button_installs_missing_harness opened the agent picker and
immediately clicked the Codex row, but the picker mounts its rows only after
the /v1/agents fetch resolves. Under CI load that render lags, and a menu
opened before the data lands can render empty or re-close on the update — so
the bare open-then-click flaked with a 30s click timeout, the Codex row never
becoming actionable (seen across two different shards). Open the picker, wait
for the Codex row and re-open if the menu flapped, then click. No product
change; passes locally unchanged (the retry is a no-op on the fast path).

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

* test: settle agent data before opening the picker in the install e2e

The install-flow e2e flaked (30s click timeout on the Codex row, then on the
picker trigger via an overlay pointer-interception when reopened). Root cause:
the picker opened before the /v1/agents fetch settled, racing the menu-open
against a re-render. Wait for the composer's "Set up Codex" notice (rendered
only once the Codex agent + its unconfigured host state load) BEFORE opening the
picker, then open once and click. Passes locally repeatedly.

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

* test: stop driving the agent picker in the install e2e (kill the flake)

The picker interaction was redundant — the single seeded Codex agent is already
auto-selected, so the composer's "Set up Codex" notice is present without
opening the dropdown. Driving the picker only added a menu-open-vs-async-render
race that flaked under CI load. Wait for the notice directly (generous 60s) and
click it to open the setup dialog.

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

* test: wait for network idle before asserting the setup notice (install e2e)

The "Set up Codex" notice depends on two async fetches re-rendering the
composer (/v1/agents auto-selecting the agent, /v1/hosts marking its harness
unconfigured). On loaded CI runners that chain lagged past the timeout and the
assertion raced the still-loading landing screen. Wait for network idle and the
host chip (readiness present) before asserting the notice.

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

* test: drop networkidle wait in install e2e (WS keeps network busy)

wait_for_load_state("networkidle") never fires in this app — the shell holds a
long-lived sessions/updates WebSocket, so the network is never idle. That wait
just burned its timeout and then raced the still-loading landing screen (the
"Set up Codex" notice was intermittently absent on CI). Replace it with plain
element waits (host chip, then the notice) at a generous 60s, matching every
other e2e_ui test. Passes locally repeatedly.

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

* fix(host): adopt an env credential under its own family, not the harness's

Review (isaac, MAJOR): pi consumes both the anthropic and openai families, so
the UI can offer an OpenAI env var (e.g. $OPENAI_API_KEY) as adoptable for pi.
`_handle_store_secret` derived the family solely from the harness (pi→anthropic)
and passed that to `adopt_env_credential`, so adopting that var wrote an
anthropic-family provider whose api_key_ref is env:OPENAI_API_KEY — mis-routed
to the anthropic endpoint, failing at run time. For the adopt kind, look the env
var up in the host's detected credentials and use its OWN detected family
(falling back to the harness family if absent). Adds a pi-adopts-OpenAI
regression test.

Also carry the install-flow e2e fix onto this branch: explicitly select Codex
in the picker and stub the /v1/sessions?kind=any agent scan so the seeded-DB
agents don't leak in and leave Claude Code selected (a CI-only flake).

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

* fix(host): harden the UI credential-write path (review feedback)

Addresses Polly's blocking finding + hardening notes and Pat's nits on the
store_secret/adopt path:

- BLOCKING (adopt boundary): the daemon's adopt handler fell back to the
  harness-derived family when an env var wasn't detected, and adopt_env_credential
  only checked the var was *set*. An owner hitting the raw API could name any set
  env var (a DB password, an unrelated secret) and have it persisted as a provider
  credential sent to the vendor endpoint. Now the handler refuses an env_var that
  isn't in detect_adoptable_credentials() (no fallback) — enforcing server-side the
  same "only adopt what was detected" restriction the UI presents.
- secrets.py: create the file-backend secrets.json 0600 atomically via
  os.open(O_CREAT, 0o600) instead of open()+chmod-after, which briefly left a
  freshly-created file group/world-readable. Now network-triggerable, so worth
  closing. Fixes the stale "0600 from the start" comment.
- adopt_env_credential: presence-only env check (`in os.environ`, not `.get`) so
  the "never reads the value" contract stays literally true.
- gateway base_url: reject a non-http(s) scheme at write time rather than writing
  a malformed provider entry that fails opaquely at the first turn.
- connect.py: hoist the harness_auth / provider_config imports to module top
  (no circular import) to match the sibling onboarding imports.

Adds regression tests: adopt refuses an undetected env var, gateway rejects a
non-http base_url, and secrets.json is 0600 even under a permissive umask.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-24 17:48:43 +07:00
dependabot[bot] 983c93c6ec chore(deps-dev): bump fast-uri from 3.1.2 to 3.1.4 in /editors/vscode (#3035)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 10:21:49 +00:00
Tomu Hirata 8b2276c529 fix(docker): wire llm/policies/routing into Docker entrypoint RuntimeCaps (#3222)
The Docker entrypoint's build_app() was constructing RuntimeCaps()
bare, so the llm:, policies:, and routing: blocks in a docker
deployment's config.yaml were silently ignored. This meant:

- Builtin policies that read event["llm_client"] (e.g.
  deny_trivial_to_expensive_model) would always see None and abstain.
- default_policies declared under policies: would never fire.
- LLM-based and external routing clients were never built.

Mirror the logic from cli.py: parse_server_llm / parse_default_policies
/ routing client construction are now applied before RuntimeCaps is
passed to init_runtime, putting docker deployments on par with the
CLI-started server.

Fixes #3159

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 10:14:59 +00:00
Serena Ruan e491999a14 fix(sessions): strip per-user pin keys from child-session summaries (#3214)
Follow-up to #3189. Every session serialization path collapses per-user
`omnigent.pinned.<user>` keys via `_labels_for_viewer` except
`_child_session_summary_from_conversation`, which passed `conv.labels` through
raw. Child sessions aren't pinnable today (the pin affordance lives only on
top-level sidebar rows), so this is a latent gap rather than a live leak — but
if a shared child were ever pinned, its summary would expose another viewer's
pin key.

- Strip any `omnigent.pinned.<user>` key from a child summary's labels. No
  collapse-to-canonical: there's no pin to surface, just the defensive strip.
- Test: a child carrying two users' pin keys yields a summary with no pin key,
  while unrelated labels survive.
- Correct the stale `useMigrateLocalPinsToServer` docstring: the migration
  patches the pinned-list cache (like `useTogglePinnedConversation`), it does
  not invalidate the pinned query.


Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:36:07 +08:00
Tomu Hirata 1674f686fe fix(pi-executor): add supportsUsageInStreaming:false to databricks-completions (#3203)
* fix(pi-executor): add supportsUsageInStreaming:false to databricks-completions

Gemini, Qwen, inkling, and other non-OpenAI models in the databricks-completions
provider reject stream_options (which Pi sends with include_usage:true by default)
with 400 'unknown field'. Add supportsUsageInStreaming:false to suppress it,
matching what pi_native_credentials.py already does for omnigent-completions.

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

* fix(pi-executor): use openai-responses for newer GPT models (gpt-5-5, gpt-5-6-*)

Newer GPT models reject function tool calls via /chat/completions with 400.
The Databricks Responses API (/ai-gateway/codex/v1/responses) now supports
tool-result chaining on subsequent turns (previously it did not).

- Add databricks-openai provider using openai-responses at /ai-gateway/codex/v1
  for gpt-5-5, gpt-5-6-*, gpt-5-3-codex (matches pi_native_credentials routing)
- Keep databricks provider (openai-completions at /serving-endpoints) for
  older GPT models (gpt-5-4, gpt-5-4-mini) that work fine with /chat/completions
- Add _pi_needs_responses_api() helper mirroring pi_native_credentials
- Update _pi_provider_for_model() to route to databricks-openai when needed

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

* fix(pi-executor): add kimi to reasoning model fragments

kimi-k2-7-code streams output on reasoning_content channel like GLM/DeepSeek.
Without reasoning:true in the model entry Pi ignores reasoning_content and
sees an empty stream, throwing 'Stream ended without finish_reason'.

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

* fix(test): update kimi model entry to expect reasoning:true flag

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

* fix(pi-native): add reasoning:true to kimi/glm/deepseek model entries

These models stream output on reasoning_content channel. Pi's openai-completions
parser requires reasoning:true on the model entry to consume that channel;
without it the stream has no content and the turn ends with
'Stream ended without finish_reason'.

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

* fix(pi-native): exclude qwen3 from completions provider

qwen3 models return content as a typed array [{type:'reasoning',...},{type:'text',...}]
when tools are present, causing Pi's streaming handler to produce [object Object].
Same root cause as gpt-oss; same fix.

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

* fix: add inkling to reasoning model fragments and LLM detection

Both kimi and inkling stream output on reasoning_content channel with
content=null. Added inkling to _PI_REASONING_MODEL_FRAGMENTS (executor),
reasoning:true model entry condition (pi-native), and LLM name detection
tokens so it appears in the model list.

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

* refactor: use allowlist for GPT completions-compatible models

Instead of a denylist of specific model ids that need the Responses API,
maintain an allowlist of GPT models known to work with /chat/completions.
Any GPT model not in the allowlist defaults to Responses API — safer
for new models not yet explicitly tested.

The executor's _pi_needs_responses_api now delegates to the same
implementation in pi_native_credentials for a single source of truth.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 18:29:07 +09:00
Tomu Hirata 0c20a59ca6 feat(smart-routing): enable routing from config, drop OMNIGENT_SMART_ROUTING (#3215)
Smart routing was gated behind an OMNIGENT_SMART_ROUTING=1 opt-in on top of the
routing/llm config. The env is redundant: build the routing client whenever the
config supplies one — a server llm: block (built-in judge) or a
routing.provider=external block (external routes:select service). Remove the env
gate in cli.py and refresh the stale references in app.py, advise_models.py, and
web capabilities.ts. Server smart_routing_enabled already keyed on the resolved
client, so the /v1/info signal is unchanged.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 09:24:52 +00:00
Pat Sukprasert c326937443 docs(harness): break Phases 1 & 2 into a PR-by-PR breakdown (#3217)
The modular-registry proposal described the phases as thin numbered lists.
Turn them into a concrete, verified implementation plan reviewers can cost:

- Add a "Current state (verified 2026-07-24)" subsection grounding the plan
  in the tree at main (59e6b70e): data model ready but no native_providers
  field; run_<x>_native already near-uniform (only claude/codex/antigravity/
  opencode carry extra kwargs); coverage uneven across hubs (resume 10,
  chat-redirect 6, interrupt 9, stop 7); dead _HARNESS_MODULES literal still
  present; harness_catalog() emits no native-agent rows.
- Phase 1 (core-only seam): 8 PRs (1.1–1.8) in a table with scope, key files,
  dependencies, risk, and estimates. 1.1 provider model + resolver is the
  additive foundation; 1.5 runner launch/terminal-route is the risk center.
- Phase 2 (community + web): 4 PRs (2.1–2.4).
- Add an effort summary: ~26–37 engineer-days across ~12 PRs, critical path
  1.1 → 1.2 → 1.5 → 2.2 → 2.3. Refresh the Bottom line to match.

Docs-only; no code paths affected.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 16:22:56 +07:00
dependabot[bot] 85fba59e72 chore(deps-dev): bump js-yaml from 4.2.0 to 4.3.0 in /editors/vscode (#2942)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 09:11:17 +00:00
Serena Ruan 8fdce5e6d9 fix(web): remove "Create new project" from the project picker menu (#3210)
* fix(web): remove "Create new project" from the project picker menu

Projects are created via the + icon next to the Projects header in the
sidebar, so the picker's own "Create new project" row was a redundant,
second entry point. Drop it (and the inline new-project input it toggled)
from ProjectPickerMenu, leaving search, the project list, and "Remove
from <project>".

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

* test(e2e_ui): file sessions via the + button after dropping picker create

The project picker no longer offers an inline "Create new project" row, so
the e2e helpers that drove that flow broke. Rewrite `_move_to_new_project`
to create the empty project from the Projects-header + button first, then
file the session via the kebab picker by name.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:01:01 +08:00
Serena Ruan 20ec819049 feat(sessions): persist pinned sessions server-side (per-user) (#3189)
* feat(sessions): persist pinned sessions server-side as a per-user label

Pins were client-only (localStorage), so they didn't follow a user across
devices. Move them to a server-side per-user session label so a pin persists
and stays per-user even on shared sessions.

- Store: `omnigent.pinned.<user_id>` label (value = epoch-ms pin time);
  `pinned_label_key()` hashes over-long user ids to fit the 128-char key
  column. `list_conversations(pinned=True, pinned_owner=…)` filters to the
  caller's own key.
- Route: `GET /v1/sessions?pinned=true` enumerates the caller's pins
  (independent of the loaded window); PATCH rewrites the client's canonical
  `omnigent.pinned` to the caller's per-user key, and `_labels_for_viewer`
  collapses it back on read so the per-user dimension never crosses the API
  and no viewer sees another user's pin key.
- Write-integrity: reject any client-supplied suffixed `omnigent.pinned.<user>`
  key so a caller can't pin/unpin for someone else.
- Forks drop per-user pin keys by prefix (a clone must not inherit pins).
- Web: server-authoritative `usePinnedConversations` + optimistic
  `useTogglePinnedConversation`; Pinned section ordered by pin timestamp;
  one-time localStorage->server migration that retains pins whose write failed.
- Guard `relativeTime`/`absoluteTime` against non-finite input (no more "NaNy").

Co-authored-by: Isaac

* test(e2e-ui): drive visual-snapshot pins via ?pinned=true, not localStorage

The populated-sidebar visual baseline seeded the pinned session in localStorage,
but pins are now server-authoritative (GET /v1/sessions?pinned=true). Under the
new model the localStorage seed is ignored and the bare-list stub answered the
pinned query too, so every row rendered as pinned → baseline mismatch (the
non-blocking UI Snapshot job).

- Split a `?pinned=true` route out from the bare-list regex (which now also
  excludes `pinned=`, mirroring the existing `project=` exclusion) and return
  just the pinned row, carrying the canonical `omnigent.pinned` label.
- Drop the `omnigent:pinned-conversation-ids` localStorage seed.
- Apply the same fix to the pinned-project flyout baseline (it passed only by
  luck — its bare-list stub happened to return exactly the one pinned row) and
  give its row the pin label so it's explicit, not incidental.

Co-authored-by: Isaac

* fix(sessions): let read-only collaborators pin a shared session

Pinning moved server-side (per-user `omnigent.pinned.<user>` label) but the
session PATCH gated all label writes at LEVEL_EDIT, so a read-only collaborator
on a shared session could no longer pin it — a regression from the localStorage
model, which had no permission check.

- Gate a pin-only PATCH (labels == {omnigent.pinned}, no other field) at
  LEVEL_READ: pinning is a personal per-viewer preference, not an edit to the
  session, so anyone who can SEE it may pin it. Any other field keeps the
  edit/owner requirement. Unpin ("" value) is still pin-only, so it downgrades
  too. The `?pinned=true` list is already scoped `accessible_by`, so a shared
  pin surfaces on "Shared with me".
- Tests: a LEVEL_READ grantee can pin AND unpin a shared session; the downgrade
  stays narrow (a non-pin label, or a pin bundled with one, still 403s).
- Rework the access-tier comment to match the if/elif/else (READ / OWNER / EDIT).

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:00:18 +08:00
Pat Sukprasert 5a84c85a39 docs(harness): sync Phase 0 completion in modular-registry proposal (#3212)
PRs #3148 (extract native terminal orchestration) and #3149 (split the
native app-session test monolith) landed the two remaining Phase 0 file
splits. Update the proposal to reflect reality:

- §1 runner hub: app.py is now ~10.1k lines (was ~20.1k) plus the new
  omnigent/runner/native/orchestration.py (~6.5k); drop the stale absolute
  line-number anchors and clarify that the dispatch arms and interrupt/stop
  closures stayed in app.py while the builders/mirrors moved out.
- Phase 0: mark both runner/app.py and the test monolith Done, noting the
  single-orchestration.py outcome (vs the proposed three-way split) and the
  nine concern-scoped test modules + shared conftest.py.
- Risk section: re-anchor the forwarder registry to _AUTO_FORWARDER_TASKS in
  its new home and note the risk now shifts to Phase 1.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 15:36:53 +07:00
Tomu Hirata 59e6b70ea1 refactor(server): split sessions.py into 8 domain sub-modules (#3194)
* refactor(server): split sessions.py into domain sub-modules

sessions.py (7799 lines) is split into 8 focused route files under
_sessions/:

  routes_core.py       — CRUD, list, WS updates, fork, switch-agent
  routes_hooks.py      — /hooks/* and /policies/evaluate
  routes_items.py      — /items and /child_sessions
  routes_resources.py  — /resources/* (terminals, files, environments)
  routes_browser.py    — /browser/*
  routes_elicitations.py — /elicitations/*
  routes_events.py     — /events, /stream, DELETE /sessions/{id}
  routes_permissions.py — /permissions/*, /owner
  routes_agent.py      — /agent, /agent/contents, /mcp

Each file exports a register_X_routes(router, ...) function.
create_sessions_router() becomes a thin delegator (~533 lines).

helpers.py gains proxy wrappers for _same_provider_family,
_agent_is_native, _agent_carries_native_fork_history,
_presentation_labels_for_agent, and _reset_runner_resources_after_switch
so existing test monkeypatches on sessions.<name> continue to work.

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

* refactor(server): move sessions/ route sub-modules out of _sessions/

Convert sessions.py to a sessions/ package and move the 8 routes_*.py
files from _sessions/ into sessions/, so the public package layout is:

  routes/sessions/__init__.py  (facade, formerly sessions.py)
  routes/sessions/routes_core.py
  routes/sessions/routes_hooks.py
  routes/sessions/routes_items.py
  routes/sessions/routes_resources.py
  routes/sessions/routes_browser.py
  routes/sessions/routes_elicitations.py
  routes/sessions/routes_events.py
  routes/sessions/routes_permissions.py
  routes/sessions/routes_agent.py

_sessions/ retains only the private internals (common, helpers,
orchestration) that do not need public names.

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

* fix(server): use facade indirection for session_stream and get_agent_cache consistently

routes_browser, routes_events, and routes_hooks were still calling
session_stream.publish() and get_agent_cache() via the direct module
binding. Apply the same facade-indirection pattern already used in
routes_core so all call sites are consistent and test monkeypatches on
sessions.session_stream / sessions.get_agent_cache are honored.

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

* fix(server): fix lint, _to_agent_object closure, and pyproject.toml exemptions

- Move _policy_type, _policy_description, _to_agent_object from inside
  register_permissions_routes closure to module-level in routes_permissions.py
  so routes_agent.py can import them directly. Fixes NameError crash on
  GET /sessions/{id}/agent in server-approvals tests and E2E tests.

- Add missing 'return router' at end of register_permissions_routes (was
  missing after the closure reorganization).

- Import the three helpers explicitly in routes_agent.py.

- Update pyproject.toml per-file-ignores to cover sessions/*.py and
  sessions/__init__.py with the same exemptions the original sessions.py
  had (ARG001, ARG002, BLE001, E501, F401, F403, F405) so pre-commit
  ruff passes.

- Run ruff format on all sessions/ sub-modules.

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

* fix(server): fix all proxy/monkeypatch misses and restore noqa directives

Route sub-modules were importing _X_impl directly instead of using the
facade proxy, causing monkeypatch(sessions, '_X', ...) to have no effect.
Fix by removing all '_X_impl as _X' imports from routes_*.py — the star-
imports from _sessions.helpers and _sessions.orchestration already bring
in the correct facade-delegating proxies.

Additional fixes:
- Access _SESSION_UPDATES_MAX_WATCHED, _SESSION_UPDATES_RESCAN_INTERVAL_S,
  _SESSION_UPDATES_HEARTBEAT_INTERVAL_S through the facade in routes_core.py
  so monkeypatch(sessions_routes, '_SESSION_UPDATES_*', N) works.
- Use _load_agent_spec_for_session proxy (not impl) in routes_resources.py.
- Access get_caps() through facade in routes_hooks.py evaluate_policy so
  monkeypatch('omnigent.server.routes.sessions.get_caps', ...) fires.
- Restore noqa: BLE001 and F401 directives in _sessions/helpers.py and
  _sessions/orchestration.py that were stripped by the RUF100 auto-fix.

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

* fix(server): delete old sessions.py, fix remaining facade proxy misses

- Delete omnigent/server/routes/sessions.py (the file was rm'd in a prior
  commit but never staged; CI was still linting it and seeing F403/F405).

- Route _HOST_BOUND_RUNNER_CONNECT_GRACE_S through the facade in
  routes_events.py (3 call sites) so monkeypatch(sessions_module,
  '_HOST_BOUND_RUNNER_CONNECT_GRACE_S', ...) is honored.

- Route _recover_subagent_status_forward_via_parent through facade
  in routes_events.py.

- Route _registered_runner_id through facade in routes_core.py.

- Route _BROWSER_ACTION_AWAIT_S through facade in routes_browser.py.

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

* fix(server): route patchable names in routes_hooks.py through facade

All five hook handlers and evaluate_policy use module-level timeout
constants (_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S etc.) and auth
helpers (_get_user_id, get_caps, get_agent_cache) that tests monkeypatch
on the sessions facade module. Access them through _sf (the facade) at
call time so monkeypatch(sessions_route, '_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S', 0.1)
and monkeypatch('omnigent.server.routes.sessions.get_caps', ...) are honored.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 08:24:25 +00:00
Kunyu Chen d8da36d081 Simplify env variables for Slack integration on Databricks apps (#3206)
Simplify env variables for Slack integration on Databricks apps
2026-07-24 00:22:39 -07:00
Rahul Ravindranathan 5972254fda feat(scheduled tasks): edit flow + text time inputs (#3186)
CI / gate (push) Failing after 2s
CI / Coverage report (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
* feat(scheduled tasks): edit tasks

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

* feat(scheduled tasks): use text time input

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

* feat(scheduled tasks): add compact time picker

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

* feat(scheduled tasks): refine task dialog layout

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

* fix(scheduled tasks): time picker wheel-scroll + column widths

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

* fix(scheduled tasks): make host field full width

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

* fix(scheduled tasks): forward Input ref so time field stops reformatting while typing

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

* fix(scheduled tasks): show all minutes and normalize field text

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

* fix(scheduled tasks): prevent edit-modal footer buttons from being clipped

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

* fix(scheduled tasks): hourly minute field placeholder 0, digits-only, clamp 59

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

* test(scheduled tasks): e2e_ui coverage for create/edit modal + time picker

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

* fix(scheduled tasks): make nextRunAtMs O(1) so the Tasks page loads instantly

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 23:46:16 -07:00
dependabot[bot] 32dbb3159d build(deps-dev): bump esbuild from 0.21.5 to 0.28.1 in /editors/vscode (#3190)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.21.5 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 06:36:53 +00:00
Pat Sukprasert 513711cec0 feat(ci): add /rerun comment command to re-run failed CI without a push (#3195)
Re-running CI today means pushing an empty commit or rebasing, which fires a
push event and dismisses existing approvals (branch protection keeps
dismiss-stale-reviews on to block approve-then-swap). A `/rerun` comment
re-runs failed jobs on the existing head SHA instead -- no new commit, so
approvals survive.

Authorized to the PR author or a write-access commenter. Only re-runs the
mock-LLM `pull_request` test suites; the merge gates and Polly AI Review are
left alone. Single file (no privileged relay) because issue_comment gets a
writable base-repo token even for fork PRs.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 13:36:39 +07:00
dependabot[bot] a28643e6d8 chore(deps): bump mcp from 1.27.2 to 1.28.1 (#2731)
Bumps [mcp](https://github.com/modelcontextprotocol/python-sdk) from 1.27.2 to 1.28.1.
- [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases)
- [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md)
- [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.2...v1.28.1)

---
updated-dependencies:
- dependency-name: mcp
  dependency-version: 1.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 05:53:46 +00:00
Tomu Hirata c847f5aefb fix(benchmark-pr): use marker-based comment upsert instead of --edit-last (#3197)
--edit-last edits the most recent PR comment regardless of author or
content, so it was overwriting the UI preview comment when both workflows
ran on the same PR. Switch to the same find-by-marker + PATCH approach
used by ui-preview.yml so each workflow manages its own comment.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 05:49:48 +00:00
dependabot[bot] 96b467d149 chore(deps): bump js-yaml (#2943)
Bumps the electron-security group with 1 update in the /web/electron directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.2.0 to 4.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: direct:production
  dependency-group: electron-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 12:38:48 +07:00
dependabot[bot] 0ceee06155 chore(deps): bump pillow from 12.2.0 to 12.3.0 (#2940)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 05:31:36 +00:00
dependabot[bot] a3a6c1e3c7 chore(deps): bump pyasn1 from 0.6.3 to 0.6.4 (#3036)
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.3 to 0.6.4.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.3...v0.6.4)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 05:09:11 +00:00
Jackson Zheng 5f98a88b57 Enable background session titles by default (#3191)
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-23 21:53:40 -07:00
Pat Sukprasert 3df3843e18 ci: add waiting-on-author PR hygiene (#3183)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 04:21:34 +00:00
dependabot[bot] 12adae2846 build(deps-dev): bump brace-expansion in /editors/vscode (#3174)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.6 to 5.0.8.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.8)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 04:15:46 +00:00
dependabot[bot] 4214d4b5fc build(deps-dev): bump vitest from 1.6.1 to 3.2.6 in /editors/vscode (#3176)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.6.1 to 3.2.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 3.2.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 04:13:15 +00:00
Sabhya Chhabria f3bf3d8a51 [polly] Add Codex goal mode (#3181)
*  feat(polly): Add Codex goal mode

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

* 🐛 fix(codex): Preserve history for fresh goals

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>
2026-07-23 21:12:15 -07:00
Jackson Zheng 829c17942c Polish workspace pane layout (#3122)
* Polish workspace pane layout

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui-snapshot): update chat baseline

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* feat(web): add workspace tab tooltips

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(e2e): cover workspace tab tooltips

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui): update merged chat snapshot

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui): refresh merged chat snapshot

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(web): stabilize right-pane e2e coverage

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(web): stabilize remaining e2e flows

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-23 20:11:08 -07:00
Serena Ruan 9151aa9b99 fix(web): make session rename optimistic so the new name shows instantly (#3185)
* fix(web): make session rename optimistic so the new name shows instantly

Renaming a session left the stale name in the sidebar for the duration
of the PATCH round-trip: all cache patching happened in the mutation's
onSuccess, so the row only repainted once the server responded.

Move the cache overlay into onMutate so the new title paints on the next
frame, snapshot the old title for rollback, and restore it in onError.
onSuccess still reconciles with the server-confirmed title + updated_at
and keeps the deliberate no-refetch behavior (an immediate GET races the
search-index reindex). Also patch the ["project-sessions", name] caches
that project folders render from — the flat ["conversations"] overlay
never touched them, so a filed session's row stayed stale until the WS
reconcile.

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

* fix(web): cancel in-flight list queries before optimistic rename overlay

Close the in-flight-reconcile clobber race flagged in review: an
already-running GET /v1/sessions reconcile poll (or a WS-triggered
fetch) could resolve after onMutate and overwrite the optimistic title
with the stale search-indexed name. Cancel the ["conversations"] and
["project-sessions"] queries in onMutate before overlaying so no
in-flight fetch can win.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 11:09:00 +08:00
Tomu Hirata 3ba4318f17 feat(telemetry): log agent_name for polly and debby in SessionCreatedEvent (#3152)
Add an opt-in agent_name field to SessionCreatedEvent. Only polly and
debby are populated — all other agent names are withheld to avoid leaking
user-defined agent names in telemetry.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 11:42:45 +09:00
Yuan Tang dbcd72831f feat(sandbox): support OMNIGENT_CONTAINER_RUNTIME env var for container runtime selection (#2949)
* feat(sandbox): support OMNIGENT_CONTAINER_RUNTIME env var for container runtime selection

Allow switching the container runtime (e.g. from docker to podman) via the
OMNIGENT_CONTAINER_RUNTIME environment variable instead of requiring per-agent
YAML configuration. The per-agent container_runtime key still takes precedence
over the env var.

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

* fix(test): add missing top-level `Any` import in test_local.py

Ruff flagged F821 (undefined name) because `Any` was used in a
runtime dict annotation but only imported inside a nested function.

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

* fix(test): read version dynamically in crash handler test

The test hardcoded "0.6.0.dev0" which breaks when the installed
version diverges from the source (e.g. after a version bump).
Read omnigent.version.VERSION at runtime instead.

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

* Revert "fix(test): read version dynamically in crash handler test"

This reverts commit 53855f5c10e3573e9d1ddbfd2afb0bd76abbc91e.

* fix: address review comments on container runtime PR

- Make container_runtime field explicitly Optional to avoid misleading
  type annotation and unnecessary type-ignore
- Update parser docstring to mention OMNIGENT_CONTAINER_RUNTIME as an
  additional default source
- Update shell script header comment to say "container runtime" instead
  of "Docker"

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

* fix(test): add autouse fixture to clear OMNIGENT_CONTAINER_RUNTIME

Prevents the host environment from leaking into tests that assume
the default runtime is "docker".

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

* style: add missing blank line before autouse fixture

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

* fix: address additional review comments on container runtime PR

- Rename _ALLOWED_RUNTIMES to ALLOWED_RUNTIMES (public API used
  cross-module by the parser)
- Reject container_runtime: null in YAML instead of silently falling
  back to the env var default
- Add test for container_runtime: null rejection

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

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-07-23 22:22:27 -04:00
Yuan Tang 8344c18420 fix(runner): reconnect dead-but-registered native terminals before turn (#2951)
* fix(runner): reconnect dead-but-registered native terminals before turn

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-07-24 02:00:36 +00:00
Cathy Yin 1241a38e40 feat(onboarding): report the installed-but-unconfigured harness state (M2 readiness parity) (#3072)
* feat(onboarding): report the installed-but-unconfigured harness state

Add the credential axis to the picker-facing readiness map so Claude, Codex,
and Pi report the yellow "installed but no credential" state — the signal the
web setup dialog needs to know when to offer an "Add credential" action
(the M2 keystone of Setup-From-the-UI). Purely additive: the values
("needs-auth" / "binary-missing") already exist in HarnessAvailability, and
the launch gate (harness_is_configured) stays binary-only, so a not-yet-authed
harness is never blocked from launching.

- New _family_provider_configured(): whether an omnigent-managed provider
  (API key / gateway) serves the harness's family, reading the same config
  omni setup's overview does. Subscription-kind is excluded (that lives in the
  CLI's own login, judged by harness_cli_logged_in). Local, side-effect free,
  never raises (fails to "no credential").
- Claude: ready when a provider is configured OR the CLI subscription login is
  present (was CLI-login only — an API-key-only user wrongly showed yellow).
  Checks config first to avoid the status subprocess on the common path.
- Pi: gains the axis it lacked entirely (was binary-only → always green once
  installed). No CLI login, so binary + provider: installed-but-no-provider is
  now "needs-auth".

Codex already had this (unchanged, the template). Qwen/OpenCode env-auth
unchanged.

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

* docs(onboarding): clarify _family_provider_configured checks entry presence

Polly review nit: the helper returns True when a non-subscription default
provider *entry* exists, not when its secret actually resolves — an entry
pointing at an unset env:/keychain ref still reads configured (matching the
secret-blind omnigent setup overview). Reword the docstring from "usable
credential" to "a default provider entry is present" and note the
secret-blind behavior + why it's safe (launch gate is binary-only; signal
only moves toward green). No behavior change.

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

* refactor(onboarding): address review nits on readiness detection

- Hoist the provider_config import in `_family_provider_configured` to the
  module top (no circular import); update the test monkeypatch targets to the
  now-module-bound name.
- Drop the internal milestone label from a test docstring.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-24 08:03:49 +07:00
Bryan Li 4bc38b96d4 feat(sandbox): operator-configured PVC mounts for Kubernetes runners (+ fix global YAML bool-resolver leak) (#2435)
* feat(sandbox): parse and validate sandbox.kubernetes.pvc_mounts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): add pvc_mounts volumes to the runner Pod manifest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): thread pvc_mounts through the kubernetes launcher

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* docs(deploy): document sandbox.kubernetes.pvc_mounts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): fail loud on unknown sandbox.kubernetes keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(sandbox): lock in pvc_mounts collision-order, null read_only, and claim-reuse semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(sandbox): pin the reserved-mount HOME prefix to the launcher's _HOME_DIR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* refactor(sandbox): reuse shared validators in the pvc_mounts parser

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(sandbox): close pvc_mounts reserved-path gaps from review

Reject mount_paths with exactly two leading slashes — POSIX normpath
preserves them so '//home/omnigent' passed both validation gates while
the kernel collapses '//' to '/' at mount time, shadowing HOME. Add
/opt to the reserved prefixes: the host image's omnigent venv lives at
/opt/venv and was shadowable. Both cases now covered in the fail-loud
parametrization.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(sandbox): reject pvc_mounts paths that mount over reserved prefixes

The reserved-path check only caught mount_paths at or under a reserved
prefix, so an ancestor like /home or /var passed validation while
mounting over the HOME emptyDir mountpoint or the Secret projections.
Reject ancestors too, and reserve /run, /var/run, and /var/lock in full
so the Debian image's /var/run -> /run and /var/lock -> /run/lock
symlinks can't alias around the lexical check.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:47:01 +07:00
Zeyi (Rice) Fan 7b835ed767 Add kunyuchen to maintainer list (#3172)
Adds kunyuchen to the canonical maintainer list in .github/MAINTAINER so they can approve PRs and participate in maintainer-gated workflows.
2026-07-23 17:31:21 -07:00
Zeyi (Rice) Fan d2fdafce1b fix(ios): block smuggled query/fragment separators in omnigent:// deep links (#3179)
## Related issue

Closes F-CR-6

## Summary

- `DeepLink.parse` validated the `/c/<id>` segment with only `!contains("/")`, but Foundation's `URL.path` is percent-DECODED — so `omnigent://host/c/id%3Fview=terminal` exposes `?` as a literal in the path and smuggles a query (and `%23` a fragment, `%2e%2e` a `..`, `%00` a control char) past the intended "/c/<id> only" shape. Added a denylist that rejects `?`, `#`, `/`, `.`, `%`, and control chars in the decoded id, so an encoded separator that `URL.path` decoded into one of those is dropped.
- The denylist deliberately does NOT assume the id's exact format (the server emits 32-hex uuids today, but the SPA's `/c/:id` route accepts any non-slash segment); the SPA stays the authority on id validity, and a future id scheme (ULID, nanoid, base64) won't be silently rejected. Benign non-canonical ids like `conv_abc` are accepted; only structure-smuggling is blocked.
- Documented the custom-scheme hijack risk in `DeepLink.swift`: iOS doesn't verify single ownership of `omnigent://`, so a co-installed app can read the link's host + id (metadata disclosure). For managed Databricks domains that can serve an `apple-app-site-association`, prefer verified Universal Links; the custom scheme is retained for BYO/OSS servers that can't host AASA, with the interception risk documented.

## Test Plan

- Unit tests (`OmnigentTests/DeepLinkTests`): 19 cases, all pass — including `testRejectsSmuggledQueryViaEncodedQuestionMark` (`%3F`→`?`), `testRejectsSmuggledFragmentViaEncodedHash` (`%23`→`#`), `testRejectsEncodedDotAndDotDot` (`%2e%2e`), `testRejectsControlCharacters` (`%00`/`%0A`/`%7F`), `testRejectsMalformedPercentEscape` (`%zz`), and `testAcceptsBenignNonCanonicalIds` (`conv_abc`/`x`/`not-a-uuid` are accepted — no smuggled structure).
- UI tests (`OmnigentUITests`): 6 cases via a DEBUG-only `--omnigent-open-url` launch-argument seam that routes the link through the real `handleDeepLink`/`DeepLink.parse` (XCUITest can't reliably deliver custom-scheme URLs on this toolchain). `testValidDeepLinkShowsConsent` (valid link → consent alert), `testBenignNonCanonicalIdIsAccepted` (`conv_abc` → consent alert), and rejection tests for smuggled `?`/`#`/`..`/control-char (no alert). A `--omnigent-reset-state` flag wipes persisted server state so each case starts with no known server. All pass on the iOS simulator.
- Manual simulator verification: drove `xcrun simctl openurl` against the running app with `OMNIGENT_DEEPLINK_TRACE` set; NSLog trace confirmed `ACCEPTED` for the valid link and `REJECTED` for all 5 smuggling/malformed links (smuggled `?`/`#`, `..`, control char, non-id).

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manually verified end-to-end on the iOS simulator: launched the app with `OMNIGENT_DEEPLINK_TRACE=1` and sent six real `omnigent://` links via `xcrun simctl openurl`. The NSLog trace showed `ACCEPTED` for the valid link and `REJECTED` for all smuggling/malformed links, proving the fix through the real `DeepLink.parse` → `handleDeepLink` path. The DEBUG-only `--omnigent-open-url` / `--omnigent-reset-state` launch-argument seam and `OMNIGENT_DEEPLINK_TRACE` NSLog logging are compiled out of Release builds (gated by `#if DEBUG`), so there is no production behavior change from the test infrastructure.
2026-07-23 17:30:33 -07:00
Enes Yilmaz 66d253eacc fix(cli): normalize Azure Databricks custom-URL workspaces to their canonical host (#2870)
* fix(cli): normalize Azure Databricks custom-URL workspaces to their canonical host

omni login (and omni host) failed for Azure Databricks workspaces with a custom
(vanity) URL like https://mydomain.azuredatabricks.net/?o=<workspace_id>: the
vanity edge 303-redirects the unauthenticated probe to /login instead of
answering, so _databricks_workspace_login_target does not recognize the
Databricks posture and login fails. The canonical host
adb-{workspace_id}.{workspace_id % 20}.azuredatabricks.net does answer, and the
?o=<workspace_id> selector already carries the id.

Rewrite the custom host to the canonical adb- form in _resolve_server_url (the
shared normalization every --server entry point uses, so omni host is covered
too). Only *.azuredatabricks.net hosts that are not already the adb- form and
carry a numeric ?o= are touched; AWS/GCP hosts, canonical URLs, and URLs without
a selector are left unchanged.

Closes #2781

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>

* fix(cli): probe before adopting the canonical Azure Databricks host

The custom-URL fix landed the canonical adb- host rewrite unconditionally in
_resolve_server_url, so a wrong synthesis could strand the user on a host they
never typed, and the unit tests only re-asserted the implementation's own
arithmetic (123 % 20 == 3), which would pass under any modulus.

Try the URL as the user gave it first. Only when that fails to resolve, and only
for an Azure vanity workspace URL carrying a numeric ?o=, synthesize the
canonical host, probe it, and adopt it if it answers. A dead synthesis now falls
back to the user's URL instead of replacing it.

The shard rule remains an observed regularity rather than a documented contract
(Microsoft calls the segment a random number and treats properties.workspaceUrl
from the ARM API as authoritative), so the probe keeps it off the load-bearing
path. Docstrings say so plainly.

Also:
- _canonicalize_azure_databricks_url is now _canonical_azure_databricks_url and
  returns None to decline, so a caller can tell "not applicable" from "no change".
- Guard the selector with isascii() as well as isdecimal(): str.isdecimal()
  accepts non-ASCII digits that int() also parses, which synthesized a
  nonsensical host.
- _probe_root reduces a URL the way _workspace_api_server_url does before
  probing. Without it the comparison against the expansion's result never
  matched (it drops the ?o= selector first, and that selector is what makes a
  URL a candidate), and the new probe requested /?o=123/v1/me.
- Replace the tautological shard assertions with five real observed
  workspace/host pairs, and drive the resolver tests through the real expansion
  with only httpx scripted, since a stubbed expander cannot catch the above.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>

* docs(cli): drop issue-number refs from Azure canonical-host comments

The repo's comment convention says code comments should describe the
scenario, not reference issue/PR numbers. Remove the (#2781) tags from
the _canonical_azure_databricks_url / _resolve_server_url docstrings and
the vanity-URL fallback test; the surrounding prose already explains the
Azure vanity-host case without needing the external link.

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 00:30:22 +00:00
Sunny Yang 78b37f20de fix(runner): resolve and re-materialize file attachments on remote-runner history reload (#2085)
* fix(runner): resolve and re-materialize file attachments on remote-runner history reload

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* fix(runner): seed the native-session compaction anchor; tolerate malformed file metadata

Native-harness sessions skip the history reload entirely, which also
skipped seeding the last server item ID that harness compaction
persistence anchors on — compactions then silently stopped persisting.
Session create now fetches just the newest item ID (newest-first, single
item, no attachment downloads) for native harnesses.

A 200 metadata response with an unparseable body no longer aborts
attachment resolution: both resolvers (the runner's message-content
resolver and the claude-native transcript rebuild) fall back to the
content response's Content-Type for the media-type hint.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(attachments): centralize file_id resolution and reference-line emission in native_attachments

The transcript rebuild and the runner each carried a full copy of the
file_id fetch-and-inline pipeline, and nine native executors repeated
the same materialize-or-marker block. Both now live in
native_attachments: resolve_file_id_block() serves the runner and the
transcript rebuild, attachment_reference_line() serves the executors,
and ATTACHMENT_MARKER_STRIP_PATTERN replaces four hand-copied forwarder
regexes. Materialized filenames are sanitized the same way as marker
names so a bracketed filename cannot break the marker consumers, and
the resume dedupe short-circuits on file size before reading bytes.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* fix(attachments): replay resolved history attachments as structured content

Cold-started claude-sdk sessions flattened prior turns into a text
prefix, so a resolved historical image reached the model as the marker
[image: name, media_type, N base64 chars]. The bytes never arrived, which
leaves the #882 symptom in place for that harness: the model describes an
attachment it cannot see.

Prior-turn attachments now replay as real Anthropic image/document blocks
via the existing converter, interleaved in transcript order. Text-only
history still takes the plain-string path and renders byte-identically,
unresolved attachments keep their existing marker, and base64 still never
enters prompt text.

Materialization also derives its collision suffix from a content hash
rather than a random one, so a history carrying two distinct uploads of
the same filename keeps one file per payload instead of gaining a copy on
every transcript rebuild.

The two tests that asserted the compact-placeholder shape are replaced by
cold-reload tests: that shape is the behavior being corrected, but the
invariant those tests protected (no base64 in prompt text) is asserted
against the prompt's text blocks.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(attachments): collapse duplicated prompt-shape branches

The structured and plain-text arms of _build_prompt returned the same
value whenever the latest message was multimodal, and re-scanned the
block list to decide which arm to take. Coalescing already leaves an
all-text history as one block, so the block count answers that.

Materialization's second identity check was a no-op guarding a write
that produces the same bytes, so the collision path flattens to one
branch.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(tests): keep runner conftest identical to upstream

Move the file-server fake's items/failure/malformed-meta behaviors out of
the shared _FakeFileServerClient into local subclasses in the one file
that uses them, so conftest.py stays in sync with upstream and per-test
modes stay next to their tests.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

---------

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 07:23:56 +07:00
Zeyi (Rice) Fan 7738df6fb3 fix(electron): guarantee the desktop quits after before-quit cleanup (#2972)
CI / gate (push) Failing after 1s
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
The before-quit handler defers the quit until serverManager.shutdown()
finishes, then re-issues app.quit() as the *only* way the quit ever
proceeds. Re-issuing app.quit() after before-quit's preventDefault() is a
known intermittently-unreliable Electron behavior (electron/electron#4994,
#33643, #39094); when it no-ops, or shutdown hangs (a stuck
'omnigent server stop'), the app stays up with its window still open —
matching 'sometimes the app is still running and refuses to quit'.

- Hard safety cap: app.exit(0) after quitCleanupTimeoutMs (unref'd) if
  graceful cleanup + the re-issued quit haven't terminated. Normal cleanup
  (<6s) completes well under the 10s cap; it only trips when stuck.
- Evaluate resolvedCliPath() inside an async IIFE so a future throw becomes
  a rejection caught by .catch, never stranding the quit.
- Install fallback: when quitAndInstallIfPending() returns true but
  quitAndInstall() doesn't actually quit (staged update gone), a short
  app.exit(0) fallback still quits.
- unref() the periodic update-check setInterval so it can't keep the event
  loop alive at quit.

Adds two regression tests (install-fallback and cleanup-cap) via an
injectable setQuitTimeouts; harness exposes setTimeout/clearTimeout/app.exit.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-23 16:47:59 -07:00
Rahul Ravindranathan cc94a9c5e6 feat(scheduled tasks): list page + sidebar nav (#3112)
* Add scheduled tasks page

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

* Remove scheduled page phase labels from comments

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

* Remove scheduled Omnigent stub wiring

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

* Shorten scheduled nav comment

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

* Remove scheduled tab styling comment

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

* Clean up scheduled task suggestions

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

* test(e2e-ui): update visual baselines

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 16:35:37 -07:00
Dhruv Gupta 131db6276b fix(codex-native): launch on the spec's declared model, not the provider default (#3175)
The codex-native launch read the spec model only from
executor.config["model"], a key the single-file agent loader never
populates, so a custom agent's declared model: was silently replaced by
the provider default. Read the canonical executor.model first — the same
field the in-process harness and the claude/cursor native launches
consume — and keep config["model"] as a fallback for bundle specs that
pin the model inside the harness config block.

Co-authored-by: Isaac
2026-07-23 23:16:50 +00:00
Dhruv Gupta af3d18ba16 fix(loader): reject the bundle type:/config: nesting in single-file executor blocks (#3178)
* fix(loader): reject the bundle type:/config: nesting in single-file executor blocks

A single-file agent YAML written with the bundle config.yaml shape
(executor: {type: omnigent, config: {harness: ...}}) loaded without
complaint: the unknown keys were silently dropped, the declared harness
with them, and a different harness was inferred from the model prefix —
databricks-gpt-* landing on openai-agents instead of the declared
codex-native, with no diagnostics. Reject exactly type:/config: with an
error that shows the flat spelling. Other extra executor keys
(use_responses, extra, ...) keep loading — the compat loader reads them
from the raw YAML.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* test: spell e2e fixture executors flat instead of the bundle config: nesting

Six runtime-generated single-file agent YAMLs in the e2e/e2e_ui/server
fixtures nested the harness under executor.config — the exact trap the
loader now rejects. They only worked because the dropped harness was
re-inferred from the gpt-* model prefix as the same openai-agents value.
Spell them flat so the declared harness actually flows. The two
spec_version bundle specs (approval agent, elicitation supervisor) keep
the nesting — config.harness is the correct spelling on the strict
parser path.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-23 16:14:11 -07:00
Sabhya Chhabria 53c0125e84 [polly] Add Claude SDK goal mode (#3084)
*  feat(polly): Add Claude SDK goal mode

- Reuse the composer Goal control for top-level Polly sessions on claude-sdk
- Send the completion condition as a native /goal command without server APIs
- Cover command dispatch, validation, read-only state, and harness gating

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

*  test(e2e-ui): Cover Polly Claude goal flow

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>
2026-07-23 16:01:43 -07:00
Kunyu Chen c8828ed62d Enhance device auth to require user login (#3156)
* Update device auth scheme to require a recent login to reduce phishing attack risks
* Device grant ui tests
2026-07-23 14:43:43 -07:00
Rahul Ravindranathan 0085334e94 feat(scheduled tasks): create-task dialog (#3123)
* feat(scheduled tasks): manual create dialog (2/3)

Stack 2 of 3 for the Scheduled Tasks page (UI-1). Builds on the data
layer (1/3). The dialog isn't mounted anywhere yet, so it type-checks
standalone.

- CreateScheduledTaskDialog.tsx: manual create form wired to POST
  /v1/scheduled-tasks. Reuses the shared AgentHarnessPicker (exported from
  NewChatDialog) with "needs setup" badges via a fallback online host;
  seed-on-open prefill (cleared on close, no stale leak); backdrop-click
  dismiss with the guard scoped to the nested-Select case only.
- ScheduleFields.tsx: frequency/time/weekday schedule builder.
- Label.tsx: small shared form label.
- CreateWithOmnigentDialog.tsx: TODO(UI-2) stub.
- NewChatDialog.tsx: export AgentHarnessPicker + add optional
  onOpenChange / content+trigger class / contentAlign props (backward
  compatible for the interactive composer).

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

* Fix scheduled task dialog defaults and picker

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

* Shorten scheduled task hourly comment

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

* Remove scheduled task phase labels from comments

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

* Remove scheduled dialog follow-up label comment

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

* Remove deferred scheduled Omnigent stub

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 14:38:40 -07:00
Pranav Setlur 34656aa806 fix(host): forward global config to the background local server (#2935)
The background server spawned by bare `omni` (`_spawn_local_server`)
launched `omnigent.cli server` without `--config`, so the server's
loader returned an empty config and never read `~/.omnigent/config.yaml`.
Its `llm:` (and `policies:`) block was invisible to the detached server,
so self-hosted smart routing silently stayed off (`sys_advise_models` ->
`router_on: false`; `/v1/info` -> `smart_routing_enabled: false`).

Forward `--config <global_config_path()>` when the file exists. Same bug
class as #2386/#2763 (Docker entrypoint dropped `policies:`); this is the
local-spawn instance.

Co-authored-by: Isaac

Signed-off-by: Pranav Setlur <psetlur@gmail.com>
2026-07-23 14:07:44 -07:00
Kunyu Chen 8285b58940 refactor(cli): replace omni integration slack start with omni integration slack --background (#3153) 2026-07-23 20:14:25 +00:00
Zeyi (Rice) Fan db11081516 fix(runner): patch heartbeat cadence on the app module (#3163)
PR #3148 extracted _session_labels_for_runner_spawn into
omnigent.runner.native.orchestration, but _SESSION_STREAM_HEARTBEAT_S
and the stream loop that reads it remained in omnigent.runner.app.

test_session_stream_emits_heartbeat_on_idle located the module to patch
via _session_labels_for_runner_spawn.__module__, which now resolves to
omnigent.runner.native.orchestration — a module that has no
_SESSION_STREAM_HEARTBEAT_S attribute — so the test raised
AttributeError and failed CI on main.

Patch omnigent.runner.app directly, which is where the heartbeat cadence
constant and its consumer actually live.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-23 13:02:46 -07:00
Harry Yao d18f7b95f5 claude-native: respect CLAUDE_CODE_USE_GATEWAY=1 for tool search (#3161)
When the launching process sets CLAUDE_CODE_USE_GATEWAY=1, that
gateway-aware mode keeps tool search enabled so MCP schemas load on
demand. Setting CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS alongside it
would override that mode, disabling all betas and inflating startup
token usage.

Only set CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS when gateway-aware
mode was NOT selected.

Ported from databricks-eng/universe#2298829.

Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-07-23 12:36:02 -07:00
Aravind Segu 56d1db68af Add overridable item-data serialization seams to SqlAlchemyConversationStore (#3126)
Introduce no-op extension points on the conversation store so a subclass
can transform conversation_items.data and control search_text, without
changing OSS behavior:

- _encode_item_data(data_json): identity by default; append's data write is
  routed through it so a subclass may compress or encrypt the payload.
- _decode_item_data_batch(stored_list): identity by default; the read paths
  (list_items, list_latest_message_items_for_conversations, the FTS-ranked
  read) decode a whole page of rows through it before building entities, and
  _to_item now takes the already-decoded data. Making the read seam a batch
  (not a per-row hook) lets a subclass decode a page in one pass — e.g. a
  single bulk decrypt — instead of once per row.
- _item_search_text(item): extracts the search text as before by default;
  may return None to skip persisting search_text (and its FTS row) on a
  schema that omits the column.

Every default preserves current behavior exactly: the column stays plaintext
Text, and search/FTS are unchanged. This lets a downstream store (Databricks'
MySQL-homed conversation store) envelope-encrypt item payloads at the column
boundary while reusing append/list_items unchanged.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-23 10:00:25 -07:00
Pat Sukprasert afe6b3ba11 [tests] Split native app session tests by concern (#3149)
* test: split native app session tests

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

* test: address native session split review feedback

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

* test: address follow-up lint feedback

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

* test: clarify native session test scopes

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

* test: update native session helper imports

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 16:51:11 +00:00
Pat Sukprasert a979ec97d7 [runner] Extract native terminal orchestration (#3148)
* refactor(runner): extract native terminal orchestration

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

* fix(runner): limit native compatibility syncing

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 16:19:00 +00:00
Sai Asish Y 750c395a50 docs(deploy): correct docker admin bootstrap flow (no auto-generated password) (#2840)
* docs(deploy): correct docker admin bootstrap flow (no auto-generated password)

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* docs(deploy): correct remaining generated-password and /data-persistence claims

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* docs(deploy): scrub generated-password flow from remaining platform guides

The Docker docs were corrected earlier, but fly / railway / render / modal /
hf-spaces still told operators to read a generated admin password out of the
logs / /data/admin-credentials — a flow that no longer exists (bootstrap never
auto-generates a password; the first admin is claimed via the web Create-admin
form or a pre-seeded OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD).

- Rewrite the first-admin step in each guide to the real flow, and drop the
  fake "Created initial admin ... password: <generated>" log block.
- Add a first-visitor security note (unauthenticated /auth/setup while no
  password-bearing account exists) to every public-facing guide; fold it into
  hf-spaces' "make the Space Public" step where the exposure is most direct.
- render: correct the disk bullet (hashes live in Postgres, not on /data) and
  the render.yaml comment that called the anchor path a password file.

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

---------

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac <isaac@example.com>
2026-07-23 16:11:36 +00:00
Tomu Hirata 538494ff73 feat(cli): add omnigent session import (inverse of session export) (#3141)
CI / gate (push) Failing after 2s
Lint / gate (push) Failing after 1s
Lint / Pre-commit checks (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
* feat(cli): add `omnigent session import` (inverse of session export)

`session export` writes a portable JSONL but there was no way to load it
back — inspecting a shared/exported session meant hand-writing items into
the store. Add `session import` to close the round-trip: it reads the
session_meta + item lines and recreates the conversation on the target
server as a new session (fresh id each time) via POST /v1/sessions with
the history passed as initial_items.

Details:
- De-aliases the `model` serialization alias back to `agent` per item and
  validates each with parse_item_data() client-side before the request.
- Agent binding: reuse the exported agent_id when it exists on the target
  server; else fall back to the built-in native agent for the export's
  harness (mirrors /v1/imports); else fail with a clear message.
- Creates history-only (host_type=external, no host_id) so no runner
  launches. Carries over title/workspace/harness/model/effort overrides.

Known limitation (documented in --help): the server seeds initial_items
under a single synthetic response_id, so exact per-turn grouping is not
preserved. Fine for viewing/debugging; a follow-up server route could
preserve it if needed.

Verified end-to-end: imported the real 260-item export, re-exported, and
diffed — identical item counts and types, agent bound, model<->agent
alias round-trips.

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

* fix(cli): scope agent→model de-alias to alias-bearing item types

Polly review caught that the import de-alias applied `model`→`agent` to
every item type, corrupting the two types where `model` is a genuine
field: `compaction.model` (silently dropped) and `routing_decision.model`
(required + collides with its own `agent` field → hard import failure for
any smart-routed session).

Derive the alias-bearing types from the data-model field definitions
(serialization_alias == "model") so the reverse map only fires for
message/function_call/reasoning/slash_command and can't drift. Add
regression tests for compaction and routing_decision.

Also address non-blocking review notes:
- Wrap non-404 create errors in a clean ClickException instead of a raw
  httpx traceback.
- Document created_by re-attribution in --help alongside the response_id
  caveat.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 14:25:36 +00:00
Anas Khan 2561d54ee5 fix(hermes): mirror native reasoning to the web conversation (#1645)
The hermes-native forwarder's messages SELECT omitted the reasoning
columns Hermes persists, so thinking shown in the TUI never reached the
web conversation. Read reasoning_content/reasoning and emit a one-shot
external_output_reasoning_delta before the assistant message (started=True),
matching the codex- and opencode-native transient reasoning contract. The
structured codex_reasoning_items column is left alone.

Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-07-23 13:29:56 +00:00
Enes Yilmaz 414b404ee5 fix(codex): fall back to the runner workspace when no explicit cwd is set (#3015)
The codex harness wrap read only HARNESS_CODEX_CWD, so when the spawn env
omits that var the executor fell through to os.getcwd(). Seven sibling
harnesses (acp, claude-sdk, goose, hermes, kimi, pi, qwen) already fall
back to OMNIGENT_RUNNER_WORKSPACE first.

tests/runtime/test_spawn_env_cwd.py::test_builder_omits_cwd_when_none
documents that the builder omits the CWD var precisely so the harness can
apply its own OMNIGENT_RUNNER_WORKSPACE fallback. codex is in that test's
builder list but never held up the harness half of the contract.

Every current caller threads a cwd, so this changes no observed behavior
today. It closes the contract gap and covers a caller that omits it.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
2026-07-23 22:25:30 +09:00
Jakub Majorek c3facacd57 feat(cli): add omni usage cost report (#2787)
*  feat(cli): add `omni usage` cost report

Summarize LLM spend across a user's sessions: rolling 24h / 7d / 30d
cost totals plus a per-session breakdown of model and cost.

- server: `GET /v1/usage` aggregates each top-level session's subtree
  usage (via `load_session_usage`), scoped to the caller, bucketing
  cost by last-activity time; normalizes the primary model per session.
- cli: `omni usage` (`--limit`, `--server`, `--json`) renders the
  report through the shared `omnigent.inner.ui` palette.

Co-authored-by: Isaac
Signed-off-by: Jakub Majorek <majorek.jakub@gmail.com>

*  feat(usage): address review — separate router, per-model breakdown, daily-rollup windows

Addresses the four review comments on the `omni usage` cost report:

1. Move the report to its own user-scoped router (omnigent/server/routes/
   usage.py) instead of the session-scoped sessions router.
2. Rename the schema UsageSession -> SessionUsage.
3. Show a per-model cost breakdown per session, mirroring the web session
   sidebar: authoritative session total on the id line, each model's
   recorded cost beneath (shown faithfully, not forced to sum). Single-model
   sessions stay on one line.
4. Source the cost summary (Today / Last 7 days / Last 30 days / All time)
   from the per-user daily-cost rollup (user_daily_cost) via a new
   sum_daily_cost range read, so windows reflect when spend occurred rather
   than a session's last-activity time. Labels relabeled to calendar-day
   truthful wording.

Regenerates openapi.json; updates unit + e2e tests.

Co-authored-by: Isaac
Signed-off-by: Jakub Majorek <majorek.jakub@gmail.com>

---------

Signed-off-by: Jakub Majorek <majorek.jakub@gmail.com>
2026-07-23 21:58:47 +09:00
Serena Ruan e4c895c7e6 test(ui-snapshot): add sidebar pinned-project flyout baseline (#3140)
* test(ui-snapshot): add sidebar pinned-project flyout baseline

The populated-sidebar baseline covers every sidebar row type but not the
hover flyout that surfaces a pinned session's originating project — the
card is portalled and only mounts on hover, so a restyle of it (recently
aligned to a compact HoverCard: clamped title + folder icon + project
name) sails through that gate.

Add a visual test that hovers a pinned, project-owned row and captures
`PinnedProjectFlyoutContent`. Mirrors the populated-sidebar fixture's
determinism (pinned clock, silenced updates socket, seeded localStorage);
the flyout's 150ms openDelay fires under set_fixed_time since only Date.now
is pinned, so a plain hover opens it.

Baseline PNG intentionally omitted — generated in CI's pinned image via the
`update-ui-snapshot` label so it matches the gate byte-for-byte.

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

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-23 19:47:30 +08:00
Bryan Chua a3d6be1221 fix(codex): normalize deprecated ultra/max reasoning effort to xhigh (#2697)
The ChatGPT desktop app writes model_reasoning_effort = "ultra" into
~/.codex/config.toml; the codex CLI forwards it as the retired "max"
wire value, which the OpenAI Responses API rejects with
invalid_value: 'max' (its ladder tops out at xhigh). Because the codex
harness copies the user config verbatim into every per-session
CODEX_HOME, every codex turn fails on such machines — including debby's
gpt sub-agents.

Two-part fix:
- validate_effort() coerces a deprecated alias (ultra/max -> xhigh) when
  the raw value is unsupported but the canonical one is. Providers that
  genuinely support max (Anthropic) are unaffected. This also stops the
  server rejecting external_reasoning_effort_change events from
  ChatGPT-app-configured codex terminals that report effort ultra.
- _populate_codex_home_config() normalizes a deprecated top-level
  model_reasoning_effort in the session's private config.toml copy;
  keys inside tables and supported values are left untouched, and the
  user's real ~/.codex/config.toml is never modified.

_normalize_copied_codex_effort() now tracks array bracket depth so a
top-level multiline array's continuation lines (which can themselves
start with "[") are never mistaken for a table header — otherwise a
still-top-level model_reasoning_effort key after such an array would be
skipped. Also updates the two reasoning-effort-validation tests that
asserted "max" was rejected outright: since max/ultra now coerce to
xhigh for codex and the OpenAI Agents SDK, those tests now assert the
coercion instead.

Fixes #2696

Signed-off-by: Bryan Chua <me@bryanchua.com>
2026-07-23 11:34:31 +00:00
Tomu Hirata 83f17cc646 fix(runtime): strip base64 image data from stored history on replay (#3133)
* fix(runtime): strip base64 image data from stored history on replay

The native-ingest strip only helps images read *after* that fix landed.
Sessions already in the conversation store still hold full base64 images
in their function_call_output items, so they keep overflowing the context
window on resume — replaying the stored output as prompt text wedges
compaction (loads over-window history to summarize, fails "prompt is too
long", writes no boundary, re-overflows).

Strip inline base64 image blocks at the replay boundary in
history_to_input_items, where every harness's stored history is converted
to LLM input. This fixes already-stored large-image sessions without a
store migration. A base64 image tool result (JSON list of
{"type":"image","source":{"type":"base64",...}} blocks) is rewritten to a
"[<media> image omitted from history …]" placeholder that points back at
the originating tool call so the image stays recoverable on demand.
Plain-text and non-image JSON outputs (the common case) pass through
unchanged via a cheap guard before any JSON parse.

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

* fix(runtime): strip base64 from truncated (invalid-JSON) image outputs

Testing against the real wedged session's export revealed the JSON-only
strip was a no-op on exactly the data that matters: stored image outputs
are clipped at the conversation-store 245760B cap, leaving the base64
string unterminated, so json.loads raises and the original (base64-laden)
output was returned unchanged.

Add a linear regex fallback that rewrites an image source block in place
when the output is not parseable JSON. The pattern uses fixed optional
key groups and a base64-alphabet char class disjoint from the quote
terminator, so it cannot backtrack catastrophically against a
multi-hundred-KB payload (an earlier lazy-quantifier attempt hung).

Verified on the real 3440987444542977 export: all 4 truncated image
items strip, 982,448 -> 832 chars (99.92%), sub-ms. New test covers the
truncated/invalid-JSON shape.

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

* fix(claude-native): strip truncated base64 images on cold resume

Native Claude Code resumes from its own local transcript, which the
wrapper rebuilds from Omnigent items before `claude --resume`. Intact
image tool results are intentionally rehydrated into real image blocks
(cheap ~1.5K tokens). But an output clipped at the conversation-store
byte cap holds corrupt/partial base64 that no longer parses: rehydration
fails, so the raw ~250K-char string was sent as tool_result text AND
stashed in toolUseResult — re-overflowing the resumed context and
wedging compaction (the exact native failure users hit).

Collapse only that truncated/unparseable-image case to a recoverable
placeholder before building the record, so both the tool_result content
and the toolUseResult metadata stay small. Intact images still resume as
images.

Verified on the real 3440987444542977 export: full transcript rebuild
drops from 1,549,700 to 563,994 chars with zero base64 leak, while a
valid image still rehydrates to an image block.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 20:02:18 +09:00
Tomu Hirata 4e509ea248 feat(llms): merge extra_headers and log upstream 4xx bodies (#3138)
Merge caller-supplied headers threaded through connection_params so MAS
can route CP serving-endpoint calls through the Barnacle forward proxy
(host + s2s auth headers). Also log the upstream error body on 4xx/5xx
for both non-streaming and streaming requests, which raise_for_status()
otherwise omits — essential for debugging CP serving/gateway failures.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 10:58:40 +00:00
Serena Ruan b2f38ea334 docs(web): drop stale migration comments from the composer config code (#3137)
The in-session config gear PR left comments that narrated the change
(a now-deleted IntelligentModelControl reference, "moved OUT of the picker
trigger", "no longer a standalone toggle", "old/pre-gear picker") and named
a "picker trigger"/"Agent picker" that no longer exists. Rewrite them to
describe current behavior — where the Smart Routing toggle, harness label,
and model/effort label live — per the repo's "describe the scenario, not
the change history" guidance.

Comment-only; no behavior change.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-23 18:55:23 +08:00
Tomu Hirata 2fdb72bdb2 fix(auto-harness): post-merge fixes for auto harness routing (#3093)
* feat(databricks-adapter): use SDK Config for OAuth token refresh

Cache a databricks.sdk.config.Config per profile and call authenticate()
on every request so OAuth tokens are refreshed transparently instead of
expiring after ~1 hour. Falls back to resolve_databricks_workspace when
the SDK is unavailable.

This addresses the v1 limitation documented in credentials/databricks.py.

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

* fix(auto-harness): hide per-turn Smart Routing toggle when Auto harness is selected

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

* fix(new-chat): hide Smart Routing checkbox in favour of Auto harness

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

* feat(auto-harness): propagate routing error to UI via routing card

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

* feat(smart-routing): route harness+model for child sessions via sys_session_send

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

* feat(smart-routing): force auto-harness for sub-agents when parent routing is on

When the parent session has smart routing enabled, a sub-agent created via
sys_session_send is now routed regardless of the harness/model the
orchestrator chose — the server forces the "auto" sentinel at child-session
create time, ignoring the tool call's agent/model args. The first-message
routing path then picks both harness and model.

Skips native-terminal wrapper labeling for forced-auto children so the
harness isn't prematurely fixed (routing may pick a non-native SDK harness);
the child takes the SDK routing path where auto-resolution runs.

Only applies to omnigent-executor agents (auto needs a swappable brain
harness); non-omnigent children keep the orchestrator's choice.

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

* fix(auto-harness): persist cost_control=on for Auto sessions, hide composer routing toggle

- New-chat create body sends cost_control_mode_override="on" when harness=auto
  so the persisted state matches the routing that always runs for auto sessions.
- Hide the per-turn composer routing icon entirely — it's superseded by the
  Auto harness (routes at session start), and its "off" state was misleading.

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

* fix(auto-harness): exclude databricks-claude-haiku-4-5 from pi routing candidates

pi routes Claude models through the Anthropic Messages gateway, whose request
path adds an eager_input_streaming field the Databricks serving endpoint
rejects with a 400 when tools are present. Filter the model out of pi's
candidate list in route_session_harness (both live-catalog and static paths)
so Claude work routes to claude-sdk instead. Keeps pi's GPT models.

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

* fix(auto-harness): prevent double-routing on forced-auto child sessions

The auto-harness resolution block and the per-turn routing block both called
route_session_harness on a forced-auto child's first message (parent routing
on + harness_override="auto"), causing two judge calls, two routing cards, and
a possible harness/model mismatch between the two picks. Track whether the auto
block routed this turn and skip the per-turn block when it did. Also fixes the
failure-path card duplication (auto emits an applied=False card, then no longer
falls through to a second card).

Cleanup: except (ImportError, Exception) -> except Exception in the databricks
adapter (Exception already subsumes ImportError).

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

* feat(auto-harness): mirror routing card into parent session for sub-agents

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

* fix(auto-harness): map live-catalog worker names to harness ids for routing

The live runner catalog (fetch_runner_models) keys rows by worker name —
sub-agent names like "claude_code" plus "self" — not by harness id. So
route_session_harness found no matches for _AUTO_ROUTING_HARNESSES and
returned "No routable harnesses are available", especially for child
(sub-agent) sessions.

Normalize worker names to harness ids via _WORKER_NAME_TO_HARNESS
(claude_code -> claude-sdk, codex, pi), and fall back to the static
infer_models table when the live catalog yields no routable candidates
(e.g. a catalog with only an unrecognized "self" worker).

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

* fix(ci): remove dead _ROUTABLE_HARNESSES and effectiveHarness (noUnusedLocals)

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

* test: update child-session routing test for forced-auto (route_session_harness)

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

* test: remove dead Smart Routing dialog tests (superseded by Auto harness)

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

* fix(auto-harness): exclude gpt-5.5/5.6 reasoning models from pi routing

pi routes GPT models through the openai-completions (/chat/completions) path.
Databricks applies a default reasoning_effort for the gpt-5.5/5.6 reasoning
models there and rejects tool calls with "Function tools with reasoning_effort
are not supported for gpt-5.5 ... use /v1/responses or set reasoning_effort to
'none'." pi's provider can't send that override, so every tool turn 400s.

Exclude databricks-gpt-5-5, -5-5-pro, and the -5-6 family from pi's routing
candidates (same pattern as pi+claude-haiku). The gpt-5.4 family works on pi
and stays; codex serves gpt-5.5+ via the Responses API natively.

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

* fix(auto-harness): redirect incompatible router verdicts off pi

Some external routers ignore the filtered candidate set we send and still
return an excluded (harness, model) pair — e.g. pi + gpt-5-5. Since we can't
stop the router choosing it, post-process the verdict: redirect a Claude model
on pi to claude-sdk and a gpt-5.5/5.6 reasoning model on pi to codex (which
serves them via the Responses API). The chosen model is preserved; only the
harness is corrected to one that can actually run it with tools.

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

* style: ruff format test_sessions_model_override

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

* fix(auto-harness): order codex before pi so GPT models default to codex

_AUTO_ROUTING_HARNESSES order is both the candidate-set insertion order and
the tiebreak when a model is served by multiple harnesses (the external
router's id-only fallback and our own model-ownership fallback both pick the
first harness owning the model). With pi before codex, a GPT model with no/
ambiguous harness resolved to pi — whose openai-completions path 400s on
gpt-5.5+ reasoning models with tools. Reorder to codex, pi so GPT defaults to
codex (Responses API, handles reasoning+tools).

Complements _redirect_incompatible_pick, which handles the separate case of a
router returning an explicit pi+gpt-5.5 pair despite our filtered candidates.

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

* fix(auto-harness): stop filtering candidates; router requires full model set

The external task_v0 router enforces a required model set (e.g. must include
gpt-5-6-luna) and returns 400 "task_v0 requires [...] models" when any is
missing. Our _filter_excluded_models pruning stripped gpt-5.5/5.6 and Claude
models from pi's candidates, making the required set incomplete and 400-ing
every route call.

Send the full candidate set unfiltered and rely solely on
_redirect_incompatible_pick to correct an incompatible (harness, model)
verdict after the router responds. Removes the now-unused _filter_excluded_models.

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

* fix(auto-harness): emit routing card after input.consumed so it renders

The auto-harness routing card (success and failure) was published to the live
SSE stream at resolution time — before the runner forward and before
input.consumed. The user-message bubble hadn't been delivered yet, so the
reducer dropped/misordered the card and it never appeared live (only on
reload). Defer the card emission to after input.consumed, matching the
per-turn routing path's ordering. Now the "router unavailable" failure card
shows in the UI.

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

* fix(auto-harness): refresh external router OAuth token per call

ExternalRoutingClient captured its bearer once at server startup (from the
routing profile), so after ~1h the token expired and the router 401'd
("Credential was not sent or was of an unsupported type"), which surfaced as
"router returned no verdict". Pass the Databricks profile through and mint a
fresh bearer per route() call via the SDK Config (same OAuth-refresh pattern
as the DatabricksAdapter fix). An explicit api_key still uses a static bearer.

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

* feat(auto-harness): surface the router's actual error in the failure card

The auto-harness failure card showed a generic "router returned no verdict".
ExternalRoutingClient swallowed the real reason (401, task_v0 required-model-set,
etc.) — only logging it. Record it on client.last_error and have
route_session_harness surface it, so the UI card reads e.g. "Routing
unavailable: router returned HTTP 401: Credential was not sent or was of an
unsupported type". _router_error_detail unwraps the gateway's nested JSON
error envelope to a clean message.

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

* fix(auto-harness): route sub-agents against the parent's catalog

A sub-agent's own runner catalog is "self"-only (it's a leaf spec with no
sub-agents), so _WORKER_NAME_TO_HARNESS didn't recognize it and routing fell
back to the small static infer_models lists — a different, incomplete candidate
set than the top agent sees (which broke the external router's required-model
check, e.g. missing glm-5-2/gpt-5-6-luna).

Add catalog_session_id to route_session_harness and pass the parent session id
for sub-agent routing (parent + child share a runner). The parent's catalog
enumerates the full spawnable-worker map (claude_code/codex/pi with complete
model lists), so a sub-agent now routes against the same stable candidate set
as the orchestrator — regardless that we route both harness and model for it.

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

* test(routing): assert external client defers profile auth to per-call

_build_external_routing_client no longer resolves a Databricks profile
token at build time — the client mints a fresh bearer per request (OAuth
refresh) so it survives ~1h token expiry. Update the test to assert the
profile is threaded through (no eager resolve, no static _auth) instead
of the old build-time resolution contract.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 10:46:20 +00:00
Serena Ruan d641eb79f9 fix(web): align sidebar session flyout and row padding (#3124)
* fix(web): align sidebar session flyout and row padding

The session hover flyout and the sidebar rows were visually inconsistent
with the pinned-project flyout and project folder rows:

- The plain session tooltip used a wide card (w-72, bg-card-solid) while
  the pinned-project flyout used a compact HoverCard look. Restyle the
  tooltip to mirror it (w-64, bg-popover, clamped title, muted metadata).
- Both flyout titles used rem-based `text-sm`, which scaled with the UI
  font-size setting and rendered larger than the fixed-px sidebar rows.
  Size both to `sidebar-compact-text` so they match the row name exactly.
- Session rows used `w-[calc(100%+1rem)]`, bleeding ~8px past the right
  edge so their highlight didn't align with the project/folder rows.
  Switch to `w-full` and shift the trailing pin/kebab controls inward
  (right-[30px] / right-1) so they stay inside the row edge.

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

* fix(web): drop reserved scrollbar gutter so sidebar rows sit flush right

The sidebar scroll container reserved a stable scrollbar gutter
(`scrollbar-gutter: stable`), which on overlay-scrollbar platforms
(macOS) leaves ~15px of empty space on the right of every row. That made
rows look uncentered — 8px inset on the left vs. 8px + 15px on the right —
and misaligned the project-folder header actions with the session-row
controls. It's also why session rows previously used `w-[calc(100%+1rem)]`
to paint over the gutter (the workaround this series already removed).

Drop the reserved gutter so the right inset collapses to the same 8px
`px-2` as the left. On overlay scrollbars there's no layout shift; the
rows and folder-header actions now line up on both edges.

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

* fix(web): match project-folder header controls to compact session kebab

The project-folder header pencil + kebab used `icon-sm` (size-7, 28px)
while the session-row kebab uses `icon-xs` (size-6, 24px). Both anchor at
`right-1` with a centered `size-3.5` glyph, so the 4px width difference
put their glyph centers in different columns — the folder ⋯ sat ~2px left
of the row ⋯ and read as misaligned.

Drop the folder-header controls to `icon-xs` so they share the compact
size (and glyph column) with the session-row kebab.

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

* fix(web): match folder-header icon spacing to session row

The folder-header pencil + kebab sat in a gapless flex, while the session
row's pin↔kebab pair has a 2px (right-1 vs right-[30px]) gap. That put the
folder pencil 2px right of the session pin, so the leading-icon columns
didn't line up across row types.

Add `gap-0.5` to the folder-actions flex so the pencil lands in the same
column as the session pin; the kebabs already share the trailing column.

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

* fix(web): shrink Projects group-header controls to compact icon

The "New project", "Expand all", and "Collapse to previous" controls in
the Projects group header were still `icon-sm` (size-7, 28px) while every
other right-gutter control — folder-row and session-row pin/kebab — is now
`icon-xs` (size-6, 24px). The larger buttons broke the shared icon column.

Drop all three to `icon-xs` so the whole sidebar right-gutter shares one
compact size.

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

* refactor(web): share one flex container for sidebar row trailing controls

The session row's pin + kebab were two separately absolute-positioned
buttons, so their spacing was hand-tuned per button and drifted from the
project-folder header actions at non-default font scales. Wrap both in a
single `absolute right-1 flex items-center gap-0.5` container — the same
pattern the folder header already uses — so the spacing is defined once
and stays aligned across every right-gutter control at any scale. Also add
the matching gap-0.5 to the Projects group-header controls.

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

* test(e2e-ui): regenerate visual baselines

* fix(web): reserve scrollbar gutter symmetrically instead of removing it

Removing `scrollbar-gutter: stable` fixed the right-edge asymmetry on
macOS overlay scrollbars but reintroduced horizontal reflow on classic-
scrollbar platforms (Windows/Linux) when the scrollbar appears/disappears.

Use `stable both-edges` instead: the gutter is reserved symmetrically on
both sides, so rows stay centered against the left `px-2` inset and never
reflow — a no-op on overlay scrollbars, correct on classic ones.

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

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-23 18:30:43 +08:00
Pat Sukprasert d681baf83b docs(harness): sync Phase 0 split status in modular-registry proposal (#3136)
The Phase 0 section listed pre-split line counts and framed the cli.py and
sessions.py extractions as to-do, but both have shipped. Update it to reflect
actual state: correct the counts, mark cli.py (#3047) and sessions.py (#3097)
done, and leave runner/app.py and test_app_sessions_native.py as the two
remaining >10k files (which can proceed in parallel). Move chat.py to a
deferred bucket since it is already under the 10k target.

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 17:12:33 +07:00
Daniel Lok 50e6bd85d0 test(runner-init): guard fork-history directives survive the reconnect envelope (#3125)
* test(runner-init): guard fork-history directives survive the reconnect envelope

Adds an integration test across the exact seam that regressed in #2793 and
was fixed in #3116: a forked claude-native session's fork directives
(carry-history, source-external-session) must survive from the store's
by-runner-id reconnect lookup into the session-init envelope the runner
reads to decide whether to clone/rebuild the vendor transcript.

Unlike the existing envelope tests (which hand-build an envelope with the
label already present) and the store unit test (which checks one method in
isolation), this drives the real store end to end — create a native source
with a captured external_session_id + workspace, fork it with
carry_history_into_native, bind it to a runner, then run
list_conversations_by_runner_id -> build_runner_session_init_payload ->
parse -> _claude_launch_metadata_from_envelope and assert the fork
directives land as launch metadata. It fails if any layer on that path
stops carrying labels (verified: reverting #3116's hydration makes it fail
with an empty label set).

Runs in CI (no vendor Claude login), unlike the opt-in
tests/e2e/test_host_claude_native_fork_e2e.py that would otherwise be the
only coverage of this path — which is why the original regression slipped
through.

Co-authored-by: Isaac

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: repair test docstring indentation broken by suggested edit

A GitHub-suggested "Potential fix for pull request finding" commit
(b48c50b3) rewrote the test docstring flush-left, leaving the function
with no indented body -> IndentationError, which failed ruff-format,
ruff-check, and pytest collection (server-rest).

Restore a properly-indented docstring and switch the em-dashes/arrows in
comments to ASCII so the file is unambiguously parseable everywhere. Test
behavior is unchanged: still passes with #3116's label hydration and fails
without it (verified by reverting the fix).

Co-authored-by: Isaac

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 10:07:11 +00:00
Pat Sukprasert 06f52ea0f7 refactor(server): split sessions route into facade + impl package (#3097)
* refactor(server): split sessions route into facade + impl package

The sessions route had grown to ~15k lines in a single file, well past
the 10k-line ceiling we want for maintainability and ahead of the
native-harness pluggability work that will touch this module heavily.

Split it into a facade over an implementation package:

- sessions.py (7.7k) stays the public entry point, keeps
  create_sessions_router, and re-exports the impl modules via `import *`.
- _sessions/common.py, helpers.py, orchestration.py hold the
  implementation, layered common -> helpers -> orchestration, each
  star-importing the ones below it.

No behavior change. Symbols that tests patch on the facade are exposed
through call-time proxies that delegate back to the facade, so a
`monkeypatch.setattr(sessions_mod, ...)` is honored no matter which impl
module resolves the name. F403/F405 are waived for these files in
pyproject since star re-export is the point of the facade.

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

* fix(sessions): honor facade monkeypatch across _sessions impl modules

The facade/_sessions split re-exports symbols via `import *`, so each impl
module holds its own binding of every name. A test's
`monkeypatch.setattr(sessions, "_kick_managed_wake", ...)` rebound only the
facade attribute; sibling impl callers kept their stale star-import binding and
ran the real path, breaking managed-wake and compact single-flight tests.

Route the patched symbols (`_kick_managed_wake`, `_compact_lock`) through
call-time facade proxies with the real body renamed `*_impl`, and add explicit
facade override imports so the patch is honored no matter which module resolves
the name.

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

* refactor(sessions): route impl-module get_agent_cache/session_stream through facade proxy

Drop the function-local `from omnigent.runtime import get_agent_cache`
and `from omnigent.runtime import session_stream` imports in the impl
modules. Those locals shadowed the module-level facade-delegating
proxies (bound via the `# noqa: F401` import block from
`_sessions.common`), so a `monkeypatch.setattr` on the facade was not
honored at those call sites.

Removing the shadowing imports lets the already-bound module-level
proxies resolve the names, keeping facade patches effective while
behaving identically when unpatched (the proxy forwards to the real
runtime symbol). Addresses Copilot review on the sessions split.

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

* fix(sessions): repair cross-module seams from the facade split

The _sessions split moved code behind an explicit __all__ per impl module
and a star-import facade, which introduced three latent seams:

- _validated_harness_override_executor_type was omitted from
  helpers.__all__, so the harness_override == "auto" gate in
  orchestration (which sees it only via star-import) hit NameError at
  session creation. Add it to __all__.

- _query_host_runner_status read _HOST_RUNNER_STATUS_TIMEOUT_S off its
  own star-import binding, so a facade-level monkeypatch was dropped.
  Read the constant off the facade module instead; strengthen the
  timeout test to assert the wait actually bails early.

- _wait_for_managed_runner_tunnel and _run_managed_wake read
  _HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S bare; qualify both through the
  facade for the same reason.

Add test_sessions_facade_exports.py to pin these re-export seams so a
dropped __all__ entry or un-re-exported constant fails at import time.

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

* fix(sessions): restore call-time get_agent_cache import in resolvers

The split dropped the call-time `from omnigent.runtime import
get_agent_cache` local import from the four harness/model resolver
functions. Without it the name resolved to the module-level facade
proxy, which forwards to a snapshot binding taken at import time, so a
test patching `omnigent.runtime.get_agent_cache` was no longer honored
and the call hit the real uninitialized runtime.

Restore the local import in _resolve_llm_model, _resolve_harness_impl,
_validated_harness_override, and _validated_harness_override_executor_type
to match pre-split behavior.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 16:54:23 +07:00
Serena Ruan 9bbb4eeb99 feat(web): in-session composer config gear modal (#3111)
* feat(web): in-session composer config gear modal

Bring the new-session gear-config affordance (#3050) into the in-session
composer. A gear icon left of the send button shows the session's live
run-config on hover and opens a config modal on click, consolidating the
mid-session switchable knobs — Model, Effort, and Smart Routing — behind one
control. Permission/approval/cursor modes stay launch-time only and are
intentionally absent.

What changed:

- New ComposerConfigGear + SessionConfigModal: draft Model/Effort/Smart Routing
  and apply on Save (Cancel discards), mirroring HarnessConfigModal. Save
  commits SEQUENTIALLY (awaiting each PATCH) because claude-native applies
  model/effort by typing separate /model and /effort slash commands into its
  terminal — firing them concurrently interleaves the injections into one bad
  line. Unchanged knobs are skipped.
- The <Model> <Effort> control is now a read-only status label, not a dropdown
  (the gear owns config); bare /model opens the modal. The label reads "Smart
  Routing" when routing is on, and falls back to the harness identity
  ("Polly (Pi)") for SDK/bundle agents that surface no model/effort.
- Harness identity moved out of the status-line tray into the gear tooltip.
- The gear is soft-disabled (aria-disabled + click guard, tooltip preserved)
  when the session isn't live, since a config PATCH can't wake a sleeping
  runner and those states never load the model catalog.
- Extracted ConfigRow / DescribedSelect / MODEL_SELECT_* sentinels from
  NewChatDialog into web/src/components/HarnessConfigControls.tsx for reuse.
- Removed the standalone IntelligentModelControl and its per-turn verdict chip;
  Smart Routing now folds into the Claude Model dropdown (a Switch for other
  routable agents).

Smart Routing eligibility is unchanged (same isCostRoutingSession gate the
prior control used); a KNOWN GAP note documents that the in-session gate is
stricter than the new-session dialog's routable-harness rule, to be aligned in
a follow-up.

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

* fix(web): restore host/context tray + fold Smart Routing into Codex model dropdown

Two follow-up fixes on the in-session composer gear modal:

- Restore the composer status-line tray (host badge + context ring) for
  host-bound sessions that have no worktree branch and no context ring yet
  (e.g. codex). Removing the harness label from the tray also dropped it from
  the render guard, which had been the de-facto "always render for a bound
  session" trigger — so the whole shelf vanished. Gate on a `showHostBadge`
  (host-bound + non-sub-agent) signal instead. Fixes the failing
  test_host_badge / test_hosts_changed_push e2e specs.
- Fold Smart Routing into the Model dropdown for ANY agent that has one
  (Claude and Codex), not just Claude. Previously Codex got both a standalone
  Smart Routing switch AND a Model dropdown whose selected value could become
  the routing sentinel with no matching option (empty trigger). The rule is now
  "has a Model dropdown" (showModels): fold in when it does, standalone Switch
  only for routable agents without one (e.g. Polly).

Both covered by regression tests (host-bound tray renders with no branch/ring;
Codex folds routing into its dropdown with no standalone switch). Verified the
previously-failing host-badge e2e specs and the gear-modal e2e specs pass
locally.

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

* test(ui-snapshot): update visual baselines for composer gear modal

The composer now shows a read-only model/effort label + config gear (and
the harness label moved into the gear tooltip), which changes the chat
conversation render. Regenerate the three drifting visual baselines from
the PR's CI-rendered artifact (byte-identical to the pinned Playwright
image the UI Snapshot gate compares against) so the gate passes.

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

* refactor(web): drop orphaned IntelligentModelControl + verdict exports

This PR relocated the standalone Smart Routing control into the composer
gear modal and removed its only app-code usage, leaving
IntelligentModelControl, parseCostRoutingVerdict, CostRoutingVerdict,
verdictRelativeTime, ModelTierPill, and COST_CONTROL_PLAN_LABEL with no
remaining consumers (only their own tests). Delete them and their tests.

Keep the still-used exports: isCostRoutingSession (ChatPage eligibility
gate), CostControlMode (NewChatDialog), and shortModelName (StatusBlocks
+ SmartRoutingCard). Fix the stale {@link ModelTierPill} JSDoc reference
in SmartRoutingCard.

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

* test(ui-snapshot): exercise the composer config gear in the chat baseline

The chat visual-snapshot fixture served a bare session (no omnigent.wrapper
label, no model_options), so modelPickerKind was null and the composer's
config gear + read-only model/effort label never rendered — the baseline
couldn't guard them. Patch the mocked session into a claude-native wrapper
(labels + harness + llm_model + model_options, mirroring the model-picker
e2e), and wait for the gear + model/effort label before capture, so the
baseline now covers the new composer surface.

The committed [linux] baseline PNG is regenerated separately from the CI
render (no Docker locally); verified on a throwaway [darwin] render that the
gear + "Sonnet 5" label now appear.

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

* test(ui-snapshot): regenerate chat baseline capturing the composer gear

Adopt the CI-rendered [linux] baseline (byte-identical to the pinned
Playwright image the gate compares against) now that the fixture renders
a claude-native session: the composer shows the config gear + "Sonnet 5"
model/effort label.

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

* fix(web): don't re-pin a leaked sticky on routing-off; use effort sentinel

Two non-blocking review notes:

- Routing-off on a no-dropdown routable agent (e.g. Polly) entered the
  model-commit branch and could setModel(resolvedModelId) where
  resolvedModelId resolves the leftover cross-session sticky
  (sessionModelOverride ?? selectedModel) — pinning a model the user never
  chose. Gate the routing-off re-pin on showModels: only agents with a Model
  dropdown re-pin; no-dropdown agents clear via setModel(null).
- The Effort select reused MODEL_SELECT_DEFAULT as its "none" sentinel;
  switch to the purpose-built EFFORT_SELECT_NONE for consistency with the
  new-session dialog.

Adds a regression test proving a leaked "gpt-5.5" sticky is not pinned when
turning routing off on an SDK/bundle agent.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-23 17:54:10 +08:00
Tomu Hirata 55a3884872 fix(claude-native): strip base64 image data from tool-result history (#3113)
* fix(claude-native): strip base64 image data from tool-result history

Reading an image file via Claude Code's Read tool returns the image as
a list of {"type":"image","source":{"type":"base64",...}} blocks. The
transcript mirror serialized that content verbatim into the stored
function_call_output, so a single image cost ~245KB (~70K+ tokens) of
literal text. On resume the native harness replays these items as prompt
text, and a handful of image reads overflows even a 1M context window —
which then wedges compaction (it must load the same over-window history
to summarize, fails with "prompt is too long", writes no compaction
boundary, and re-overflows on the next resume). The base64 is useless to
the model as text anyway.

Strip inline base64 image blocks to a "[image omitted from history]"
placeholder before serializing the tool-result output. Observed on a
real wedged session: 245,080 -> 55 chars per image (99.98% reduction),
eliminating the ~281K-token replay overrun.

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

* fix(claude-native): make stripped-image placeholder recoverable

The base64-strip placeholder was a dead "[image omitted from history]"
marker. Since a stripped image always comes from a tool call (e.g. Read
of a file path) that is preserved intact right before the output, the
agent can view the image again by re-running that call. Name the media
type and say so in the placeholder, so the image is recoverable on
demand rather than appearing silently lost.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 18:31:06 +09:00
Anthony Ivan 09b9f00c76 fix(pi): show intermediate reasoning (#2979)
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-23 17:56:19 +09:00
Tomu Hirata a30cc35063 fix(llms): flatten list-shaped content in non-streaming converter (#3109)
Non-streaming chat_response_to_response stored message.content raw, so
for Claude via Databricks (and Kimi, etc.) — which return content as a
list of typed blocks — OutputText.text became a list instead of a str.
This broke prompt_policy (fail-closed DENY on .strip() of a list) and
any non-streaming consumer of databricks-claude-* models.

Reuse the existing _extract_delta_content helper (already used by the
streaming path) to flatten list-of-blocks content into a string; it
returns the plain string unchanged for existing providers.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 17:48:32 +09:00
Daniel Lok 1e914403e2 fix(store): hydrate labels in list_conversations_by_runner_id (#3116)
Forked claude-native (and other native) sessions launched the vendor
TUI with no prior conversation history, even though the fork copied the
history into the store (the web UI showed it). The runner never received
the fork directives that drive transcript seeding.

Root cause: list_conversations_by_runner_id built its Conversation
entities without fetching labels, so they carried labels={}. The runner
reconnect path (_on_runner_connect) sources conversations from this
lookup and builds the session-init envelope from conversation.labels;
with an empty label set the fork directives (omnigent.fork.carry_history,
omnigent.fork.source_external_session_id) were dropped in transit. The
init-envelope initializer then caches and shares that label-less envelope
with the first-turn path, so even the label-hydrated get_conversation
result was never used for the envelope. The runner saw no fork labels,
skipped the clone/rebuild branches, and launched the TUI fresh.

This dropped labels for every consumer of the reconnect path, not just
claude-native forks — any label-driven behavior on reconnect (codex / pi
/ qwen fork history, presentation ui/wrapper labels) was equally
affected and is fixed by the same hydration.

Fix: fetch labels via the existing batched _fetch_labels_bulk inside the
same _conv_session and thread them into _to_conversation. One extra
query, no N+1, correct under the split-DB topology (labels live in the
conversation DB).

Co-authored-by: Isaac
2026-07-23 08:13:49 +00:00
Aravind Segu 1370a31247 Add injectable-conversation-id seams to create_session_with_agent and fork_conversation (#3106)
`create_conversation` already accepts an optional `conversation_id` (falling back
to `generate_conversation_id()` when omitted). This extends the same capability to
the other two session-creating methods via protected `_..._with_id` seams:

- `create_session_with_agent(...)` -> `_create_session_with_agent_with_id(conversation_id, ...)`
- `fork_conversation(...)` -> `_fork_conversation_with_id(conversation_id, ...)`

The public methods stay unchanged thin wrappers that pass `generate_conversation_id()`,
and the `ConversationStore` ABC is untouched, so this is a behavior-preserving refactor
for all existing callers. It lets a subclass mint the id externally and inject it as the
row id (e.g. a store that keys conversations by an identity-service node id) — which
`create_conversation` already permits but these two methods did not.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-23 01:07:00 -07:00
Serena Ruan aa9e748ff2 feat(projects): add a config column for project-level session defaults (Phase 2) (#3108)
* docs(projects): mark the benchmark TODO done (#3094)

The list_projects / list_project_sessions journeys, project corpus seeding, and
the dev/benchmarks PR-benchmark trigger all landed in #3094. Update the PRD
status so the roadmap points at Phase 2 (project defaults) as the next item.

Co-authored-by: Isaac

* feat(projects): add a config column for project-level session defaults (Phase 2)

Phase 2 (P4a) of the projects feature — the backend half. Gives a project a
place to store default session settings (host, workspace, harness, model,
reasoning effort, git base-branch, …) so a new session created in the project
can pre-fill them, replacing the inference-based prefill (#2133) in a follow-up.

- Migration b3c4d5e6f7a8: add a nullable `config` TEXT column to `projects`
  (additive; clean downgrade). NULL = no stored defaults.
- The column is an OPAQUE JSON object: the backend persists it whole and never
  filters on it, so the key vocabulary is owned by the client (the new-chat
  dialog) and can grow without a schema change. Values are hints, not enforced.
- Plumb config through the stack: SqlProject model, Project entity (decoded
  dict, empty when unset), ProjectStore.create/update (encode/decode helpers
  mirroring session_overrides), and the /v1/projects schemas + routes.
- update() semantics: config=None leaves it unchanged; config={} clears it —
  distinct, so a rename never wipes stored defaults.
- Tests: store round-trip + None-vs-{} update semantics, route create/get/patch
  round-trip, entity default_factory isolation, migration up/down verified.
- Regenerated openapi.json (config on ProjectObject/Create/Update).
- PRD: mark the backend config column done; the dialog wiring and #2133
  retirement remain as follow-up sub-items of Phase 2.

Co-authored-by: Isaac
2026-07-23 15:39:36 +08:00
Jackson Zheng d29ba6bfd7 Polish sidebar navigation and session metadata (#3092) 2026-07-23 00:32:16 -07:00
Zeyi (Rice) Fan 950defda0c feat(omnidev): add pod-wired omnigent passthrough subcommand (#3110)
## Related issue

N/A

## Summary

- Adds `omnidev omnigent <args…>`, which forwards any omnigent command to
  `uv run omnigent …` with the current checkout's pod env applied
  (`OMNIGENT_DATA_DIR`, `OMNIGENT_DATABASE_URI`, `OMNIGENT_CONFIG_HOME`,
  `OMNIGENT_URL`), so a CLI command talks to the same pod the supervisor runs
  and coexists with a running supervisor (no lock acquired).
- Resolves the repo root → pod dir (same as the supervisor), ensures the pod
  tree, and reads persisted ports so `OMNIGENT_URL` targets a live server. Runs
  in the foreground inheriting stdio and exits with omnigent's status code;
  omits the supervisor's log-mirror env so omnigent's own TTY detection wins.
- The `omnigent` subcommand is a named gate with `trailing_var_arg` +
  `allow_hyphen_values`, so the existing install subcommands
  (`install`/`update`/`check`/`refresh`/`shell-hook`) keep their top-level
  surface and clap's typo-suggestion guardrail. New `src/omnigent_cmd.rs` holds
  the pure `build` + `run` split for testability.

## Test Plan

- `cargo build` and `cargo clippy` clean (no warnings).
- `cargo test` — 60 tests pass (36 unit + 7 install-mgmt + 17 pod-setup),
  including 4 new `omnigent_cmd` unit tests: args forwarded after
  `uv run omnigent`, empty passthrough, pod-isolation env applied, and
  log-mirror env omitted.
- `omnidev --help` shows the flat subcommand surface; `omnidev omnigent …`
  outside a checkout fails at repo-root discovery (not at clap); `omnidev
  isntall` still suggests `install`.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manual verification: confirmed `--help` renders the new `omnigent` subcommand,
the passthrough routes outside a checkout (repo-root error, not a clap error),
and the typo guardrail survives (`omnidev isntall` suggests `install`).

## Changelog

`omnidev omnigent <args…>` runs an omnigent command against the current checkout's pod via `uv run omnigent`, with the pod's isolated env applied
2026-07-23 06:59:54 +00:00
Kunyu Chen 09a9843b13 Enable Slack integration tests in CI with additional integration tests (#3104)
Enable Slack integration tests in CI with additional integration tests
2026-07-22 23:47:53 -07:00
Zeyi (Rice) Fan 823ee72d76 fix(server): enable accounts mode for non-loopback binds (#3107)
## Related issue
N/A

## Summary
- A bare `omnigent server --host 0.0.0.0` used to stay in header mode and fail-close (401 on every request) with no warning and no path forward, because an end user has no realistic way to inject an identity header. The existing first-admin terminal prompt also never fired, since it no-ops when `account_store is None` (header mode).
- Now a non-loopback bind with no explicit auth config auto-enables accounts (login) mode, mirroring the Docker/Cloudflare/k8s entrypoints. The server boots and serves; first-admin setup happens via the web Create-admin form. A stderr warning is emitted at startup naming the host and the mode change.
- Removed the `_maybe_prompt_first_admin` TUI prompt path entirely — the server should just be a server, and the web Create-admin form (which is fully self-sufficient) is now the only interactive setup route. Explicit operator choices (`OMNIGENT_AUTH_PROVIDER`, `OMNIGENT_AUTH_ENABLED`, deprecated `OMNIGENT_ACCOUNTS_ENABLED`) always win; the loopback default is unchanged.

## Test Plan
- `uv run python -m pytest tests/cli/test_bind_auth_defaults.py -v` — 13 new unit tests covering the loopback/non-loopback/explicit-override matrix (accounts auto-enabled + warning on non-loopback; explicit provider/auth-enabled respected; empty `AUTH_PROVIDER` treated as unset; OIDC resolves downstream).
- `uv run python -m pytest tests/cli/test_server_lifecycle.py tests/cli/test_cli_auth.py tests/server/test_accounts.py -q` — existing tests still pass (131 total).

## Demo
N/A

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

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

## Coverage notes
The new `_apply_bind_auth_defaults` helper is unit-tested directly across all matrix corners; existing server-lifecycle / accounts / CLI-auth suites confirm no regressions.

## Changelog
`omnigent server --host 0.0.0.0` now enables accounts (login) mode automatically instead of silently 401-ing every request

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-23 06:34:43 +00:00
Rahul Ravindranathan 1f08a5d028 feat(scheduled tasks): data layer — API client, hooks, schedule helpers (1/3) (#3098)
Stack 1 of 3 for the Scheduled Tasks page (UI-1). Pure lib/hooks, not
rendered yet, so it type-checks standalone.

- scheduledTasksApi.ts: hand-written client for all 6 /v1/scheduled-tasks
  endpoints (mirrors sessionsApi.ts).
- useScheduledTasks.ts: React-Query list query (page-scoped 60s poll, with
  a guard-rail comment) + create/patch/delete mutations with invalidation.
- scheduleText.ts: client-side RRULE → "Weekdays at 8:00 AM · Next run in Xh".
- scheduleBuilder.ts + timezones.ts: RRULE construction + IANA tz helpers.
- Adds the rrule@^2.8.1 dependency (the only new dep).

Co-authored-by: Isaac

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-22 23:25:30 -07:00
Zeyi (Rice) Fan c7b24b8b05 refactor(cli): replace omni server start with omni server --background (#3105)
## Related issue

N/A

## Summary

- Removes the `omni server start` subcommand. `omni server` already starts
  the server (in the foreground), so `start` was a redundant way to launch it;
  the only thing it added was the detached/background mode.
- Adds a `--background` flag to `omni server` that reproduces the former
  `start` behavior: spawn (or reuse) the managed detached local server instead
  of running uvicorn in the foreground. `omni server stop` / `omni server
  status` are unchanged.
- Updates the desktop app's CLI shell-out, docs, skill files, and tests to
  the new invocation.

## Test Plan

- `omni server start` now exits `2` with "No such command 'start'" (verified
  via `CliRunner`).
- `omni server --background` routes to `ensure_local_omnigent_server()` and
  short-circuits before the foreground port-bind check; prints the URL and
  captured log path on spawn, "already running" on reuse, and omits the log
  line when `log_path` is unknown (3 renamed tests pass).
- `omni server stop` / `omni server status` behave as before (verified via
  CliRunner with stubbed registry).
- `server --help` lists `--background` and only the `stop`/`status`
  subcommands; bare `omni server` still reaches the foreground port-bind
  check.
- `node --check web/electron/src/omnigent_cli.js` passes; the spawn primitive
  in `host/local_server.py` invokes the bare `omnigent.cli server` foreground
  command, so it is unaffected by the `start` removal.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Renamed the three `test_server_start_*` tests in `tests/cli/test_server_lifecycle.py`
to `test_server_background_*` (invoking `server --background`); updated
comments in `tests/host/test_local_server.py`. Manually verified routing,
help output, and the desktop CLI arg via ad-hoc CliRunner/node checks.

## Changelog

`omni server start` is removed; use `omni server --background` to launch the
detached managed server instead.
2026-07-23 06:09:05 +00:00
Serena Ruan de1c6f00ee perf(benchmarks): add list_projects + list_project_sessions read journeys (#3094)
* perf(benchmarks): add list_projects + list_project_sessions read journeys

The web sidebar now hammers two project read paths that had no benchmark
coverage: GET /v1/sessions/projects (the project list, a dual-read union of
first-class projects and legacy omni_project label-projects) and
GET /v1/sessions?project= (a project folder's sessions, the dual-read filter
behind clicking a folder).

Add both as latency journeys mirroring the existing list_sessions hot-read
path. Each is a single-request read (1 HTTP/op). list_project_sessions'
setup reads a representative project from the seeded corpus, self-seeding a
first-class project + one filed session when the DB is empty (smoke path) so
the ?project= filter resolves a real member instead of an empty match.

Wire both into the smoke test's curated HTTP-journey list and document them
in the README journey table.

Co-authored-by: Isaac

* perf(benchmarks): seed first-class projects so the project journeys measure real work

The list_projects / list_project_sessions journeys added earlier had no project
data to read: the corpus seeder never filed a session into a project, so against
a real corpus list_projects timed an empty union and list_project_sessions read
a degenerate 1-row folder (self-seeded fallback) — testing nothing about scale.

Seed first-class projects into the corpus and file a configurable fraction of
sessions into them (round-robin), across both write paths:
- new --projects N (default 20) and --filed-fraction F (default 0.5) knobs;
- projects owned by the reserved "local" user the loopback server resolves to,
  so the owner-scoped project reads see them;
- membership set on conversation_metadata.project_id (store path via
  set_conversation_project, core fast path via the bulk metadata insert);
- deterministic project ids (derived from the index) so both paths produce
  byte-identical project rows and a re-seed at the same config is stable;
- project knobs folded into the reuse marker so a pre-existing corpus without
  projects is reseeded once.

Now list_projects unions a realistic folder count and list_project_sessions
reads a populated folder (~sessions×fraction/projects members).

Tests: extend the fast-path row-count + byte-stability tests to cover the
projects table and per-folder membership; the smoke seed test asserts projects
are created and filed sessions are listable via the owner-scoped ?project=
filter.

Co-authored-by: Isaac

* ci(benchmarks): run the PR benchmark check when the benchmark harness changes

The PR benchmark regression check only triggered on migration/store changes, so
a change to the benchmark harness itself (journeys, seeder) — like adding the
project read journeys and project seeding — never ran the benchmark it defines.

Add dev/benchmarks/** to the trigger paths so harness changes are exercised
against the nightly baseline on the PR that makes them.

Co-authored-by: Isaac
2026-07-23 13:44:47 +08:00
simtsc d290e9736c fix(web): unify subagent status dot color across list and graph views (#3009)
The Subagents panel list view and graph/tree view kept separate,
duplicated status->color maps that had drifted: the quiet connected
states (launching, idle, done) rendered a blue --session-active dot in
the list but a grey --muted-foreground dot in the graph, so the same
agent showed a blue dot in list and a grey dot in graph.

Extract a single shared subagentStatus module (activity classification +
dot palette) and have both StatusIndicator (list) and NodeStatusDot
(graph) color their dot from it, so a given status renders an identical
dot in both views. The graph keeps its own per-activity border/background
tint, but the dot color is now the shared source of truth.

Also align the graph's activity classification with the list's: the
graph now honors the 'disconnected' state (a runner disconnect renders a
quiet grey dot in both views, not the red 'Failed'), and the root/main
node uses sessionStatus so launching and disconnected are reflected
there too.

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>
2026-07-23 05:36:27 +00:00
Serena Ruan 19976bcabd feat(projects): polish project-folder header actions (#3096)
* feat(projects): polish project-folder header actions

Refine the hover-revealed controls on a project-folder header:

- Swap order so the new-session (pencil) sits left of the "..." kebab,
  mirroring how the two buttons read left-to-right.
- Align a session row's quick-pin with the kebab (right-8) so the pin/kebab
  pair lines up with the project row's pencil/kebab pair.
- Add a "New session in project" tooltip on the pencil.
- On mobile, hide the pencil (max-md:hidden) and fold the action into the
  kebab as a md:hidden "New session" item linking to the same pre-filed
  composer.

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

* test(e2e): cover project new-session mobile fold

Add a Playwright e2e asserting the folder header's new-session pencil is
hidden below the md breakpoint (max-md:hidden) and the same action is offered
as a md:hidden "New session" kebab item linking to the pre-filed composer.
Satisfies the E2E UI Required gate for the mobile behavior change.

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

* test(e2e): scope mobile-fold locators to the test's project

The bare project-new-session / project-actions test-ids match every project
folder on the shared e2e server, so the mobile-fold test hit a strict-mode
violation (2+ pencils) once another test seeded a second folder — passing in
isolation but failing in the CI shard. Scope the pencil and kebab locators by
their per-project accessible names ("New session in <project>", "Project
actions for <project>") so only this test's folder is matched.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-23 12:41:34 +08:00
Serena Ruan d1b8577e78 feat(projects): first-class projects in the web sidebar (#3061)
* feat(projects): first-class projects in the web sidebar

Wires the web app to the first-class projects entity (#2765/#3053), keeping
the legacy omni_project label path working via dual-read so no migration is
forced. Folders are keyed by name (the union key that merges a first-class
project and a like-named label-project into one folder), carrying the
first-class id when one exists.

Backend
- GET /v1/sessions/projects now dual-reads: unions first-class projects
  (project_store.list — incl. empty, with id) and legacy label-projects
  (id=None), merged by name and sorted. Response shape list[str] →
  list[{id, name}]; still owner-scoped. openapi.json regenerated.

Frontend
- projectsApi.ts: typed /v1/projects CRUD client (list/create/rename/delete).
- Hooks: useProjects → ProjectSummary[] ({id, name}); new useCreateProject,
  useRenameProject; reworked useDeleteProject (archive + unfile every member,
  then delete the container). Filing/moving files via project_id, resolving
  the picked name to an id and creating the first-class row on demand for a
  label-only folder; "" unfiles. Conversation.project_id added.
- Sidebar: folders keyed by {id, name}, members matched by project_id OR the
  legacy label; always-visible Projects section with a "New project"
  (create-empty) control extracted to NewProjectButton.tsx; Rename dialog;
  delete threads id; a row's current-project dual-reads project_id→name so a
  pinned first-class member keeps its project flyout; "Remove from project"
  unfiles silently (a first-class project persists when emptied); empty
  folders read "No sessions".
- NewChatDialog: composer files new sessions via project_id.

Tests
- projectsApi unit tests; reworked hook tests (resolve→file, create-on-demand,
  archive+unfile+delete); sidebar/composer suites updated; server union test;
  e2e_ui docstrings + fixtures updated for the project_id membership flow.

Deferred (kept on the label path via dual-read): the new-session prefill state
machine and the Settings archived-only project picker; retiring label reads is
gated on the Phase 4 backfill.

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

* fix(projects): rename-dialog Enter, checked promote PATCH, typed projects schema

Addresses the review on #3061:

- Rename-project dialog: wrap the body in a <form> so Enter submits natively
  (Radix Dialog doesn't provide one, and the prior manual key handler looked
  for the confirm button inside the <input> and never fired).
- useRenameProject label-only promote: check res.ok on each re-file PATCH and
  throw on failure, so a 4xx/5xx no longer reports success with members left
  unfiled.
- GET /v1/sessions/projects: return a typed SessionProjectSummary list instead
  of list[dict] + response_model=None, which produced an empty ("schema": {})
  OpenAPI response and broke client generation. openapi.json regenerated.
- Drop the stale test comment describing the removed last-session remove-confirm
  gate.

Copilot #2 (recreate missing metadata row) and #4 (...->NotImplementedError in
the abstract method) intentionally declined, consistent with prior rounds.

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

* fix(projects): keep dual-read membership coherent on move/rename; lift row lookup

Addresses the second web-UI review round on #3061:

- moveConversationToProject now clears the legacy omni_project label in the same
  PATCH as it sets project_id. The sidebar groups a folder by project_id OR the
  label during the dual-read transition, so a stale label would keep a moved
  session in its old label-folder (and match two folders at once). project_id is
  the single source of truth after a move.
- useRenameProject reconciles members for BOTH paths (first-class rename and
  label-only promote): sweep the folder's members via ?project=<oldName>, re-file
  each onto the target project_id, and clear the legacy label — so a first-class
  rename no longer strands label-matched members in an oldName folder.
- resolveOrCreateProjectId tolerates the create-on-demand race: a concurrent
  move to the same new name can 409 on the second POST; re-list and use the
  winner's id instead of failing.
- ConversationRow no longer calls useProjects() per row. A list-level
  id->name map is provided via context (ProjectNamesContext), so row renders are
  O(1) with no per-row query observer.

Test PATCH-body assertions updated for the added labels field.

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

* fix(projects): preserve the original error when create-on-demand truly fails

resolveOrCreateProjectId caught the create error to tolerate the 409 race
(a concurrent move created the same name), but a genuine 500/network failure
was indistinguishable and surfaced as a generic "Could not resolve or create"
message. Re-list to disambiguate: if the row now exists a racer won — use it;
otherwise rethrow the ORIGINAL error so the true cause isn't masked.

Addresses a non-blocking note on #3061.

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

* test(e2e-ui): stub /v1/sessions/projects with the {id,name} shape in prefill test

The project-prefill e2e test stubbed GET /v1/sessions/projects with the old
bare-string body, but this PR changed the endpoint to return
SessionProjectSummary objects. The sidebar parsed no folder, so the project
header never rendered and header.hover() timed out.

Return the dual-read union shape ({id: None, name} for the label-only project
the test seeds), matching the endpoint contract and the sibling sidebar tests.

Co-authored-by: Isaac

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-23 10:52:40 +08:00
Tomu Hirata c9201a3650 Revert "fix(auto-harness): show routing toggle when effectiveHarness is 'auto' or empty"
This reverts commit d765bc317a.
2026-07-23 10:21:54 +09:00
Tomu Hirata d765bc317a fix(auto-harness): show routing toggle when effectiveHarness is 'auto' or empty
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-23 09:55:56 +09:00
Tomu Hirata b49c4e722d Merge branch 'main' of https://github.com/omnigent-ai/omnigent 2026-07-23 09:51:26 +09:00
Sabhya Chhabria 82c25ffbec [claude] Load Databricks models dynamically (#2831)
*  feat(claude): Load Databricks models live

- Refresh the gateway catalog once per new native session and share the launch snapshot with the UI.
- Keep provider-neutral aliases, cached fallback behavior, and authoritative model removals.

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

* 🐛 fix(claude): Handle delayed model catalogs

- Retry sticky model handoff after live options arrive, including bind races
- Map provider model ids and defaults to friendly active picker rows
- Tighten model option contracts and cover backend/UI edge cases

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

* fix(api): regenerate OpenAPI schema

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

* 🐛 fix(claude): Mirror managed model catalog

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

* 🐛 fix(ui): Resolve launch models from host

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

* test: fix model discovery CI coverage

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

* test: stub host model discovery in e2e

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

* fix(claude): preserve live catalog routing

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

* fix(claude): don't treat a failed-primary empty catalog as authoritative

Addresses the outstanding review round:

- discover_databricks_claude_models: when the UC listing fails and the
  legacy gateway answers with no Claude routes, re-raise the primary
  error instead of returning {} — callers now fall back to cached ucode
  models rather than hard-failing the launch on a transient UC outage.
- Warn when model-services pagination is truncated at the page budget.
- Runner claude-model-options: answer ClickException config failures
  with 424 instead of the retryable 503, so the picker path stops
  conflating "no models configured" with "still booting".
- chatStore bind race: a preserved raced-catalog selection must still
  exist in that catalog — a removed sticky alias no longer lingers
  visually selected.
- Document that the pre-launch host catalog is an ambient-default
  preview; launch re-resolves with the session's agent spec.

Co-authored-by: Isaac

* test(e2e): pick the live catalog label in the model/effort scenario

The config modal's Model rows now carry the host catalog's display
names ("Opus 4.8"), not the static alias labels, so the exact-match
click must use the mocked catalog's label.

Co-authored-by: Isaac

* chore: revert accidental uv.lock churn from the merge

Co-authored-by: Isaac

* fix(api): sync openapi.json with the host model-options docstring

Co-authored-by: Isaac

* fix(api): tolerate provider model rows without displayName

Polly review: the shared NativeModelOption schema made displayName
required and _model_options_from_wire validated all-or-nothing, so one
Codex model/list or OpenCode /api/model row lacking displayName blanked
the whole picker for the session. Restore displayName as optional (the
UI already falls back to the id) and skip malformed rows individually
instead of discarding the catalog.

Co-authored-by: Isaac

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-22 17:50:37 -07:00
Serena Ruan cf11dbce2f fix(web): remove footer background from Configure agent modal (#3089)
The Configure <agent> modal's Cancel/Save footer used the shared
DialogFooter's muted tray background and top divider, which read as a
distinct gray band. Override it to blend into the modal body so the
footer matches the rest of the surface.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-23 08:50:29 +08:00
Kunyu Chen 036cc32a6c Slack integration on Databricks Apps with U2M OAuth (PKCE) (#3051)
Slack integration on Databricks Apps with U2M OAuth (PKCE)
2026-07-22 16:06:03 -07:00
Aravind Segu cc1b6e3ac5 perf(db): consolidate scheduled_tasks listing into one user-scoped index (#2983)
Fold ix_scheduled_tasks_created_at and ix_scheduled_tasks_user_id into a
single ix_scheduled_tasks_user_scope (workspace_id, user_id, created_at, id).
The per-user GET /scheduled-tasks listing (store.list(owner_user_id=...):
WHERE workspace_id AND user_id ORDER BY created_at, id) becomes an ordered
index seek with no filesort, instead of a user_id seek that must sort or a
created_at scan of every owner's rows.

The scheduler-boot read (list_active_all_workspaces) uses neither index for
its state filter and its ordering only feeds independent per-task timer
arming, so dropping the created_at-ordered scan costs nothing.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-22 13:06:18 -07:00
Kerry Chang de8c38f68a feat(web): add ⌘⌥V hotkey to toggle voice dictation (#3044)
* feat(web): add ⌘⌥V hotkey to toggle voice dictation

Add a WhisperFlow-style global hotkey (⌘⌥V / Ctrl+Alt+V) that toggles the
composer's voice dictation from anywhere in the app — the same action as
clicking the mic button.

- New useVoiceDictationHotkey hook, mirroring useCommandPaletteHotkey: a
  global keydown listener that bails inside terminals / the Monaco editor,
  ignores auto-repeat, and matches on the physical KeyV code (⌥ rewrites the
  character on macOS). Uses the browser-safe ⌘⌥ chord shared by the
  sidebar-toggle and pinned-session hotkeys — plain ⌘M minimizes the window
  on macOS and most ⌘⇧-letter combos are browser shortcuts.
- ComposerMicButton gains an opt-in enableHotkey prop plus onVoiceStart /
  onVoiceDiscard callbacks. While listening, Enter commits (stop, keep the
  text) and Esc cancels (stop, revert to the pre-dictation snapshot); a
  discard guard drops a trailing transcript that races in after Esc.
- Wire the hotkey + snapshot/restore into both composers (ChatPage and the
  New Chat landing screen); the two never mount at once, so the chord never
  double-fires.
- Document the shortcut in the keyboard-shortcuts dialog.

Co-authored-by: Isaac
Signed-off-by: kerryspchang <kerryspchang@users.noreply.github.com>

* fix(web): skip the doomed Web Speech take in Electron dictation

In Electron the SpeechRecognition constructor exists but has no backend, so
the first take always fails with a "network" error and only then falls back
to the server path — a visible ~1s "fail then recover" on every take. Real
browsers don't hit this because Web Speech genuinely works there.

When the server advertises dictation and we're in the Electron shell, go
straight to the server path and skip the Web Speech attempt entirely. The
existing "network" fallback stays as a safety net for other environments.

Co-authored-by: Isaac
Signed-off-by: kerryspchang <kerryspchang@users.noreply.github.com>

* test(e2e): cover the voice-dictation hotkey and Enter/Esc commit/discard

The E2E UI gate flagged the new keyboard-driven dictation behavior as
user-facing and unit-tested only. Extend the existing server-dictation
Playwright test with three cases driving a real browser + live server +
fake engine:

- the ⌘⌥V / Ctrl+Alt+V hotkey starts and stops a take (window keydown
  path, matched on the physical KeyV code — not the mic button onClick),
- Enter while listening ends the take and keeps the dictated text (and,
  via the capture-phase handler, does not send the draft),
- Esc while listening ends the take and reverts to the pre-dictation text.

Extract the server-mode page setup (mic permission grant + stripping the
SpeechRecognition constructors) into a shared helper the four tests share.

Co-authored-by: Isaac
Signed-off-by: kerryspchang <kerryspchang@users.noreply.github.com>

---------

Signed-off-by: kerryspchang <kerryspchang@users.noreply.github.com>
Co-authored-by: kerryspchang <kerryspchang@users.noreply.github.com>
2026-07-22 11:10:08 -07:00
Thomas Garnier eea03b4040 fix(egress): don't inject credentials on TRACE/OPTIONS + honor Max-Forwards (#3029)
* fix(egress): don't inject credentials on TRACE/OPTIONS + honor Max-Forwards

TRACE is a loopback diagnostic whose final recipient reflects the request
back to the caller, so the credential proxy attaching a bound-host secret
on TRACE would echo it straight back into the sandbox. Refuse credential
injection/swap on TRACE and OPTIONS regardless of the allowlist.

Also make the proxy a conformant intermediary for Max-Forwards
(RFC 7231 §5.1.2): answer TRACE/OPTIONS as the final recipient when the
hop budget reaches 0 (never forwarding into the injection path), and
decrement a positive budget before forwarding.

Signed-off-by: mxatone <6202935+mxatone@users.noreply.github.com>

* refactor(egress): address Polly review notes on Max-Forwards handling

Non-blocking follow-ups from the automated review:
- Normalize the method with .upper() inside _apply_max_forwards so the
  guard holds even if a future caller forgets to upper-case the verb.
- Document that the OPTIONS Allow list is intentionally static and
  proxy-scoped (the proxy's own final-recipient capabilities, not the
  origin's).
- Note that a request body on the terminate path is intentionally left
  undrained since the reply is Connection: close.

Signed-off-by: mxatone <6202935+mxatone@users.noreply.github.com>

---------

Signed-off-by: mxatone <6202935+mxatone@users.noreply.github.com>
2026-07-22 09:58:26 -07:00
Cathy Yin 335d90f621 feat(web): one-click install for a missing harness in the New Chat dialog (#2987)
* feat(web): set up a missing harness from the New Chat dialog

Turn the dead-end "binary missing" / "needs auth" warning in the New
Chat harness picker into a working setup flow, gated behind the
server's harness_install_enabled capability (flag off → the picker is
byte-for-byte the pre-feature UI).

- A "Set up →" affordance on an unready harness opens HarnessSetupDialog,
  a server-driven checklist that reflects the harness's real setup steps
  and per-step status from /v1/harnesses and /v1/info.
- One-click install drives POST /v1/hosts/{id}/harnesses/{harness}/install,
  scoped per-harness so concurrent installs of different harnesses track
  independently; the dialog reads live host readiness so the badge flips
  without a reconnect.
- Steps we can't yet detect (API-key / gateway auth) point at
  `omnigent setup` rather than showing an untrackable checkbox.

Frontend-only; the backend for this flow landed in #2912.

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

* fix(web): address review on the harness setup dialog

- Wire the harnessInstallableOnHost guard into the Install button so the
  UI never offers a one-click install the server's allowlist would
  reject (defence in depth against catalog/allowlist drift); it was
  exported and tested but never called. Fix the stale
  canInstallHarnessFromUI doc reference.
- Key the post-install toast on the refreshed readiness the install
  returns: "ready" only when the harness is actually launchable,
  otherwise "installed — one more step" so it can't contradict a
  still-showing sign-in row (e.g. Codex).
- Add a fallback message when the server published no setup steps for a
  spelling, instead of an empty dead-end dialog.

Adds tests for the guard, both toast wordings, and the empty-steps
fallback.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-22 15:25:56 +00:00
Cathy Yin 73498532db fix(onboarding): judge harness install success with the readiness resolver (#3068)
* fix(onboarding): judge install success with the readiness resolver

try_install_harness_cli judged install success with a bare
shutil.which(spec.binary), but readiness (harness_cli_installed) uses
resolve_cli_binary — the full ladder that also probes the
nvm/npm-global/homebrew bin dirs the host daemon's frozen PATH omits.

On a host whose npm prefix is off PATH, npm lands the binary in a
fallback dir: the install verdict returned "not on PATH" (→ 502 → red
"failed" toast) while readiness resolved it via the ladder (→ green
"ready" tick). One install, two contradicting verdicts, surfaced by the
UI setup dialog.

Judge success with the same resolve_cli_binary the readiness badge uses
so the two can't disagree, while keeping the ~/.local/bin PATH-prepend
the setup wizard's later harness_login relies on. Adds a regression test
pinning that an off-PATH-but-on-ladder binary reads installed from both
try_install_harness_cli and harness_cli_installed.

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

* docs(onboarding): clarify HarnessInstallResult resolves off PATH too

Polly review nit: after unifying the install verdict on resolve_cli_binary,
the "on PATH after the attempt" phrasing on HarnessInstallResult.installed
and in try_install_harness_cli's docstring was stale — success can now also
come from a binary resolved via the fallback ladder (off bare PATH). Reword
both to say "resolves via resolve_cli_binary". No behavior change.

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

* fix(onboarding): put the resolved install dir on PATH for later login

Polly review follow-up on the install-verdict fix: judging install
success via resolve_cli_binary's full ladder fixed install-vs-readiness,
but the wizard's *later* steps (harness_login / harness_cli_logged_in /
harness_logout) still shell out with the bare binary name and only bare
shutil.which. The prior remediation only prepended ~/.local/bin, so an
install that succeeded via a different fallback dir (nvm / npm-global /
homebrew) could be followed by a login step that couldn't locate the
binary just installed.

Prepend the dir the binary actually resolved from (Path(resolved).parent)
to PATH, so install, readiness, and login all converge on the same
binary. Adds a test pinning that a bare shutil.which (what login uses)
finds the CLI after an off-PATH install, and updates the ~/.local/bin
refresh test for the resolver-based mechanism.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-22 15:02:37 +00:00
Daniel Lok 23e498521f fix(runner): quiet idle-reaper shutdown instead of a scary error banner (#3060)
* fix(runner): quiet idle-reaper shutdown instead of a scary error banner

When the runner idle monitor reaps an inactive runner after
`runner.idle_timeout_s` (default 1h), the runner exits cleanly (code 0),
but the UI rendered the same loud red `ErrorBanner` a genuine crash would
— even though the session is fully reactivatable (host-bound sessions
relaunch the runner on the next message). A clean idle shutdown tripped
two banner-producing server paths:

1. Relay path (durable / reload banner): the runner's `GET /stream`
   dropped abruptly, so the SSE relay published `failed` +
   `runner_disconnected` and persisted it as a `last_task_error` label.
2. Host exit-report path (live): the host's `_watch_runner` reported
   `host.runner_exited`, which became `failed` + `runner_failed_to_start`.

This treats a clean idle exit as benign (a genuine crash still shows the
banner):

- Runner drains its session streams before the idle shutdown: enqueues the
  `[DONE]` sentinel to each `GET /stream` so the relay returns cleanly
  (no `runner_disconnected`, no durable label). `serve_tunnel` now takes a
  `shutdown_event` + `on_graceful_shutdown` hook; on signal it waits for
  in-flight dispatch tasks to emit their end frames, then closes the socket
  with a normal close handshake (the handshake completing is the delivery
  confirmation — robust over a remote connection, not a timing nudge), and
  stops reconnecting.
- Host suppresses the exit report for a clean (code-0) exit; a non-zero
  exit still reports its cause.

Co-authored-by: Isaac

* refactor(runner): address PR review nits on graceful-shutdown loop

- Use asyncio.create_task instead of ensure_future in the graceful-shutdown
  read loop, matching the module convention (Copilot).
- Make the graceful-shutdown serve test deterministic: pre-arm the shutdown
  event so the first recv() race resolves to it, dropping the real-time
  sleep(0.01) that could flake under load (Copilot).
- Give the flagged bare `await task` an explicit effect via
  `assert task.result() is None` (CodeQL "statement has no effect").

Co-authored-by: Isaac

* docs(runner): note the same-tick frame drop in graceful shutdown

Polly/Copilot review flagged that if a frame and the shutdown signal
complete in the same asyncio.wait tick, the shutdown branch wins and the
frame is dropped. That is acceptable on the idle-reaper teardown path (a
host-bound session replays/relaunches on the next message); document it so
the trade-off is explicit for future readers.

Co-authored-by: Isaac

* refactor(runner): snapshot drain queues; create_task in tests

Follow-up PR review nits (Copilot):

- `_drain_session_streams` now iterates `list(_session_event_queues.values())`.
  The loop is synchronous (no await, so nothing interleaves on the event loop
  today), but snapshotting keeps the drain robust if a queue mutation ever
  moves off this atomic path — matching the `list(...)` idiom already used by
  the timer-cleanup / pane-reaper paths.
- Switched the two remaining `asyncio.ensure_future(...)` test helpers to
  `asyncio.create_task(...)` for consistency with the module convention.

Co-authored-by: Isaac

* fix(runner): log recv failure while settling cancelled read on shutdown

PR review (Copilot): the graceful-shutdown branch swallowed
WebSocketException while awaiting the cancelled recv_task. If recv() had
already failed with an abnormal close on the same tick the shutdown fired,
the socket may be dead — so the drain's [DONE] frames won't reach the
server and it will see a disconnect — yet there was no trace of why.

Keep suppressing the exception (letting it propagate would skip
_graceful_drain and reintroduce the abrupt drop this PR removes), but split
the handling: silent on CancelledError (normal cancellation), debug-log on
WebSocketException so the rare same-tick failure is diagnosable without
disturbing the quiet UX.

Co-authored-by: Isaac
2026-07-22 22:39:16 +08:00
Tomu Hirata c9eee1f048 perf(scheduled-tasks): fix unbounded queries in scheduled-task store (#2997)
* perf(scheduled-tasks): fix unbounded queries in scheduled-task store

Three unbounded DB reads could cause excessive load as the task table grows:

- Issue #5: `list()` fetched all workspace tasks then filtered in Python.
  Add `owner_user_id` parameter to `list()` (ABC + SQLAlchemy) so the
  WHERE clause uses the existing `ix_scheduled_tasks_owner_user_id` index.
  Update the route to pass `owner_id` directly instead of post-filtering.

- Issue #6: `list_runs()` returned every historical run for a task with no
  LIMIT. Add a `limit: int = 100` keyword parameter (ABC + SQLAlchemy) and
  apply `.limit(limit)` to the query.

- Issue #10: `list_active_all_workspaces()` had no cap on rows returned at
  scheduler boot. Apply a hard `.limit(10_000)` to prevent unbounded load.

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

* fix(scheduled-tasks): paginate list_runs and arm all tasks at boot instead of silent caps

Problem A: GET /scheduled-tasks/{id}/runs silently truncated run history at
100 rows with no pagination. Replace the bare limit with cursor pagination:
list_runs now returns (runs, next_cursor) and takes after_id; the endpoint
accepts limit (1-1000) and after, and returns {runs, next_cursor}. Run ids are
random UUIDs, so the keyset resolves the cursor row's scheduled_at and compares
the full (scheduled_at, id) tuple under the DESC order — an id-only cursor
would skip/repeat rows on scheduled_at ties.

Problem B: scheduler boot (list_active_all_workspaces) capped at 10k rows, so
tasks beyond the cap silently never armed. Chose the complete-pagination
approach over a loud-warning cap: the method now keyset-pages internally by
(workspace_id, created_at, id) in 10k batches and returns ALL active tasks, so
every task is armed at boot. Full pagination is strictly correct (no task ever
left un-armed) and the boot scan is a rare, one-shot cost.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-22 21:25:42 +09:00
Tomu Hirata 12d59cc3b6 perf(conv-store): eliminate read-after-write in conversation methods (#2996)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-22 21:25:06 +09:00
Tomu Hirata c92fcf15aa fix(permission-store): add query limits and consolidate session opens (#2995)
* fix(permission-store): add query limits and reduce session opens

Unbounded queries on list_for_user, list_for_session, and list_users
could fetch unlimited rows from the DB. Add limit: int = 1000 to each
with .limit(limit) applied to the query; update the abstract base class
to match.

check_access opened 2 separate sessions for 2 PK lookups.
get_permission_level opened 3 sessions (is_admin + 2 get calls).
Consolidate each into a single `with self._session()` block following
the same pattern used by resolve_access.

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

* revert(permission-store): restore separate sessions in check_access and get_permission_level

The consolidation of check_access and get_permission_level into single
sessions changed the timing characteristics of permission reads. Under
xdist parallel test execution the CI integration suite (Integration
openai-agents) saw test_share_and_second_user_continues fail: a
concurrent reset from another worker cleared the mock LLM queue between
configure_mock_llm and the owner's first turn, causing the second turn to
receive no LLM response.

Revert check_access and get_permission_level to their original
multi-session implementations to restore the original execution timing.
The resolve_access consolidation (used by the hot GET /v1/sessions path)
is retained as it was already present on main and is not implicated in
the failure.

Issue #15 (reducing session opens in check_access/get_permission_level)
remains open and can be addressed with a more targeted fix that also
addresses test isolation.

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

* feat(permissions): add cursor pagination to GET /sessions/{id}/permissions

list_for_session now returns (grants, next_cursor) with user_id-ordered
keyset pagination. The API endpoint accepts limit (1–1000, default 100)
and after (cursor = user_id) query params and returns
{"permissions": [...], "next_cursor": str|null}.

GET /users gains a limit query param (1–1000, default 100) wired through
to list_users(). list_for_user keeps its silent 1000-row cap (internal
only).

All callers of list_for_session updated to unpack the tuple.

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

* test(permissions): cover cursor pagination and dict response shape

Add a store-level pagination test and update the session permissions
integration tests to unwrap the new {permissions, next_cursor} response
shape. Fix list_for_session cursor to return the last returned user_id
so the exclusive user_id > after_user_id filter does not skip a row.

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

* test(permissions): update e2e/server tests for paginated permissions response

GET /v1/sessions/{id}/permissions now returns {permissions, next_cursor}
instead of a bare list. Update the e2e sharing test and the e2e_ui
permissions-modal helper to read the permissions array.

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

* fix(web): parse paginated permissions response in listPermissions

GET /v1/sessions/{id}/permissions now returns {permissions, next_cursor}
instead of a bare array. listPermissions follows the cursor and
concatenates all pages, returning Permission[] so callers
(isSessionSharedWithOthers, AgentInfo, usePermissions) are unaffected.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-22 21:22:41 +09:00
Serena Ruan 1b9a0c91b0 fix(web): move host-offline reconnect prompt into the composer host badge (#3062)
* fix(web): move host-offline reconnect prompt into the composer host badge

When a session's host went offline, the "Host is offline — click to
reconnect" affordance rendered as a banner below the composer, separate
from where the host is already named. Fold it into the composer's host
badge: when a session is `host_offline`, the badge becomes a clickable
red "Host is offline — click to reconnect" control in place of the
passive host name + status dot.

ConnectionIndicator now suppresses its banner for `host_offline` whenever
the composer (and its badge) is on screen — i.e. everywhere except the
terminal-first *terminal* view, where the PTY owns the surface and the
banner still carries the affordance. `local_stranded` keeps the banner
everywhere (no host, so no badge to host it).

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

* fix(web): keep host-offline banner for sub-agent sessions

A sub-agent session's composer hides the host badge (the header's child
slot owns that row), so the badge can't carry the host-offline reconnect
affordance. The banner suppression keyed only on the terminal view, so a
non-terminal-first sub-agent `host_offline` session lost the affordance
entirely. Thread `isSubAgentSession` into ConnectionIndicator and only
suppress the banner when the badge will actually render it.

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

* fix(web): give sub-agent sessions the same host-offline reconnect path

The previous fix special-cased sub-agents by keeping the banner for them.
Instead, treat them like normal sessions: the composer's host badge carries
the reconnect affordance for a host_offline sub-agent too (only the passive
name badge stays hidden for a child). ConnectionIndicator goes back to
uniform suppression whenever the composer is on screen.

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

* refactor(web): drop unreachable sub-agent host_offline handling

Sub-agent sessions are never host-bound — sys_session_send creates the
child with host_id null and the server inherits only runner_id, so a
stranded child is always local_stranded, never host_offline. The badge's
reconnect affordance therefore never needs to render for a sub-agent;
gate showReconnect back on showHost and drop the dead sub-agent test.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 18:28:30 +08:00
Tomu Hirata 26da06d258 feat(smart-routing): add 'Auto' harness option that routes both harness and model (#3045)
* feat(auto-harness): use live runner catalog to filter available harnesses

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

* fix: restore Auto harness option and routing icon after merge

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

* fix: remove leftover comment placeholder

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

* fix: restore auto-harness session create intercept and first-message resolution after merge

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

* fix: restore route_session_harness lost in merge

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

* fix(auto-harness): always clear 'auto' sentinel after first-message resolution

Add _unset_harness_override to update_conversation so the 'auto' sentinel
is cleared even when routing returns harness=None (unavailable/failed).
Without this, the resolution block re-ran on every turn and emitted
a routing card each time.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-22 10:21:34 +00:00
Serena Ruan babd1c8b21 test(server): stop background-title test from racing the coordinator (#3063)
test_first_message_schedules_background_semantic_title wrote its own seed
title via store.update_conversation after posting the first user turn. The
events endpoint already seeds the title synchronously before returning, so
that manual write raced the background coordinator's rename and clobbered it
when it landed late — the source of the flaky
"assert 'please investigate...' == 'Debug authentication timeout'" failure.

Drop the redundant manual seed (and the now-unused db_uri fixture) so the
test relies on the endpoint's seed, matching the passing sibling tests.

Co-authored-by: Isaac
2026-07-22 18:20:24 +08:00
Pat Sukprasert 4cd191c275 test(e2e-ui): Fix native mock routing (#3056)
- Route accumulated conversations to the latest matching turn queue
- Keep native mock credentials active and refresh the Claude mock model

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-22 10:18:43 +00:00
Tomu Hirata 03db62c3d7 Merge branch 'main' of https://github.com/omnigent-ai/omnigent 2026-07-22 19:18:02 +09:00
feishuai 8a68650d75 fix(setup): avoid termios setup crash on Windows (#1993)
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>
2026-07-22 16:51:37 +07:00
Anthony Ivan d0afeddbfa fix(host) - Properly Hide Claude task notification control messages on the UI (#2104)
* 🐛 fix(history): Hide Claude task notifications

- Mark Claude task notification transcript rows as meta context

- Hide legacy task-notification rows during history hydration

* 🐛 fix(history): Handle monitor task notifications

---------

Co-authored-by: Anthony Ivan <anthony.ivan@example.com>
Co-authored-by: Serena Ruan <82044803+serena-ruan@users.noreply.github.com>
2026-07-22 17:37:07 +08:00
creynold84 ebc17378ef feat(slash-menu): substring-match slash commands by name (#2655)
* feat(slash-menu): substring-match slash commands by name

The slash-command suggestion menu matched a query as a prefix of the
full, namespaced command name, so typing `/using-superpowers` surfaced
nothing — the name starts with `superpowers:`. Match the query as a
case-insensitive substring of the command name instead, so
`/using-superpowers` surfaces `/superpowers:using-superpowers`.

A single shared helper `slashCommandMatches(name, query)` in
SlashCommandMenu.tsx backs all three web filter sites (the menu render
filter, ChatPage `menuMatches`, and NewChatDialog `slashMenuMatches`) so
the visible list and the keyboard-nav index can't drift apart. The
omnigent REPL completer (`_SlashCommandCompleter`) mirrors the same rule
in Python so the CLI and web UI behave alike; parallel unit tests keep
the two implementations from diverging.

Matching is name-only, not description: the web menu never shows
descriptions inline, so a description-driven match would look
unexplained. Insertion order is preserved (no relevance ranking) to keep
the menu's Commands/Skills section split contiguous, and submit routing
is unchanged — menu completion still fills the canonical name first.

Co-authored-by: Isaac
Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>

* style(slash-menu): prettier-format merged import lines

Rewrap the import statements combined during the ap-web -> web rebase so
they satisfy `prettier --check` (they exceeded the print width). No
behavior change.

Co-authored-by: Isaac
Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>

* style(repl-test): drop explicit `return None` from _noop_handler

Ruff (RET501) flags an explicit `return None` in a `-> None` function.
The bare `return` is equivalent; keeps `pre-commit run --all-files`
green. No behavior change.

Co-authored-by: Isaac
Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>

* test(e2e-ui): cover slash-command substring matching in both composers

Adds the Playwright coverage the e2e_ui gate requires for this
user-facing change. Two tests drive the new substring behavior in a real
browser against a spawned server:

- In-session composer: `/ontext` (mid-name substring of `/context`,
  prefix of nothing) surfaces the row AND highlights it — proving the
  render filter and `menuMatches` keyboard-nav filter substring-match in
  lockstep.
- New-chat landing composer: a stubbed non-native agent bundling a
  `code-review` skill; `/review` surfaces the row and Tab completes it to
  `/code-review ` — covering keyboard completion.

Co-authored-by: Isaac
Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>

* fix(slash-menu): rank prefix matches ahead of mid-string matches

Substring matching combined with auto-highlight (setMenuIndex(0)) and
immediate execution of no-arg built-ins let a short query execute the
wrong command. Built-ins are ordered /compact, /context, /effort,
/model, /help, so typing `/e` highlighted `/context` first (it contains
"e") and Enter/Tab ran it immediately instead of filling `/effort `;
`/m` similarly hit `/compact` ahead of `/model`. The REPL completer had
the same ordering.

Rank matches for display: built-ins before skills (so the Commands
section stays above Skills and the flat keyboard index walks the same
order that's rendered), and within each group prefix matches before
mid-string matches. The sort is stable, so ties keep insertion order and
an empty query (lone `/`) still lists everything unchanged.

A new shared helper `rankedSlashCommandNames` backs all three web filter
sites (menu render, ChatPage `menuMatches`, NewChatDialog
`slashMenuMatches`) so the visible order and keyboard index stay aligned;
the REPL completer mirrors the rule (prefix tier before substring tier,
insertion order within each). Tests pin the ordering on both sides,
including a real-registry REPL assertion.

Co-authored-by: Isaac
Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>

---------

Signed-off-by: Colin Reynolds <colin.reynolds@databricks.com>
2026-07-22 17:02:49 +08:00
Serena Ruan 7454803c2f feat(web): move new-session harness config into a gear-icon modal (#3050)
* feat(web): move new-session harness config into a gear-icon modal

The new-session composer's agent picker did double duty — selecting the
agent/harness AND exposing every run-config knob (model, effort, permission
mode, Codex approval + dangerous bypass, Cursor exec mode, bundle brain
harness) via desktop hover-flyout submenus and a bespoke mobile drill-in.
This overloaded one control and made the submenu machinery complex.

Split the concerns: the picker dropdown now only selects the agent, and a
gear icon beside it opens a "Configure {agent}" modal that adapts to the
selected agent's capabilities. The modal edits a local draft and commits on
Save (Cancel discards).

Also in this pass:
- Picker dropdown groups: "needs setup" harnesses fold into a "More" flyout;
  custom (user-registered) agents fold into a "Custom agents" flyout. On
  touch, both drill in-place with a Back row instead of hover flyouts.
- Gear tooltip summarizes the current settings on hover.
- Config Selects anchor below the trigger, pinned to trigger width; option
  descriptions (permission/approval/cursor) show in a footer that tracks the
  hovered row.
- Codex bypass toggle simplified to a plain switch (no typed-phrase gate),
  still behind Save with the danger banners.
- Smart routing folds into the Model dropdown as a "Smart Routing" option
  (when the server enables it and the harness is routable); picking it
  freezes Effort to Default. Removes the standalone composer toggle here
  (unchanged in the in-session composer).

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

* test(e2e-ui): regenerate visual baselines

* fix(web): surface Smart Routing for all routable agents; address review

Polly AI review flagged that Smart Routing lived only in Claude's Model
dropdown while _ROUTABLE_HARNESSES still advertised Codex/Pi/bundle agents —
a silent UI regression (server still routes them). Fixes:

- Add a standalone "Smart Routing" toggle row in the gear modal for routable
  agents that have no Model dropdown to fold it into (Codex, bundle agents).
  Claude keeps offering it as a Model option.
- Commit costControlMode in save() for every eligible agent, not just the
  Claude branch.
- Reset costControlMode on agent change (alongside the bypass reset), so an
  armed routing can't carry to an agent whose modal can't clear it.
- Picking "Default" in the Model dropdown while routing was on now defers
  (null → omitted) instead of emitting an explicit "off".
- Refresh the stale reset-effect comment (the typed bypass phrase is gone).

Adds tests for the Codex standalone toggle, its create-flow wiring, and the
reset-on-agent-change behavior.

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

* fix(web): make gear tooltip consistent with the modal for effort/routing

Address Copilot review (PR #3050): the tooltip's Effort summary showed the
"—" sentinel while the modal's unset option is "Default", and it didn't
reflect Smart Routing (which freezes effort) for non-Claude agents.

- Effort now reads "Default" when unset or when Smart Routing is on,
  mirroring the modal.
- Non-Claude routable agents show a "Smart Routing: On" tooltip row when
  armed (Claude folds it into the Model row).

Adds tooltip tests for the Default-effort label and the Smart Routing case.

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

* fix(web): keep the gear visible for routing-eligible agents

Address Copilot review (PR #3050): the gear was hidden when the selected
agent had no permission/approval/cursor knob and wasn't a brain-harness
agent — which would also hide Smart Routing, since it lives only in the gear
modal now. Fold smartRoutingEligible into selectedAgentHasKnobs so any
routing-eligible agent keeps its gear.

In practice every routable selectable agent already has another knob (Claude
permission, Codex approval, bundle Agent Harness), so this is defensive —
but it makes the visibility gate provably correct rather than reliant on that
overlap. Adds tests for the bundle-agent routing+harness case and the
knob-less non-routable case (gear hidden).

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

* fix(web): gate Smart Routing UI on eligibility to avoid stale-on states

Address Copilot review (PR #3050): a stale costControlMode="on" combined with
smartRoutingEligible=false (server later disabled the flag, or a non-routable
agent) could (a) leave the Model Select on the __smart__ sentinel with no
matching item, and (b) show misleading "Smart Routing" rows in the gear
tooltip. Gate both smartRoutingOn (modal) and routingOn (tooltip) on
smartRoutingEligible so the UI only reflects routing when it's actually
offered for the current agent.

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

* test(e2e-ui): update picker interactions for the grouped/gear-modal picker

The gear-modal refactor moved custom agents into a "Custom agents" submenu,
needs-setup harnesses into a "More" submenu, and the bundle brain-harness
picker into the config modal's Agent Harness select. Update the e2e drivers
that still assumed the old flat picker:

- test_create_custom_agent: reach "Create custom agent" via the Custom agents
  submenu; on a sandbox the whole group is omitted (assert both absent).
- test_hide_unconfigured_harnesses: Goose (unconfigured) now folds into "More"
  when the toggle is off — drill in to find it.
- test_agent_picker_version: the custom upload lives in the Custom agents
  submenu; the built-in stays inline.
- test_codex_auth_availability: the bundle harness badge is in the config
  modal's Agent Harness select now (open gear → open select).
- test_start_session (fork-of-fork dedup): top level is now Claude + the
  Custom agents submenu trigger (2 menuitems); the custom agent survives
  inside the submenu.

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

* fix(web): make the new-session picker and config modal mobile-friendly

- Agent picker dropdown ran off the top of short mobile viewports (clipped
  under the status bar). Add collisionPadding so Radix's available-height cap
  leaves a safe margin and the menu flips/scrolls instead of overflowing.
- Config modal rows squeezed the label into a narrow column beside a fixed
  w-52 control, forcing heavy wrapping on mobile. Stack label-over-control
  full-width on mobile; keep the side-by-side layout from sm+.

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

* fix(web): badge unconfigured brain harnesses in the config modal; fix e2e

Two follow-ups from the E2E run:

- The config modal's Agent Harness select showed a plain "(needs setup)" text
  for unconfigured harnesses, dropping the reason-specific badge (and its
  new-chat-landing-harness-warning-<id> testid) the old picker had. Restore the
  amber badge with the reason text ("needs auth", etc.) so bundle agents like
  Polly surface Codex auth state again.
- test_create_custom_agent sandbox check: the "Custom agents" submenu can
  legitimately render on a sandbox when a session-scan surfaces a discovered
  custom agent; only the create action is gated. Assert just that "Create
  custom agent" is absent, not the whole submenu.

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

* fix(web): fold Codex bypass into Approval dropdown; a11y + review fixes

UI/UX:
- Codex "Bypass approvals & sandbox" is now the most-permissive option in the
  Approval dropdown (it's conceptually an approval stance) instead of a
  separate toggle. The persistent danger banner stays when it's selected.
- Smart Routing toggle for non-Claude routable agents moves to the FIRST row
  and right-aligns the switch.

Accessibility (Copilot review): the config-modal Select triggers had no
accessible name (the ConfigRow label is visual-only). Add aria-label to the
Model / Effort / Agent Harness triggers and an ariaLabel prop on
DescribedSelect (Permissions / Approval / Mode).

Logic (Copilot review):
- The effectiveAgentId reset effect (bypass + smart routing) now fires only on
  an actual agent change, not initial resolution — so a costControlMode/bypass
  restored from the landing draft isn't wiped on mount.
- Picking Model "Default" always defers routing to the spec default (null),
  never emitting an explicit "off".

Tests: unit + e2e updated for the folded bypass option and the codex
needs-auth badge (now in the Agent Harness select; .first for Radix's
trigger mirror).

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

* fix(web): surface "Create custom agent" when no custom agents exist

On a fresh non-sandbox host with no custom agents, "Create custom agent"
was buried inside a lazily-mounted "Custom agents" submenu — non-obvious,
and it left the sandbox-gating e2e assertion vacuous (the item was never
in the DOM after opening the top-level dropdown regardless of target).

Only fold into the "Custom agents" submenu once custom/pending agents
exist; otherwise surface the create action as a top-level picker row.
This restores discoverability on a fresh server and makes the sandbox
`to_have_count(0)` assertion meaningful.

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

* fix(web): compute Smart Routing eligibility from the effective harness

A bundle agent (Polly/Debby) on a routable brain harness shows both the
Smart Routing toggle and the Agent Harness override in the config modal.
Arming routing and then overriding to a non-routable harness (e.g. Cursor)
left eligibility computed from the spec harness, so Save still committed
cost_control_mode_override and the create sent routing "on" for a harness
that can't route — with no visible control to clear it.

Compute eligibility from the effective harness (brain-harness override wins
over the spec harness), and gate cost_control_mode_override on eligibility
at create time as a safety net (also covers a stale "on" left after the
server flag flips off). Add a test for the override -> ineligible path.

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

* test(e2e): neutralize agent discovery in create-custom-agent tests

With "Create custom agent" now a top-level picker row only when no custom
agents exist, these tests began failing on the shared e2e_ui server:
sessions left behind by other tests leaked in via the kind=any discovery
scan as discovered custom agents, flipping on the "Custom agents" group and
folding the create action back into a submenu — so the top-level create row
the helper clicks was absent.

Stub the kind=any scan to return no agents (same approach as
test_codex_auth_availability.py) so only the stubbed Claude agent feeds the
picker and the create row renders deterministically at the top level.

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

* fix(web): show armed Codex bypass as the Approval value in the gear tooltip

Bypass is now an Approval dropdown option, and the modal's Approval trigger
shows "Bypass approvals & sandbox" when armed. The gear tooltip still split
it into `Approval: <preset>` (often "Default") plus a separate `Bypass: On`
row, implying approvals were still at the preset. Mirror the modal: when
bypass is armed the single Approval row reads "Bypass approvals & sandbox".

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-22 17:01:49 +08:00
Daniel Lok 3e88237c71 feat(benchmarks): simulated network delay + per-journey request counts (#2977)
* feat(benchmarks): add simulated network delay + per-journey request counts

The benchmark harness runs everything over loopback, so it can't tell a
chatty journey (many round-trips) from a lean one on wall-clock alone, nor
model what those round-trips cost over a real network. Two related knobs
close that gap.

- --network-delay-ms (default 0) injects an httpx request-hook sleep before
  every client->server request, modelling a real network hop. benchmark.yml
  gains a network_delay_ms dispatch input (0 on the nightly schedule for
  stable trend data).
- Every run now reports http_requests / http_requests_per_op: the server-side
  HTTP request count over the timed region (schema v4->5). For runner journeys
  this captures the cross-process runner->server / host->server traffic a
  client hook can't see; for HTTP journeys it's known by construction.

The counter is the server's existing ServerPerformanceMetrics.total_started,
which lives in the server subprocess and is only pushed to OTel. A CI-only
router (dev/benchmarks/omnigent/debug_router.py) exposes it at
GET /debug/server-metrics. It never ships in production: it lives under dev/
(excluded from the wheel), is mounted only via the new debug_router_modules
config key (mirroring the policy_modules load-by-dotted-path seam) that prod
config never sets, and a failed import is logged-and-skipped.

compare.py surfaces a Req/op column so an added/removed round-trip shows up
in the PR comparison. README documents both features and their v1 scope
(client<->server hop only; tunnel frames and LLM hop are follow-ups).

Co-authored-by: Isaac

* docs(benchmarks): note CI time-budget limit for high network delays

A CI dispatch at network_delay_ms=100 over the full journey set hit the
workflow's 30-min per-leg timeout: the delay multiplies across the full-turn
journeys' round-trips (cold start ~12 requests/op; turn journeys poll every
0.2s). Document the empirical budget (10ms finishes in ~6 min; 100ms times
out) and steer high-delay experiments toward an HTTP-journey subset.

Co-authored-by: Isaac

* feat(benchmarks): per-route request appendix + full-width CI table

Two follow-ups from reviewing the request-count output:

- The printed table truncated wide headers ("HTTP/op" -> "HTTP…") in CI logs,
  because rich falls back to 80 columns when stdout is not a TTY. Give the
  non-interactive console a 160-col floor so every header renders in full;
  real terminals keep auto-detection.

- Add a per-journey network appendix so the request count is actionable, not
  just a single number. ServerPerformanceMetrics now tallies requests by
  low-cardinality route template (record_route, exposed via the debug
  endpoint's route_counts); the harness diffs it per journey and the report
  gains per-run route_requests plus a summary network_routes breakdown
  ({route, requests, per_op}, sorted per_op desc, grouped across runs). This
  names which endpoints a journey's requests hit — e.g. session_cold_start's
  ~12 requests/op spread across the cross-process runner->server / host->server
  calls — not just the total. The harness's own counter-poll route is filtered
  out. Schema v5 -> v6; sample_output.json + README updated.

Co-authored-by: Isaac

* perf(benchmarks): drive warm turns over SSE instead of polling to idle

drive_turn polled GET /v1/sessions/{id} every 0.2s until the session status
returned to idle. That inflated the per-journey request count — normally
~2 GET/op, but ~800/op (124/op averaged) when a turn stalled and the loop
polled out the full 180s timeout, which is what made warm_turn's
GET /v1/sessions/{id} count balloon on the postgres leg.

Switch drive_turn to the SSE completion path the real Web UI uses: subscribe
to GET .../stream, post the message, and return on the session.status -> idle
event (guarded by seen_running so a prior turn's trailing idle can't end the
wait early). One subscription instead of an unbounded poll loop.

Result for warm_turn: a flat 3 requests/op (stream + events + policies/evaluate),
no ballooning when a turn is slow, and it mirrors production client behavior.
Latency is also more accurate — SSE observes completion immediately rather than
at the next 200ms poll tick, so p50 is no longer quantized upward.

_sse_session_status parses both the nested ({"data":{"status"}}) and flat
({"status"}) session.status shapes. Unit test + runner-journeys e2e cover it.
README CI-budget note corrected (turn journeys no longer poll).

Co-authored-by: Isaac
2026-07-22 16:44:40 +08:00
Tomu Hirata af055a72b9 fix(telemetry): resolve harness from agent_cache instead of _globals._agent_store (#3054)
_resolve_harness() routes through _globals._agent_store, which is only
populated when the server starts via the CLI (runtime.init()). In other
deployment paths the global is None, so _resolve_harness silently returns
None and SessionCreatedEvent emits harness: null for SDK sessions.

Fix: in create_session, resolve the harness directly from the in-scope
agent and agent_cache (dependency-injected into every request handler),
which are always populated regardless of how the server starts. This
mirrors the native_agent path for native harnesses and uses the existing
_spec_harness() helper for SDK executor types.

Also adds unit tests for _resolve_harness covering:
- None conv / uninitialized store / agent not found → None
- harness_override wins before any store lookup
- executor config["harness"] key → resolved harness name
- executor.type fallback → resolved harness name
- unexpected exception → None (never raises)

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-22 08:44:04 +00:00
Serena Ruan 6a7efafce2 fix(credentials): stop mislabeling OAuth Databricks profiles as malformed (#3059)
* fix(credentials): stop mislabeling OAuth Databricks profiles as malformed

The configparser fallback in resolve_databricks_workspace treated any
profile without a static `token` as malformed and told the user to "fix
or remove it". OAuth profiles (auth_type = databricks-cli) legitimately
have no token — only the databricks-sdk path can mint one for them — so
the message was actively misleading, steering users to break a valid
profile.

Distinguish a well-formed OAuth profile (non-`pat` auth_type, no token)
from a genuinely malformed one via a new `_SectionNeedsSdk` signal, and
raise an actionable OSError instead. The message now branches on why the
SDK path failed: if databricks-sdk isn't installed (it ships in the
`databricks` extra, not the base install), it tells the user to install
`omnigent[databricks]`; if the SDK is present but auth failed, it points
at the CLI / OAuth session.

The PAT fail-loud guard (missing token on a token-auth profile) is
unchanged.

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

* fix(credentials): harden SDK-import check and tailor non-CLI remediation

Address PR review:

- `_databricks_sdk_importable` now does a real `import databricks.sdk.config`
  in a try/except instead of `importlib.util.find_spec`. find_spec can return
  a spec for an SDK whose transitive deps are missing, and can even raise on a
  partial install — both would misroute or escape the error-message branch.

- The `_SectionNeedsSdk` remediation is no longer hard-coded to OAuth. The
  signal now carries the section's `auth_type`, and the resolver only suggests
  `databricks auth login` for `auth_type = databricks-cli` (OAuth-U2M). Other
  SDK-only auth types (azure-cli, metadata-service, oauth-m2m, …) get neutral
  wording naming the actual auth_type. The profile is now described as
  "token-less ... that only the databricks-sdk can resolve" rather than
  unconditionally "OAuth".

Adds a test for the non-databricks-cli branch (azure-cli) asserting the
message names the auth_type and does not misdirect to `databricks auth login`.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 16:21:27 +08:00
Pat Sukprasert 72b1ce6e9b refactor(cli): extract native TUI subcommands into cli_native.py (#3047)
* refactor(cli): extract native TUI subcommands into cli_native.py

Phase 0 of making native harnesses pluggable: carve the 11 native
coding-agent subcommands (claude, codex, opencode, pi, cursor, kiro,
goose, hermes, antigravity, qwen, kimi) out of cli.py into a dedicated
cli_native.py so the follow-up registry-driven seam lands in a small,
focused module instead of a 14k-line file. Behavior-preserving.

- New omnigent/cli_common.py holds the decorator-time constants
  (RESUME_PICKER_SENTINEL, CLAUDE_STARTUP_PROFILE_ENV_VAR) and
  reject_native_on_windows. It is a leaf module (imports nothing from
  omnigent.cli), so both cli.py and cli_native.py can import it without a
  cycle — required because Click evaluates command decorators at import
  time.
- omnigent/cli_native.py exposes register_native_commands(cli), which
  cli.py calls at module bottom (after the group and shared launch
  helpers exist). Command bodies reach shared cli.py helpers through thin
  call-time proxies on the omnigent.cli module, which keeps this module
  free of a top-level omnigent.cli import (no cycle) and lets tests that
  monkeypatch omnigent.cli.<helper> still take effect.
- polly/debby (bundled example agents, not native TUIs) stay in cli.py,
  along with the shared helpers they and the native commands use.

Also drafts designs/harness-modular-registry-proposal.md (the doc the
harness_plugins.py comment already references), which lays out the full
NativeHarnessProvider plan and the phasing this commit begins.

Test plan: tests/cli/test_cli.py (244), test_chat.py/test_import.py/
test_runner_startup.py (137) all pass; ruff format+check and the
pre-commit file hooks pass; `omnigent <tool> --help` renders for all 11.

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

* refactor(cli): extract config/onboarding subsystem into cli_config.py

Gets cli.py under the 10k-line-per-file budget (13,248 → 9,664). The native
subcommand extraction alone left cli.py well over budget, so move the second
large cohesive block: the interactive harness/credential configuration
subsystem behind `omnigent config` / `omnigent setup` and the first-run
`configure harnesses` picker.

- New omnigent/cli_config.py (~3,650 lines) holds the 63 config helpers:
  _configure_harness_add, every _manage_*_harness / _prompt_install_* / _set_*,
  the ambient-credential adoption path, node-dependency preflight, and
  _run_configure_harnesses_interactive. _CLI_LOGIN_BRAND moves with them (it had
  no other user). The config/setup/integration Click commands stay in cli.py.
- The 3 config-load helpers the block needs (_load_global_config /
  _save_global_config / _load_effective_config) stay in cli.py (used ~20x each
  there); cli_config reaches them through call-time proxies, so importing
  cli_config never imports omnigent.cli (no cycle) and monkeypatching
  omnigent.cli.<helper> is still honoured.
- cli.py re-imports the 7 config entry points its commands call, so they remain
  omnigent.cli attributes (patchable, importable) for callers and tests.
- Tests: repoint references for helpers that are called *intra*-cli_config to
  omnigent.cli_config (where patching now takes effect) — the _manage_* dispatch
  test, _adopt_detected_providers / _promote_global_auth_to_provider /
  _launch_*_configure / _qwen_auth_configured patches, and the opencode / promote
  imports. Helpers cli.py itself calls stay patched on omnigent.cli.

Behavior-preserving; no command, flag, or prompt changed.

Test plan: tests/cli/{test_cli,test_configure_models,test_opencode_setup,
test_chat,test_import,test_backend,test_runner_startup}.py all pass; ruff
format+check and pre-commit file hooks clean; cli.py is 9,664 lines.

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

* refactor(cli): address bot review on native/config extraction

Follow-ups from the PR #3047 bot reviews (Copilot, github-code-quality,
Polly), all behavior-preserving:

- cli_native.py: drop the duplicated --session/--resume validation block in
  the codex command (Copilot) — it validated twice; the single pre-backend
  check is kept, ordering unchanged.
- cli_native.py: fix the claude --host help text (Copilot) — the flag is a
  no-op (del register_host), so the old "Requires --server" help was
  misleading. Now marked [DEPRECATED] no-op.
- test_opencode_setup.py: use one import style for omnigent.cli_config
  (github-code-quality) — drop the `from ... import` line and qualify the
  two calls with the cli_config alias the file already uses.
- cli.py: drop the "(#334)" ticket id from the _run_bundled_agent comment
  (Polly / CLAUDE.md "no ticket IDs in comments").

Test plan: tests/cli/{test_opencode_setup,test_cli,test_configure_models}.py
(362) pass; ruff check + format clean; claude/codex --help render.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-22 14:51:11 +07:00
Jackson Zheng a509d2145f Generate session titles in background (#3024)
* Generate session titles in background

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Restrict background title harnesses

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Document background title rollout

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Explain minimal Codex configuration

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Require explicit background title opt-in

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-22 00:20:15 -07:00
Serena Ruan d1ca873783 feat(projects): session→project membership over HTTP (Phase 1b) (#3053)
* feat(projects): session→project membership over HTTP (Phase 1b)

Completes Phase 1 of the projects feature (see designs/PROJECTS_PRD.md) by
linking sessions to first-class projects and exposing it over HTTP. Phase 1a
(#2765) shipped the empty container; this adds the membership pointer and the
move/list surfaces that read it, so no column or store method ships unused.

- Migration c2d3e4f5a6b7 (chained after b1c2d3e4f5a6): nullable project_id
  (Uuid16) on omnigent_conversation_metadata + ix_conversation_metadata_project_id.
  Additive, no backfill, no DB FK (Rule R032). NULL = unfiled.
- Conversation.project_id on the entity; mapped in _to_conversation.
- ConversationStore.set_conversation_project() (file/move/unfile by id).
- list_conversations(project=<name>) is now a name-based dual-read: a session
  is "in <name>" if it has EITHER the first-class membership (metadata.project_id
  → the owner's project of that name) OR the legacy omni_project label. "" =
  unfiled. Backward-compatible: with no first-class members the filter collapses
  to the prior label-only behaviour. The first-class prefetch is intersected
  with the caller's permission-scoped ids so the IN/NOT IN list can't grow past
  their own sessions.
- PATCH /v1/sessions/{id} files/unfiles by id (owner-only; target-project
  ownership validated → 404, no existence leak); GET /v1/sessions?project=<name>
  lists owner-scoped; project_id surfaced on SessionResponse / SessionListItem;
  project_store wired into the sessions router; openapi.json regenerated.
- Tests: store membership ops + dual-read (incl. unfiled + cross-DB split-DB);
  route move/unfile/list with single- and multi-user ownership boundaries.

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

* fix(projects): reject null project_id; push unfiled exclusion down in single-DB

Addresses review on #3053:

- PATCH /v1/sessions/{id}: an explicit JSON ``null`` for project_id used to
  coerce to "" and silently unfile the session, contradicting the contract
  (omit = unchanged, "" = unfile). Reject null with 400 so only "" unfiles.
- list_conversations(project=""): in single-DB mode (metadata colocated with
  conversations) push the first-class exclusion down as a NOT IN subquery
  instead of materializing every filed id into Python. Split-DB keeps the
  bounded prefetch. Caps memory for single-user / unscoped callers.

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

* fix(projects): unfile-path 404 parity, single-DB IN subquery, doc null vs omit

Addresses the second review pass on #3053:

- PATCH /v1/sessions/{id}: the unfile branch (project_id == "") ignored
  set_conversation_project()'s return, so unfiling a session with no metadata
  row reported 200 while the file path returns 404. Check the result and raise
  404 for parity.
- list_conversations(project=<name>): mirror the unfiled-branch optimization —
  in single-DB mode use the member SELECT as an IN subquery instead of
  materializing member ids into Python; split-DB keeps the bounded prefetch.
- UpdateSessionRequest.project_id docstring: distinguish omit (unchanged) vs
  null (rejected 400) vs "" (unfile); regenerate openapi.json.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 14:50:37 +08:00
Serena Ruan a4623a23eb ci: only run coverage-report when all pytest shards pass (#3049)
The coverage-report job used `!cancelled()`, so it ran even when one or
more pytest shards failed. A failed shard drops its covered lines from the
`coverage combine`, so the resulting total is computed off partial data and
compared against main's baseline — misleading. A red pytest run gets re-run
anyway, which re-triggers coverage, so there's no value in computing it now.

Gate on `success()` so coverage-report only runs when every pytest shard is
green. The draft guard stays: on drafts pytest is skipped, and a skipped
dependency doesn't make `success()` false.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 13:20:22 +08:00
Serena Ruan 8a598ed059 feat(projects): first-class projects entity + CRUD (Stage 1) (#2765)
* feat(projects): first-class projects entity + CRUD container

Promote "projects" from the implicit ``omni_project`` conversation label to a
first-class, owner-private container that groups sessions and exists
independently of its members — so it can be empty, renamed, and (later) carry
its own config. See designs/PROJECTS_PRD.md.

This is Phase 1a — the container only: create / list / rename / delete empty
projects. Session->project membership (the conversation_metadata.project_id
column, conversation-store plumbing, dual-read listing) and the session-move
HTTP surfaces are Phase 1b (a follow-up), so this PR ships no column or store
method that nothing consumes yet.

- projects table (SqlProject): Uuid16 id, name, owner_user_id, created_at,
  updated_at. ix_projects_owner_user_id (workspace_id, owner_user_id,
  created_at, id) serves the owner-scoped list ordered by created_at as a pure
  index scan; UNIQUE (workspace_id, owner_user_id, name) enforces per-owner
  name uniqueness at the DB layer for non-NULL owners (the store's _name_taken
  check guards NULL-owner / single-user rows).
- Migration b1c2d3e4f5a6 creates the table only; additive, no backfill,
  no DB foreign keys (Rule R032).
- Project entity; ProjectStore + SqlAlchemyProjectStore (owner-scoped CRUD;
  IntegrityError -> ALREADY_EXISTS as the uniqueness-race backstop).
- POST/GET/PATCH/DELETE /v1/projects, owner-scoped; wired into create_app +
  CLI; schemas + openapi.json regenerated.
- Tests: store CRUD + owner isolation + name uniqueness (incl. DB backstop);
  route CRUD (single- + multi-user header auth); entity.

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

* fix(projects): discriminate name-UNIQUE violation before mapping to ALREADY_EXISTS

The create()/update() IntegrityError handlers translated *any* integrity
failure into an ALREADY_EXISTS name collision, which could hide unrelated
problems (a PK collision on id, a NOT NULL violation) behind a misleading
409/"already exists". Add _is_name_conflict() to translate only when the
per-owner name-UNIQUE index was hit and re-raise everything else. It matches
both dialect signatures: Postgres names the index (ix_projects_name), SQLite
lists the columns (projects.name).

Also add a regression test proving a non-name integrity failure (PK reuse)
re-raises as IntegrityError, and tidy the list-order assertion to a set
membership check.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 13:02:01 +08:00
Serena Ruan f36543958a fix(dictation): shield stream close from disconnect cancellation (#3048)
An abrupt browser disconnect tears the dictation WebSocket's ASGI task
down via cancellation. The cleanup in the finally block awaited
handle.close() inside the already-cancelled scope, so the cancellation
fired at the await before the close ran — leaking the take. For the
remote engine this leaks a worker capacity slot until the connection
dies. contextlib.suppress(Exception) did not help: anyio cancellation is
a BaseException, and suppressing it only hides the traceback while the
close is still skipped.

Wrap the close in a shielded anyio.CancelScope so cleanup always
completes before the outer cancellation resumes.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-22 12:52:16 +08:00
Sabhya Chhabria fda35701f8 feat(import): Add OpenCode chat imports (#3046)
- Discover and export sessions through OpenCode public CLI contracts
- Preserve ordered messages, files, tool calls, and tool results
- Cover single, batch, schema-drift, and live-server import paths

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-21 21:52:08 -07:00
Sabhya Chhabria 24831901e7 feat: import Qwen, Kiro, Pi, and Kimi chats (#3032)
* 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>
2026-07-21 20:41:13 -07:00
Cathy Yin 9b9d331964 feat(server): install a missing harness onto a connected host from the UI (backend, flag-gated) (#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>
2026-07-22 10:34:13 +07:00
Serena Ruan b221bb9f2e fix(web): don't queue messages while only background work is running (#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>
2026-07-22 11:07:52 +08:00
Tomu Hirata 8ee18e9535 perf(permission-store): eliminate N+1 queries in reassign_user_grants (#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>
2026-07-22 11:53:05 +09:00
Tomu Hirata 8adf530912 perf(store): batch FTS inserts in append and fork_conversation (#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>
2026-07-22 11:52:33 +09:00
Sabhya Chhabria a1c6608b7c fix(runner): fail closed when tool policies fail to resolve (#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>
2026-07-21 18:18:08 -07:00
Kerry Chang a944270cc9 feat(dictation): remote worker engine for offloading speech-to-text (#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>
2026-07-21 17:54:58 -07:00
Rahul Ravindranathan de7cc8df16 feat(scheduled tasks): run-completion tracking + run-history endpoint (#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 + #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 / #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>
2026-07-21 17:34:53 -07:00
Sabhya Chhabria 886f9d43dd fix(sessions): avoid GeneratorExit on SSE stream disconnect cleanup (#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>
2026-07-21 14:57:15 -07:00
John Bonewitz 336d1fb6e2 feat: server-side streaming dictation for the composer mic button (#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>
2026-07-21 14:41:19 -07:00
Sabhya Chhabria 7443647015 fix(runner): count async tools, timers, and approvals as active work (#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>
2026-07-21 14:39:32 -07:00
Sabhya Chhabria 91d2439046 fix(runner): reject non-object tool arguments in execute_tool (#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>
2026-07-21 14:38:56 -07:00
Sabhya Chhabria 5260339c84 fix(async-inbox): use handle_id as the canonical sys_call_async identifier (#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>
2026-07-21 14:38:19 -07:00
Sabhya Chhabria d344ac9474 fix(sessions): serialize explicit /compact per session (#2585)
_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>
2026-07-21 14:37:48 -07:00
Sabhya Chhabria 2af7e2aa57 📝 docs: Remove accidental Codex screenshot (#3031)
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-21 14:08:53 -07:00
Sabhya Chhabria a063839ecf [codex] Surface native subagents in Agents UI (#3028)
* 🐛 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>
2026-07-21 13:51:23 -07:00
Aravind Segu a19b08c751 perf(db): drop conversations title-unique index + title_hash column (#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>
2026-07-21 13:44:57 -07:00
Daniel Lok b51b3f126d feat(runner): log the exit reason on every hookable shutdown path (#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
2026-07-21 22:51:03 +08:00
Tomu Hirata 7fae3e4853 perf(permission-store): eliminate N+1 queries in reassign_user_grants
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>
2026-07-21 23:13:59 +09:00
Tomu Hirata 723d2cb249 perf(conv-store): batch FTS deletes in delete_conversation (#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>
2026-07-21 23:04:23 +09:00
Tomu Hirata a077eb6835 feat(session-ui): HTTP headers support for MCP servers in session UI (#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>
2026-07-21 11:08:09 +00:00
Daniel Lok 77d0908c30 perf(runtime): bump default idle-reap window to 1 hour (#2986)
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
2026-07-21 09:32:06 +00:00
simtsc 8d15110478 perf(web): lazy-load Shiki so it leaves the main bundle (#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>
2026-07-21 17:11:06 +08:00
Zeyi (Rice) Fan f35726a9b9 fix(ci): format desktop update test (#2982)
## 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>
2026-07-21 02:03:55 -07:00
Rahul Ravindranathan 5ff4c9d2b8 feat(scheduled tasks): make workspace/host optional on create (#2946)
* feat(scheduled tasks): make workspace/host optional on create

Many scheduled tasks do no code work — research, summaries, chat-only —
so requiring a workspace and a connected host at create time is wrong.
Make both optional on CREATE. No schema/migration change: the DB columns
are already nullable.

- routes/scheduled_tasks.py: CreateScheduledTaskRequest.workspace and
  host_id become optional (still reject empty strings). The router's
  _validate_launch_inputs skips connected-host workspace validation when
  BOTH are unset and returns a null canonical workspace; supplying just
  one of the pair is still an error. PATCH is unchanged — it still cannot
  null an already-set workspace/host_id.
- scheduled/fire.py: a fired task with neither host nor workspace creates
  a default/no-workspace session and seeds its prompt as the opening user
  turn (the no-host analog of the connected-host launch+dispatch), instead
  of recording a failed run. A task that pins a host_id (with or without a
  workspace) stays on the honest connected-host path and still records a
  skipped/failed run when that host is missing or offline.
- tools/builtins/scheduled_tasks.py: drop workspace/host_id from the
  sys_scheduled_task_create required list; they remain optional properties.

Normal POST /v1/sessions is unchanged — the shared session-create
validation and the sessions route still require a workspace.

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

* fix(scheduled tasks): resolve owner's live host when host unset (rework)

Rework of the optional-workspace/host semantics: an unset host_id no
longer means "run hostless" — it means "run on the owner's live host,
whichever it is". The prompt always runs on real compute.

- Unset host_id: resolve the owner's most-recently-active ONLINE host at
  fire time (host_store.list_hosts(owner) + host_registry; v1 first-online
  tiebreak). No online host, or no host store/registry, records a failed
  run (no_online_host / host_registry_unavailable) — never a silent no-op.
- Unset workspace: default to the host's HOME, canonicalized to an
  absolute realpath via a host.stat of '~' (_resolve_default_workspace).
  The stored conversation row never holds a literal '~'; an unresolvable
  HOME records a failed run (default_workspace_unresolved).
- Removed the hostless seed-prompt dispatch path; every fire goes through
  connected-host launch+dispatch. Resolution produces an effective task
  (dataclasses.replace) threaded through preflight/validate/create/dispatch
  and is never written back to the stored row.
- Pinned-host tasks are unchanged (offline still skipped/failed); the API
  partial-binding rejection and PATCH rules are unchanged.

Fixes two /review MAJOR findings from the rework:
- literal '~' persisted where an absolute realpath is contracted → now a
  canonical absolute path via host.stat.
- os_env.cwd boundary bypassed for a defaulted workspace → workspace
  validation is gated on the resolved effective.workspace, so a defaulted
  HOME outside a boundary-pinned agent records a failed run, matching
  POST /v1/sessions.

Tests: 101 passed across the scheduled fire/routes/tool-dispatch and
scheduler-lifespan suites; ruff clean.

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

* docs(scheduled tasks): correct optional host/workspace wording to resolve-live-host

Doc-only. The tool description, workspace/host_id schema property text,
and the route request comment + _validate_launch_inputs docstring still
described the pre-rework hostless design ('fires as a default/no-workspace
session', 'omit both for research/summaries/chat-only', 'needs neither a
workspace nor a connected host'). After the rework an unset host_id
RESOLVES the owner's online host at fire time (a failed run is recorded if
none is online) and an unset workspace defaults to that host's home dir —
it is not hostless. Reword the surface text to match. No logic change.

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

* feat(scheduled tasks): allow pinned host without workspace (default to host HOME)

Workspace is now ALWAYS optional. A task may pin a host but omit the
workspace — e.g. a task that only talks to an MCP (PagerDuty, etc.) needs
no code directory. The workspace defaults to the launch host's home
directory whether the host was pinned OR resolved from the owner's live
hosts at fire time.

The four combos:
- host none + workspace none → resolve owner's live host, default workspace to HOME.
- host set  + workspace set  → run there (workspace validated at create).
- host set  + workspace none → run on the pinned host, default workspace to HOME. (was 400; now allowed — the fix.)
- host none + workspace set  → still 400 (a path with no machine is meaningless).

- routes/scheduled_tasks.py _validate_launch_inputs: short-circuit to a
  null canonical workspace whenever workspace is None (host set or not),
  skipping validate_existing_host_workspace (which raises on a null
  workspace). Only workspace-without-host stays a 400. Agent + model/effort
  validation still run.
- scheduled/fire.py _resolve_effective_task: the HOME default already
  applies to a pinned host (host_id kept, workspace resolved to canonical
  HOME); docstring clarified that a pinned host is not re-resolved.
- tools/builtins/scheduled_tasks.py: tool + property text note workspace is
  always optional and a host may be pinned without one.

Shared _session_create_validation.py / sessions.py untouched — normal
POST /v1/sessions still requires a workspace.

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

* fix(scheduled tasks): check pinned-host ownership before stat RPC

When a task pinned host_id but omitted the workspace, _resolve_effective_task
issued a host.stat of '~' to the pinned host to derive the default workspace
BEFORE the ownership check (which lived in the preflight, run after
resolution). A task pinning another owner's online host would thus dispatch a
stat RPC to a host it doesn't own on every fire — the preflight then correctly
rejected it (host_not_owned, no session, path not leaked), but the RPC had
already gone out.

Reorder, not new validation: extract the existence + ownership check into a
shared _authorize_pinned_host helper (a local host_store.get_host read — no RPC
to the host) and call it for a PINNED host before _resolve_default_workspace.
The preflight reuses the same helper. A resolved host (host_id was unset) is by
construction the owner's own, so its path is unchanged and not double-checked.
Single-user / auth-disabled (owner_user_id None) behavior is unchanged — the
owner check is skipped, matching the preflight.

Net: for a pinned host, ownership is authorized before any RPC reaches it;
owned/valid hosts behave exactly as before.

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

* fix(scheduled tasks): authorize pinned host at create even when workspace omitted

_validate_launch_inputs returned early the moment workspace was None,
before any host authorization ran. So a scheduled-task create/PATCH with
host_id set but no workspace persisted the host_id without verifying the
caller owns it or that it exists (200), and a bad reference only surfaced
as a failed run at fire time.

Authorize a pinned host (existence + ownership) BEFORE the workspace-None
early return, reusing the same resolve_host_owner the workspace-present
branch already calls inside validate_existing_host_workspace (whose
semantics fire.py:_authorize_pinned_host mirrors) so create-time and
fire-time authorization cannot drift. It is a LOCAL store read only — no
host.stat / workspace RPC — preserving the no-workspace contract (workspace
defaults to host HOME at fire time). Single-user / auth-disabled mode still
skips the owner check (existence is still enforced), matching the fire path.

A nonexistent host now 404s and a non-owned host 403s at create; PATCH is
covered via the shared helper. Updates the test that asserted the old 200,
adds nonexistent/non-owned create cases and a PATCH-adds-host case, and
keeps the fire-path late-failure backstop tests.

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

* style: ruff-format test_desktop_update.py (whole-repo pre-commit gate)

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-21 02:00:54 -07:00
Aravind Segu 829fdd5174 refactor(db): unify session-owner identity columns to user_id (#2978)
Three tables stored the same session-owner Databricks identity under
different column names and widths. hosts.owner (VARCHAR(256)) and
scheduled_tasks.owner_user_id are renamed to user_id (VARCHAR(128)),
matching user_daily_cost.user_id and the schema-wide identity
convention (session_permissions.user_id, account_tokens.user_id,
device_grants.user_id).

The change is confined to the DB + Python layer: the JSON API keys
("owner", "owner_user_id") are preserved at the route boundary, so the
HTTP contract, OpenAPI, SDKs, and web UI are unaffected.

Migration b3c1a2d4e5f6 renames both columns (narrowing hosts.user_id
256->128), swaps uq_hosts_workspace_owner_name ->
uq_hosts_workspace_user_id_name and ix_scheduled_tasks_owner_user_id ->
ix_scheduled_tasks_user_id, with a full downgrade. Verified
up/down/data-preservation on SQLite.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-21 01:12:02 -07:00
Zeyi (Rice) Fan 34857abb47 fix(ci): install web dependencies with matching peer mode (#2980)
## Related issue

N/A

## Summary

The Electron build workflow could not install the web dependencies because it used strict peer resolution against a lockfile generated with legacy peer handling. Use `--legacy-peer-deps` consistently with the web lockfile generation and other web CI jobs.

## Test Plan

- `cd web && npx --yes --package npm@11.12.1 npm ci --legacy-peer-deps --no-audit --no-fund`
- `cd web && npm run build:overlay`
- `uv run pre-commit run --files .github/workflows/electron-build.yml`

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

## Coverage notes

Verified the install with CI's pinned npm 11.12.1 and built the update overlay successfully. This workflow-only correction does not require a new automated test.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-21 00:54:10 -07:00
Zeyi (Rice) Fan 2a8ff39251 feat(electron): shell-owned desktop update overlay + banner-safe web bridge (#2975)
## Related issue

N/A

## Summary

Desktop update UX is moved out of the server-rendered web bundle into the
Electron shell, so an update notification shows regardless of the connected
server's web-bundle version (an older server that predates the in-page banner
no longer leaves the desktop app unable to say it's out of date).

- Shell-owned overlay: a transparent, frameless child window (per shell window)
  renders the SAME `UpdateBanner` component (reused, not duplicated) built into
  `electron/overlay/` via a standalone Vite entry. It sizes to the card via
  ResizeObserver height reports and collapses to a 1px click-through sliver when
  empty (never `hide()`, so the renderer keeps laying out and can re-appear).
- Banner-safe server-page bridge: `preload.js` collapses
  available/downloaded/error-security to `idle`, so no web bundle — including
  older ones still mounting the in-page banner — can show a duplicate; Settings
  still reads/writes update prefs and surfaces check errors.
- Menus: "Check for Updates…" and "Restart to Update" (with native up-to-date /
  failed / nothing-ready dialogs) live under the production Server menu;
  notification sounds + DevTools fold into a dev-only Debug menu.
- Security: `forceDevUpdateConfig` is derived from `!app.isPackaged` (env var
  removed) so a packaged build can never be redirected to the HTTP dev feed.
- In-app theme is mirrored to `nativeTheme` (setColorScheme IPC) so the overlay,
  native dialogs, and menus follow the theme switcher, not just the OS.
- Feed: publish provider points at the omnigent.ai generic feed; the build
  workflow uploads `latest-linux.yml` / `latest.yml`. The overlay is built
  automatically before dev/packaging via `prebuild:*` hooks.

## Test Plan

- `npm test` in web/electron — 218 pass.
- `npx vitest run` for UpdateBanner / SettingsPage / settingsNav — pass.
- `npx tsc -b` clean; `npm run build:overlay` produces the island.
- Manual: ran the unpackaged app against a local fake feed (127.0.0.1:8765
  advertising 0.6.1); confirmed the overlay appears, re-appears across repeated
  checks (root-caused a hidden-window ResizeObserver stall and fixed it), the
  in-page top banner stays suppressed, and "Check for Updates…" shows the native
  up-to-date / failure dialogs.

## Demo

N/A — desktop overlay; verified manually (see Test Plan). No media captured in
this environment.

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

Unit tests cover the updater main-process wiring and the UpdateBanner states.
The windowed overlay (positioning, show/collapse, theme) was verified manually
against a local fake feed, since it can't be exercised headlessly.

## Changelog

Desktop update notifications now appear in a native corner toast that works
regardless of the connected server's version.

## Follow-up review fixes

- Overlay lifecycle: explicitly `destroy()` the child overlay when its parent
  shell window closes (Electron does not auto-close child windows, so it would
  otherwise be orphaned with live IPC handlers).
- Production install path: "Restart to Update" moved into the production Server
  menu (not just the dev-only Debug menu) so a user who dismisses the toast can
  still install a downloaded update; surfaces a native dialog when nothing is
  ready instead of silently no-op'ing.
- Overlay build: `publicDir: false` in the overlay Vite config so the ~150KB of
  PWA icons / favicon from `web/public/` are no longer copied into the shipped
  `electron/overlay/` bundle.
- Theme on reload: push the live `nativeTheme` theme on every
  `did-finish-load` (not just on `nativeTheme` changes), so Cmd+R on the overlay
  no longer reverts to the stale OS theme captured in the `?theme=` URL param.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-21 00:42:16 -07:00
Daniel Lok e3bc0fc702 perf(runner): Defer untracked cache setup (#2976)
- Move the optional filesystem probe off the runner startup path
- Deduplicate setup across processes and linked worktrees
- Keep runner and workspace registry initialization explicit and idempotent

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-21 15:32:01 +08:00
Tomu Hirata cc87a41300 deps(policies): migrate CEL evaluation from cel-expr-python to cel-python (#2970)
* 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>

* deps(policies): migrate CEL evaluation from cel-expr-python to cel-python

cel-expr-python had no wheels for Linux aarch64 or macOS x86_64, requiring
a platform conditional in pyproject.toml and graceful degradation. cel-python
(cloud-custodian/cel-python) is pure Python and ships on all platforms.

- Replace cel-expr-python with cel-python>=0.5 (unconditional dependency)
- Rewrite omnigent/policies/builtins/cel.py to use the celpy API:
  - celpy.Environment() + env.compile() + env.program() for compile phase
  - prog.evaluate({"event": celpy.json_to_cel(event)}) for eval phase
  - CELParseError / CELEvalError for specific exception handling
  - Direct MapType key lookup (key in result / result[key]) rather than
    converting the whole map to strings
- Remove platform restriction notes from deploy READMEs
- Update NOTICE attribution URL

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

* chore: update uv.lock and apply pre-commit fixes for cel-python migration

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-21 06:25:34 +00:00
Aravind Segu 044766e1e7 perf(db): drop the hosts token_hash unique constraint (#2971)
The managed-host launch-token auth path no longer needs a token_hash
index. The tunnel endpoint is /hosts/{host_id}/tunnel, so the connecting
peer already names the host it claims to be — resolve_launch_token now
seeks the row by the (workspace_id, host_id) primary key and compares the
stored digest to the presented token's digest with hmac.compare_digest
(constant-time, preserving the no-timing-oracle property).

Drops uq_hosts_token_hash (workspace_id, token_hash). Its uniqueness was
never load-bearing — launch tokens are 256-bit secrets.token_urlsafe(32)
values whose digests do not collide in practice — and nothing rides it now
that the lookup keys on the PK.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 22:54:26 -07:00
Daniel Lok 524d117161 fix(web): trust server session.status so "Working…" clears on idle (#2900)
* fix(web): trust server session.status so "Working…" clears on idle

The main chat's "Working…" indicator reads only `sessionStatus`, but the
`session.status` handler dropped a bare `idle` (no responseId) whenever an
`activeResponse` was still `streaming` — deferring to `response_end` to own
the lifecycle. `response_end` only sets the local `status`/`activeResponse`,
never `sessionStatus`, so when that guard fired nothing ever cleared the one
field the indicator reads. On a fresh session the first-turn wrapper-response
id mismatch leaves `activeResponse` stuck `streaming`, so the turn's genuine
terminal `idle` was eaten and the shimmer stayed lit even though the server,
sidebar, and local status all reported idle.

Remove the guard so `sessionStatus` tracks the server's session-level status
1:1. The idle heuristic now lives in exactly one place — the runner's
PTY-activity watcher — instead of being split between server and client. The
bubble lifecycle (`status`/`activeResponse`) still defers to `response_end`,
independently of the session-level status.

Co-authored-by: Isaac

* test(e2e-ui): cover Working indicator clearing on a bare server idle

The E2E UI gate requires a tests/e2e_ui/** test covering the visible chat
behavior this branch changes. Add a Playwright test that drives the exact
edge shape the claude-native PTY-activity watcher emits on a plain turn — a
turn-start `running` carrying a `response_id` (opening the streaming
`activeResponse`), then a trailing bare `idle` with no `response_id` — and
asserts the "Working…" indicator clears. This is the case the removed
dropped-idle guard covered; before the fix the indicator stayed lit forever.

Verified the test fails with the old guard restored and passes with the fix.

Co-authored-by: Isaac
2026-07-21 13:52:41 +08:00
Tomu Hirata 005e632987 fix(telemetry): track sdk harness name in SessionCreatedEvent (#2968)
* 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>

* style: reformat harness ternary in SessionCreatedEvent telemetry

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-21 05:37:03 +00:00
Aravind Segu fc5ab1cace fix(server): key HostRegistry by (workspace_id, host_id) (#2969)
The in-memory host registry keyed live connections by host_id alone,
but a host_id is only unique within a workspace — the hosts table PK is
(workspace_id, host_id). A BYO/local host has a stable config.yaml
host_id, so a user who belongs to multiple workspaces and points that
host at more than one presents the same host_id to each.

Keyed on host_id alone, the second workspace's connect treated the
first's healthy tunnel as stale: it evicted the entry (newest-wins) and
poisoned the first connection's outbound queue, so that workspace's host
operations then failed with "connection was replaced". Without host-
tunnel replica affinity, routing could also resolve the wrong
workspace's tunnel for the same host_id.

Key the registry by (workspace_id, host_id) to mirror the DB PK. The
workspace defaults to current_workspace_id() — 0 in single-tenant/OSS,
so behavior there is unchanged — and is captured into HostConnection at
register time so the long-lived sender loop's send_text guard never
reads request context. Every call site is already request-scoped, so no
call-site changes are needed; the change is contained to host_registry.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 22:34:55 -07:00
Aravind Segu d6b3bf1d26 perf(db): consolidate policies listing indexes; drop name unique key (#2961)
The `policies` table carried three overlapping secondary structures that
didn't pull their weight: `ix_policies_created_at` matched no query,
`ix_policies_session_id` and a scope-less listing left `list_defaults`
scanning every session row to find the handful of global policies, and a
`uq_policies_session_id_name_cksum` unique constraint that only enforced
session-name uniqueness (default-name uniqueness was already app-enforced).

Collapse the two listing indexes into one combined
`ix_policies_scope_session (workspace_id, scope, session_id, id)`. `scope`
leads `session_id` so `list_defaults` (WHERE ws + scope='default') seeks the
prefix and `list_for_session` (WHERE ws + scope='session' + session_id) seeks
the full key — `list_for_session` gains a `scope='session'` predicate so it can
reach `session_id` in the key (proven via EXPLAIN QUERY PLAN; without it the
planner table-scans). `created_at` is deliberately omitted: with `session_id`
between `scope` and `id` it cannot cover the `ORDER BY created_at, id` for both
queries, so both sort their small result set in memory (as the session listing
already did).

Drop the `uq_policies_session_id_name_cksum` unique constraint and enforce
session-name uniqueness in the store (`create`/`update`), mirroring the
existing default-policy path. The session-policy PATCH route now maps a rename
collision to 409. Net: one fewer index maintained per write, no DB constraint,
same seek performance on both reads.

Migration d4c1b9e6f3a2 (off a7f3c1b9e2d4).

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 22:13:05 -07:00
Daniel Lok 6441f3b312 perf(runner): Coalesce session initialization (#2793)
- Send versioned launch metadata with the session-init handshake
- Share initialization across tunnel callbacks and first-turn dispatch

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-21 12:56:37 +08:00
Zeyi (Rice) Fan dcaeaaf626 chore(electron): bump desktop shell to 0.6.0 (#2964)
Bump omnigent-desktop-electron from 0.3.0 to 0.6.0 in web/electron/package.json and package-lock.json. The shell reads its version dynamically via Electron's app.getVersion() (sourced from package.json#version), so no source, build-config, or updater changes are needed.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-20 21:34:20 -07:00
Zeyi (Rice) Fan e18d7e3dad docs(readme): restore Telemetry section, drop Configuration section (#2963)
The Telemetry disclosure section added in #2934 (5fd0012f) was accidentally
removed by #2933 (c555ba9c), which deleted it in the same diff that added the
Configuration section. Restore the Telemetry section verbatim between "Write
your own agent" and "Contributing", and remove the Configuration section.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-20 21:30:41 -07:00
Aravind Segu ec94437dc3 perf(db): fold conversation_id into the comments primary key (#2955)
Widen the comments PK from (workspace_id, id) to
(workspace_id, conversation_id, id) and drop the now-redundant
ix_comments_conversation_id index (workspace_id, conversation_id,
created_at, id).

The (workspace_id, conversation_id) prefix the secondary index shared
with the PK is now carried by the PK itself, so it backed the
per-conversation reads (list_for_conversation, the fingerprint
aggregate, the cascade delete) purely as write/space overhead. Its one
extra job -- feeding list_for_conversation's ORDER BY created_at, id an
index-ordered scan -- is given up for a filesort over the small
per-conversation comment set.

The three store point-lookups (get/update_comment/delete) already
receive conversation_id, so they now key on the full PK tuple instead of
fetching by (workspace_id, id) and filtering conversation_id in Python;
the lookup itself enforces the conversation scoping.

Migration a7f3c1b9e2d4 (off z9a2b3c4d5e6) is a pure key change:
conversation_id is already NOT NULL and populated, so no backfill.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 21:24:50 -07:00
Aravind Segu fd3f64c328 perf(db): drop the unused ix_files_created_at index (#2954)
`ix_files_created_at` on `files` (workspace_id, created_at, id) only served
a session-less listing (WHERE workspace_id ORDER BY created_at, id), and
nothing issues that query. Every read of a session's files goes through
`FileStore.list(session_id=...)` — the agent `list_files` tool (in-process
and runner-proxied over GET /v1/sessions/{id}/resources/files) and the
session-resources route — all of which filter by session_id and are served
by `ix_files_session_id_created_at`. Global (session_id IS NULL) files are
only surfaced via the `include_unscoped` OR query, which also rides the
session-scoped index.

Since the global listing had no caller, `FileStore.list` now requires
`session_id` (the `session_id=None` branch that produced the unindexed
query is removed), and migration c3e8f1a9d2b7 drops the index.
`ix_files_session_id_created_at` is unchanged.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 20:54:36 -07:00
Zeyi (Rice) Fan d82d0aa230 Add rahulrav1 to maintainers (#2957)
Add rahulrav1 to the canonical maintainer roster in .github/MAINTAINER. This grants merge-approval, the skip-security-scan waiver, and e2e-approved permissions per the existing workflows.
2026-07-20 20:46:04 -07:00
Daniel Lok c02ca4b815 fix(benchmarks): make the perf harness resilient to HTTP failures (#2917)
A journey's setup ran unwrapped inside run_latency/run_throughput, so a
transient 500 there (e.g. _setup_target_session's raise_for_status) propagated
up and aborted the whole benchmark suite mid-run. Separately, a run in which
every operation failed contributed all-zero latencies to the summary averages,
so a failed run masqueraded as an infinitely fast one and skewed the reported
numbers toward zero.

- journeys.py: catch setup failures and record them as a single failed run
  (`setup: HTTP 500`); suppress teardown failures; unify per-op failure
  classification in `_failure_reason`.
- measure.py: aggregate() and check_thresholds() average only runs with a
  successful sample; summaries gain runs_total/runs_ok and omit metric keys
  when every run failed. print_results matches and notes excluded runs.
- run.py: outer per-journey safety net — any other unexpected error records a
  `skipped` block and the suite continues. A no-successful-sample journey fails
  the CI gate only when a threshold was supplied.
- compare.py: report skipped/all-failed journeys as `skipped` rather than a
  spurious -100% improvement.
- schema.py: bump SCHEMA_VERSION 3 -> 4; update sample_output.json + README.

Co-authored-by: Isaac
2026-07-21 11:38:13 +08:00
Aravind Segu 8be06064a1 test(cli): assert crash-report version against VERSION, not a hardcoded string (#2956)
test_build_report_contains_required_fields pinned the expected version line
to "omnigent 0.6.0.dev0". The 0.7.0.dev0 bump (#2950) left it stale, so the
misc pytest shard fails on main and every branch cut from it. Assert against
`omnigent.version.VERSION` so the check tracks the real version and does not
break on future bumps.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 20:33:00 -07:00
Daniel Lok f70085da2f [auth] Reuse delegated credentials in host runners (#2762)
*  perf(auth): Reuse delegated runner credentials

- Exchange host launch binding tokens for short-lived owner bearers before resolving user credentials.\n- Share runner auth with Claude and refresh hook snapshots without exposing the binding token.

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* ♻️ refactor(auth): Address review feedback

- Avoid logging bridge paths and collect cancelled refresh tasks explicitly.\n- Inject the refresh interval so tests use the existing direct import style.

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* fix runner auth fallback behind Apps proxy

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* perf(auth): bootstrap runners with host bearer

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* docs(api): regenerate OpenAPI schema

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

---------

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-21 11:14:01 +08:00
Zeyi (Rice) Fan dc64952419 perf(benchmark): bulk-insert the SQLite seed corpus in one transaction (#2947)
dev/benchmarks/omnigent/seed.py seeded the benchmark corpus through the
production store ORM API one row at a time (~2M single-row INSERTs, ~20k
commits, each preceded by throwaway PRAGMAs on session open), taking ~6-10
min on CI. The benchmark only measures the store read path, so the write
strategy does not taint what's measured provided the resulting corpus is the
same shape.

Add a SQLAlchemy Core bulk-insert fast path (_seed_via_core) that writes the
whole corpus in one transaction via ~10 batched executemany flushes (1 commit
instead of ~20k). It uses the ORM Table objects so Uuid16 binds bare-hex to
16 bytes byte-identically to the store, computes title_hash explicitly
(Python defaults don't fire under executemany, and sets all kind/status
columns explicitly. The schema at head carries no FK constraints (migration
p1a2b3c4d5e6 dropped them all), so insert order is free under
PRAGMA foreign_keys=ON.

Dialect-gated: SQLite uses the fast path; every other dialect (e.g. the
nightly Postgres benchmark) falls back to the existing store-API loop
(_seed_via_store), extracted verbatim, so behavior there stays identical.

Byte-stable: same RNG seed/counts/_FRAGMENTS, same generate_*_id calls, same
per-session draw order (title first, then items), same 0-based position
allocation, same label stamped on the last session, same _meta_value config
string. Item data/search_text are built byte-identical to
MessageData.model_dump(exclude_none=True) + extract_search_text (the slow
path keeps _make_items as the single source of truth). The fast path item
build bypasses pydantic (building plain dicts) to keep the 1M-item Python
phase cheap; a byte-stability test pins both paths to identical corpora.

Idempotency preserved: the reuse-skip check, --reseed, and --print-head work
unchanged; ensure_user(local) and the seed-meta label upsert are mirrored
via sqlite_insert.on_conflict_do_*.

Target: ~20-30s end-to-end (was ~6-10 min) for the 5000x200 corpus; measured
~27s locally. Scope: seed.py + a new test file only; no product store/db code
under omnigent/stores/ or omnigent/db/ touched.
EOF
)
2026-07-20 18:46:34 -07:00
omnigent-ci[bot] 943bafe342 Bump version to 0.7.0.dev0 (#2950)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 18:45:42 -07:00
lilly-luo de79983d01 feat(routing): server-side smart routing via external routes:select gateway (#2864)
* feat(routing): server-side smart routing via external routes:select gateway

Adds a GatewayRoutingClient that implements the existing RoutingClient
protocol by calling an external routes:select gateway (the Databricks
AI-Gateway routing service, or any endpoint speaking the
omnigent.api.routing.v1 proto). Because every frontend — CLI, web UI,
SDK, the native-harness forwarders, and child sessions — already routes
through the server's route_turn() chokepoint, swapping the routing
client covers all of them with no per-client code and no web changes.

Server config selects between two mutually-exclusive providers via a new
routing: block (gated on OMNIGENT_SMART_ROUTING=1 as before):
  routing:
    provider: gateway          # or "llm" (default, existing built-in judge)
    base_url: https://<host>/ai-gateway/routing/v1
    router_name: task_v0
    profile: <databricks-profile>   # optional; mints a bearer for the gateway host

Candidate models come from the server's live catalog (the same
available_models the built-in judge receives), mapped to proto
route_options; the SelectRouteResponse maps back to a RoutingResult.
Requests use snake_case proto3-JSON (preserving_proto_field_name=True).
A gateway error or empty selection returns None so the turn proceeds on
the agent's default model.

Routing is gated per-session by the existing cost_control_mode_override
switch (the web UI's "Intelligent model" toggle). The CLI had no way to
set it, so this adds a /route on|off slash command (and the SDK
set_cost_control_mode + Session.cost_control_mode_override plumbing it
needs); turning routing on clears any pinned /model override in the same
PATCH, matching the web client.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): rename GatewayRoutingClient to ExternalRoutingClient

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): drop CLI /route toggle; keep ExternalRoutingClient for parity

Tables the CLI-side cost-control enablement (the /route slash command and
its SDK set_cost_control_mode / Session.cost_control_mode_override
plumbing). Scope is now feature parity with today's routing: the server
can route via an external routes:select gateway (ExternalRoutingClient +
routing: config), gated per-session by the existing
cost_control_mode_override switch that the web UI toggle already sets.
Enabling routing from the CLI can come later.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): add ROUTES_SELECT_PATH constant; provider "external"

- Extract the "routes:select" custom-method path to a ROUTES_SELECT_PATH
  constant in smart_routing.py.
- Rename the config provider value "gateway" -> "external" (routing.provider:
  external) and update prose/logs to say "external"/"router" instead of
  "gateway" (the Databricks AI-Gateway product name and its URL path are
  kept where they refer to the real endpoint).

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): split _build_routing_client into per-provider helpers

_build_routing_client is now a thin dispatcher on routing.provider,
delegating to _build_external_routing_client and
_build_local_llm_routing_client. Behavior unchanged.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): inline provider dispatch; drop _build_routing_client

The provider selection (routing.provider -> external vs llm) now lives
inline at the server startup call site, calling
_build_external_routing_client / _build_local_llm_routing_client
directly. Behavior unchanged.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): simplify provider dispatch at startup

Collapse the provider-selection block to a single condition: an
``external`` provider requires ``routing.provider == "external"``;
anything else (no block, other/missing provider) falls through to the
built-in llm judge, preserving the OMNIGENT_SMART_ROUTING + llm: parity.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): flatten external routing-client config parsing

Normalize base_url/router_name/profile with (x or "").strip() up front so
the validation collapses to plain `if not base_url or not router_name`.
Drop the dead isinstance(dict) guard (the caller guarantees a dict) and
its now-invalid test.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* test(routing): merge redundant missing-field cases into one test

base_url and router_name are validated by a single condition now, so
fold the two separate missing-field tests into one.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* feat(routing): config-driven model_prefix + log gateway error bodies

ExternalRoutingClient now round-trips model ids through a per-request
router_id -> local_id map: it applies an optional, config-declared
model_prefix (routing.model_prefix, default empty) to strip a
deployment's catalog prefix on the way out and restore the exact catalog
id on the router's answer. No provider is hardcoded in core — an
unconfigured deployment sends catalog ids verbatim, so OSS/non-Databricks
setups (bare model ids) work unchanged. A Databricks workspace whose
serving endpoints are named "databricks-<model>" sets
model_prefix: databricks- to match a router (e.g. task_v0) that keys on
bare ids.

Also split routes:select error handling so the gateway's response body
is logged on 4xx/5xx (the actual reason, e.g. task_v0's required-model
error) instead of a bare status code, and surface transport/parse
failures at warning level.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* feat(routing): add provider-agnostic routing.api_key auth option

routing.profile is Databricks-specific. Mirror the llm: block by adding
an env-expandable routing.api_key: an explicit bearer token (${ENV}
expanded) that takes precedence over profile, else the Databricks profile
convenience, else unauthenticated. Non-Databricks deployments can now
authenticate an external router without a Databricks CLI profile.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* refactor(routing): use click.echo for config warnings, drop lone _logger

Match cli.py's house style (click.echo(..., err=True)) for the two
routing-config warnings instead of introducing the file's only
logging.getLogger. Behavior unchanged.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

* feat(routing): multi-prefix model map + validate router pick against candidates

Address review feedback on external routes:select routing:

- model_prefix accepts a list (or scalar) so multiple catalog prefixes
  (databricks-, system.ai.) can be stripped; first match wins.
- key the router-id -> local-id map on (harness, router_id) so the same
  bare model id served under different harnesses (Databricks-authed PI vs
  a Codex subscription) maps back to distinct local ids.
- validate the router's returned model against the candidate set we sent,
  like the built-in judge: an out-of-set pick returns None instead of being
  persisted as the session's model_override.

Co-authored-by: Isaac
Signed-off-by: Lilly <lilly.gray@tecton.ai>

---------

Signed-off-by: Lilly <lilly.gray@tecton.ai>
Co-authored-by: Lilly <lilly.gray@tecton.ai>
2026-07-21 01:45:06 +00:00
Zeyi (Rice) Fan 2a2dfcd120 fix(release): stop release-workflow self-poisoning and benchmark failures (#2945)
Three release-workflow bugs that blocked the 0.6.0rc1 release. Real CI on
the base commit was green in all cases — the failures were self-inflicted.

1. Assert-green-CI gate self-poisoning. The gate queried the base SHA's
   check-runs and failed on any non-green run, but counted check-runs produced
   by THIS workflow (plan, benchmark, cut, bump-main, …). A single premature
   failure on a prior dispatch left a failure conclusion on the SHA and
   poisoned every later dispatch in a self-sustaining loop.

   Fix: exclude every check-run belonging to a release.yml run (identified by
   workflow run ID in details_url, not by job name — so a real nightly
   `benchmark` regression from a different workflow still gates). One-shot
   fail-fast design preserved.

2. benchmark ModuleNotFoundError. The benchmark job's first `uv run --no-sync`
   ran seed.py before any `uv sync`, so the venv had no deps and `import yaml`
   died. The sync was buried later, too late for the seed steps.

   Fix: add one `uv sync --extra dev` up front (the "sync once" half of the
   repo's existing --no-sync pattern), matching benchmark.yml/benchmark-pr.yml.

3. Baseline benchmark fails across schema boundary. The baseline step checked
   out the previous release tag and booted its server against a bench.db seeded
   by the current (newer) code. The DB was at the newer Alembic head; the older
   server didn't know that revision (migrations are forward-only) → server
   died → 90s health-check timeout.

   Fix: seed at the OLDER release's schema head instead. The baseline (older
   code) reads it natively; the candidate (newer code) auto-migrates it forward
   on startup. Reordered the benchmark job: find the previous tag first, then
   seed + run baseline at the older schema, then re-sync and run the candidate
   (which migrates the same bench.db forward). Removed the seed cache (the cache
   key was scoped to the newer schema head, which no longer matches the seed
   point; the separate seed-perf PR will make seeding fast enough not to need it).

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-20 18:39:57 -07:00
Yuan Tang f2696fcbf6 docs(openshell): use uv instead of pip in install instructions (#2948) 2026-07-21 01:17:17 +00:00
Aravind Segu d83d13e2d3 refactor(db): store opaque policy/host text columns as CompressedText (#2939)
Convert the three remaining raw TEXT columns — policies.handler,
policies.factory_params, and hosts.configured_harnesses — to
CompressedText (a transparent zstd-compressed BLOB) so they satisfy the
no-TEXT/MEDIUMTEXT schema rule and stay 1:1 with the managed USM schema.

These columns hold opaque handler paths / machine-generated JSON and are
never used in a SQL predicate, so storing them as a compressed byte frame
is safe. The Python type stays `str`, so stores and callers are unaffected.

Migration z9a2b3c4d5e6 mirrors z4a2b3c4d5e6 (TEXT->LargeBinary on upgrade,
no backfill; downgrade decompresses each value then restores TEXT). Its
downgrade addresses each row by that table's real PK column — hosts keys
on host_id, not id.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 17:17:00 -07:00
Tim McDonnell 91d720ac3b fix(sandbox): detach managed BoxLite boxes (#2846)
Signed-off-by: Tim McDonnell <tj1627@gmail.com>
2026-07-20 16:37:13 -07:00
simtsc e19209e019 fix(repl): treat /model show|list|status|current as display, not a switch (#2888)
* fix(repl): treat /model show|list|status|current as display, not a switch (#2779)

Typing /model show (intending to display the current model) was parsed as
a switch to the literal model id 'show', persisting it as model_override and
breaking every subsequent turn with no UI way to recover. Route the display
keywords show/list/status/current to the same readout as bare /model instead
of setting an override.

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

* ♻️ refactor(repl): Simplify model command tests

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

---------

Signed-off-by: simtsc <2539637+simtsc@users.noreply.github.com>
Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-20 16:16:04 -07:00
Zeyi (Rice) Fan 2e20f72ffe Disable automatic session rename out of the box (#2944)
The automatic "auto-title" rename asks the model to call
sys_session_rename on the first turn of every fresh session — an extra
model round-trip that slows every new session. Gate it behind
OMNIGENT_SESSION_RENAME, defaulting to off, so the feature ships
disabled out of the box while keeping the implementation (tool
registration, dispatch, the auto-title endpoint) intact. The manual
"Rename" sidebar item is unaffected.

session_rename_instruction() and session_rename_allowed_tools() are the
single canonical gate both the Claude-native launcher and the shared
runner consult; returning None / () there suppresses the instruction
and empties the tool preapproval everywhere.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-20 23:01:20 +00:00
Aravind Segu e8e95e9363 perf(db): drop the unused ix_scheduled_tasks_state index (#2937)
ix_scheduled_tasks_state (workspace_id, state, created_at, id) on
scheduled_tasks does not earn its keep. Its per-workspace query shape --
WHERE workspace_id AND state ORDER BY created_at, id (list_active) -- has no
production caller; the scheduler reads active tasks exactly once at boot via
list_active_all_workspaces (WHERE state ORDER BY workspace_id, created_at,
id), which is a near-full scan regardless.

ix_scheduled_tasks_created_at (workspace_id, created_at, id) already serves
that boot read: scanning it yields the exact ORDER BY workspace_id,
created_at, id the query wants, with state applied as a residual filter. The
residual check is free here because the store selects whole rows (state is
already loaded), and scheduled_tasks is low-cardinality (a handful of tasks
per user, and delete is a hard delete so no deleted rows linger) -- nothing
meaningful to skip. So the index is pure write/space overhead.

The state column and its ck_scheduled_tasks_state check constraint are
unchanged -- only the index is removed. Index-only, no data change; DROP is
native on every dialect and the downgrade restores it.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 15:40:23 -07:00
Zeyi (Rice) Fan c905acb2ba fix(deps): restore memory extra as backwards-compat alias for hindsight (#2938)
In #2605 the `memory` optional-dependency extra was renamed to `hindsight`
without keeping the old name around, making `omnigent[memory]` / `--extra
memory` silently install a nonexistent extra. Re-add `memory` as an alias
extra pulling the same `hindsight-client` so existing install commands keep
working. Scheduled for removal in 0.70 (TODO).
2026-07-20 15:34:14 -07:00
Zeyi (Rice) Fan c555ba9cc5 feat(config): per-harness startup command/args overrides + OMNIGENT_*_PATH standardization (#2933)
Add a polymorphic `harness:` key in config.yaml — a scalar (legacy) or a
mapping with `default` plus per-harness `command`/`args` overrides. The
legacy scalar form still works and auto-migrates to the mapping form on the
next config write.

Harness binary-path precedence: `OMNIGENT_<NAME>_PATH` env var >
`harness.<id>.command` config > built-in default. `args` follow the same
precedence with config args as the base and CLI pass-through args appended.

Env-var standardization: `OMNIGENT_<NAME>_PATH` (base id, `-native` suffix
stripped) is the canonical per-binary override, unifying the headless
`HARNESS_*_PATH` and native `OMNIGENT_*_PATH` conventions into one namespaced
name. The env var keys off the underlying binary, not the harness id, so
`claude-sdk` (which runs the `claude` CLI) shares `OMNIGENT_CLAUDE_PATH` with
`claude-native`.

The legacy `HARNESS_<NAME>_PATH` (codex/pi/kimi/goose/qwen/hermes) is still
read as a deprecated fallback — a one-time runner-side log warning when it
provides the value, plus a terminal-visible CLI startup notice for
interactive invocations. Slated for removal in v0.8.0.

The pre-existing `omnigent claude --command` flag is deprecated (warns on
use, pointing to `OMNIGENT_CLAUDE_PATH`/config) and will be removed in a
future release. No other native command gained a `--command` flag —
override via env or config.

New module `omnigent/harness_startup_config.py` (leaf resolver, lazy-imports
the alias helper): `resolve_harness_config`, `resolve_harness_command`,
`resolve_harness_args`, `resolve_harness_path`, `config_harness_path_override`.
Config deep-merge of the `harness` mapping across global+local (per-harness
sub-keys). Write-side scalar→mapping migration with a one-time stderr notice.
`config set harness=<id>` deep-merges into existing overrides; `config list`
renders the default + notes overrides.

`args` wiring: the 11 native Click commands thread config args as the base
with CLI pass-through args appended (via `_resolve_harness_startup_args`).
The 7 env-resolver native commands (pi/cursor/kiro/goose/hermes/qwen/kimi)
thread `harness.<name>-native.command` config into `OMNIGENT_*_PATH` before
`_ensure_backend`. The 5 headless spawn-env builders (codex/pi/kimi/goose/qwen)
set `OMNIGENT_*_PATH` from config when ambient env is unset.

Signed-off-by: Zeyi Fan <zeyi.f@databricks.com>
2026-07-20 15:20:38 -07:00
Aravind Segu 09656cae98 perf(db): drop the unused ix_conversation_metadata_kind index (#2936)
ix_conversation_metadata_kind (workspace_id, kind, id) on
omnigent_conversation_metadata has no serving query. kind is fully
determined by parent_conversation_id nullness -- a child always has a
parent, a top-level session never does -- so list_conversations filters
kind on the AP conversations table (parent-nullness) and the sub-agent
roll-up (list_child_conversation_ids_by_parent) rides
idx_conversations_parent; neither reads the metadata kind column. kind is
also a 2-value column (kind IN (1, 2)), so a standalone index could never
be selective.

The kind column and its ck_conversation_metadata_kind check constraint are
unchanged -- only the index is removed.

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 15:02:43 -07:00
Zeyi (Rice) Fan 5fd0012fae docs(readme): add telemetry disclosure section (#2934)
## Related issue

N/A

## Summary

- Add a **Telemetry** section to the README disclosing that Omnigent collects
  anonymized usage data by default, with no sensitive or personally
  identifiable information.
- Link to the [Usage Telemetry](https://omnigent.ai/docs/deploy/telemetry)
  docs page for opt-out instructions, and note that managed-service users
  should consult their service agreement.

## Test Plan

- Previewed the rendered markdown locally; verified the section sits between
  "Write your own agent" and "Contributing" and the docs link points to
  https://omnigent.ai/docs/deploy/telemetry.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Docs-only change; verified by reading the rendered README diff.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-20 21:39:00 +00:00
Aravind Segu 6438d1f75f refactor(db): merge agent_configuration back into conversations (#2931)
Fold the 1-to-1 agent_configuration companion table back onto
conversations: agent_id returns as a first-class indexed column and the
four per-session overrides collapse into one nullable session_overrides
JSON blob (VARCHAR(512), NULL when the session uses all agent/spec
defaults).

The overrides were never filtered in SQL, so a blob loses no query
capability while dropping a table, an extra INSERT, the get_conversation
JOIN, and the paired-row repair/fork/delete plumbing. agent_id stays a
real indexed column (ix_conversations_agent_id) so the agent->conversation
reverse lookup and the agent_id / has_agent_id / agent_name list filters
stay index-backed.

- db_models: delete SqlAgentConfiguration; add agent_id + session_overrides
  to SqlConversation; restore ix_conversations_agent_id.
- conversation store: add _encode/_decode_session_overrides; rewire
  create/get/list/update/fork/switch/delete and the bulk reads onto the
  merged row; drop the JOIN, batch-fetch, and missing-row repair logic.
  Fix the id-collision -> ConversationAlreadyExistsError translation, which
  had relied on the agent_configuration INSERT failing first.
- agent store: session-id reverse lookup reads conversations.agent_id.
- migration b7e4d2c9a1f3: reversible; ids are normalised to bytes in Python
  so the copy is correct on SQLite/Postgres/MySQL regardless of the source
  column's declared type (the split created it VARCHAR; conversations stores
  ids as raw bytes).

Reverses bb2c3d4e5f6a.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 20:55:16 +00:00
Constantin-Tiberiu Craiu a35773f71a fix(codex): bridge global agent instructions (#2809)
* fix(codex): bridge global agent instructions

Signed-off-by: Constantin-Tiberiu Craiu <57532657+craiuconstantintiberiu@users.noreply.github.com>

* Update codex_executor.py docstring

Signed-off-by: Constantin-Tiberiu Craiu <57532657+craiuconstantintiberiu@users.noreply.github.com>

---------

Signed-off-by: Constantin-Tiberiu Craiu <57532657+craiuconstantintiberiu@users.noreply.github.com>
2026-07-20 13:30:55 -07:00
Aravind Segu 971b19999c perf(db): make the conversation_items position index plain (drop UNIQUE) (#2930)
ix_conversation_items_conversation_id_position was UNIQUE on (workspace_id, conversation_id, position, created_at). The created_at tail only existed because a UNIQUE index must contain the partition key, and with it in the key the DB no longer enforced position uniqueness anyway (only per epoch-second). Strict position uniqueness is owned by the next_position allocator under _lock_conversation, which never reuses a position; no code path catches a position IntegrityError.

So the UNIQUE flag is redundant. Repoint the index to a plain (workspace_id, conversation_id, position): same access path for the dominant per-conversation position-ordered scan, one less uniqueness probe on the hot insert path, and created_at drops out (a non-unique index needs no partition key). The PK still carries created_at, so the table stays partition-ready.

Migration c7d2e9f4a1b8; index-only, no data change. Updates the three tests that asserted the old unique/created_at shape.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 20:05:18 +00:00
Rahul Ravindranathan 624216a78d feat(scheduled tasks): wire scheduler fire path (#2720)
* feat(scheduled): real on_fire fire path + wire store into entrypoints

Replace the no-op _placeholder_on_fire with a real fire path
(omnigent/server/scheduled/fire.py): on firing, re-read the row (skip if
missing/non-active), create an owner-granted session bound to the task's
agent, launch its connected-host runner, dispatch the prompt, and record
the run — all fire-and-forget via asyncio.create_task so the scheduler
timer re-arms immediately. managed_sandbox targets are recorded as a
skipped run for now (connected_host only in v1).

Wire SqlAlchemyScheduledTaskStore into all three entrypoints (cli.py,
deploy/databricks, deploy/docker) so the scheduler actually starts.

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

* feat(scheduled): add /v1/scheduled-tasks CRUD routes

Owner-scoped CRUD for scheduled tasks (create/list/get/update/delete),
mirroring the hosts router. Create/update validate the RRULE via
validate_rrule (400 on invalid); every mutation keeps the live
ScheduledTaskScheduler in sync via add/update/remove. Mounted under /v1
whenever a scheduled_task_store is configured.

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

* feat(scheduled): add sys_scheduled_task_* MCP tools

Four agent-facing builtins — create/list/update/delete scheduled tasks —
always registered by ToolManager (no spec opt-in, like the policy tools).
The runner dispatches each to the /v1/scheduled-tasks REST endpoints via
server_client; RRULE validation and owner scoping stay server-side. Added
to the local-dispatch and native-relay tool sets so native harnesses see
them too.

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

* style(scheduled): ruff lint + format cleanup

Sort imports, drop unused imports, dict-literal, de-Yoda a condition,
wrap long tool-schema descriptions, and drop redundant None defaults —
no behavior change.

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

* test: allow scheduled task tools in manager schemas

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

* Tighten scheduled task fire v1 scope

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

* Trigger CI rerun

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

* fix(scheduled): timezone validation, remove unused FireDeps.agent_store, fix _grant_owner docstring

- Validate IANA timezone on POST /v1/scheduled-tasks and PATCH
  /v1/scheduled-tasks/{id}; an unrecognized timezone name returns HTTP 400.
- Remove FireDeps.agent_store: the field was declared but never read inside
  fire.py. Updated the FireDeps constructor in app.py and test_fire.py.
- Correct _grant_owner docstring: permission_store=None is a no-op (auth
  disabled), not a grant — the previous wording claimed the grant was never
  skipped, directly contradicting the early-return on line 281.
- Add integration tests for invalid timezone on create and update.

Signed-off-by: Rahul Ravindranathan <rahul.ravindranathan@databricks.com>
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Fix scheduled task validation and failure runs

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

* Preserve scheduled workspace validation comments

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

* Preserve session metadata validation comments

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

* Remove scheduled fire v1 wording

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

* Fix scheduled fire races and scoping

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

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
Signed-off-by: Rahul Ravindranathan <rahul.ravindranathan@databricks.com>
2026-07-20 12:35:53 -07:00
Aravind Segu 62064caef7 perf(db): index conversation title uniqueness by a 16-byte hash (#2928)
The per-parent child-title unique index keyed on the wide title column (a 512-char prefix on MySQL, ~2 KB per entry on utf8mb4). Add a title_hash column holding sha256(title)[:16] and repoint the index at it, so entries are a fixed 16 bytes. The index keeps its name so the store's IntegrityError to NameAlreadyExistsError translation still matches; semantics are unchanged (two titles collide iff their 128-bit digests do, and only among siblings under one parent).

The ORM default stamps title_hash on INSERT and the store recomputes it on the two rename paths; the column is nullable so raw-SQL inserts that bypass the ORM default don't have to supply it. Migration a2b7c3d8e4f9 adds the column, backfills existing rows (keyset-batched Python, since SQLite has no sha256), and swaps the index.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 19:13:45 +00:00
Aravind Segu 001193cfc4 perf(db): drop the redundant conversations created_at/updated_at indexes (#2924)
The two bare (workspace_id, <ts>, id) sort indexes on conversations are never the chosen access path: the sessions list is ACL-scoped (id IN (...)) and resolves via the PK, the default sidebar (archived=false, updated_at DESC) is served by ix_conversations_archived_updated, and sub-agent/root listings use their own indexes. Meanwhile updated_at is rewritten on every item append, so the index is pure write amplification.

Migration f4a1c8b2d3e6 drops both; downgrade recreates them.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-20 18:35:18 +00:00
Kunyu Chen 2c628b8a69 slack integration refactoring, user experience improvements and documentation (#2850) 2026-07-20 10:56:43 -07:00
Sabhya Chhabria 01f1db3df4 [host] Refresh harness readiness without reconnect (#2828)
* 🐛 fix(host): Refresh harness readiness live

- Publish change-only readiness updates from connected hosts
- Persist updates and notify web and desktop host queries

* 🐛 fix(host): Harden readiness refresh contract

- Cover full-refresh and unchanged-map timer paths
- Centralize readiness states and reject partial or empty live maps

---------

Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-20 06:12:18 -07:00
Serena Ruan 4ac6e0f08e fix(ci): point feature-blog footer to releases page (#2913)
The feature-blog post footer linked "download the latest release" to
https://omnigent.ai/download, which is not a valid page. Convert the link
to "check the latest release" pointing at https://omnigent.ai/releases.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-20 18:32:30 +08:00
Serena Ruan 908d3bec6a feat(ci): auto-assign the maintainer with most context on a feature blog (#2911)
* feat(ci): auto-assign the maintainer with most context on a feature blog

Mirror doc-sync's reviewer assignment, adapted for the multi-PR nature of a
feature blog: tally who merged the feature's contributing PRs (from pr_refs)
and request review from the most frequent merger — the maintainer with the
most context. Authors are the fallback (outside contributors may lack site
access; a maintainer always merges), bots and the CI identity are skipped.

The merger/author tally reuses the existing per-PR `gh` loop in Draft posts
(one extra `gh pr view --json mergedBy,author` per ref), writing the chosen
login to /tmp/reviewer_<idx>.txt. The Open-draft-PRs step @-mentions them in
the body (durable ping) and best-effort --add-reviewer/--add-assignee,
tolerating GitHub's 422 for non-collaborators.

Co-authored-by: Isaac

* fix(ci): write reviewer @-mention on the draft-PR update path too

Polly review: the force-push update path called assign_reviewer but never
refreshed the PR body, so an existing draft never got the durable @-mention.
Since --add-reviewer commonly 422s (the source-repo maintainer isn't an
omnigent-site collaborator), the mention is the only reliable ping — it must
land on both paths. Build the body once and `gh pr edit --body` it on update.

Also surface gh-pr-view failures in the merger tally with a ::notice:: instead
of swallowing them silently, so a systematic API failure isn't invisible.

Co-authored-by: Isaac
2026-07-20 18:22:11 +08:00
Serena Ruan 6078e844de feat(ci): auto-generate a hero image for each feature-blog post (#2908)
* feat(ci): auto-generate a hero image for each feature-blog post

The drafter now emits an IMAGE_PROMPT line describing a concrete visual scene
for the feature (subject only, grounded in the post content, no style words).
The workflow appends a fixed brand style suffix, calls the image model on the
same gateway host (databricks-gemini-3-pro-image), writes the PNG to
public/images/blog/<slug>.png, and rewrites heroArt to point at it.

- Content-driven: the subject comes from the feature the drafter just wrote
  about, so every hero depicts that feature (not a generic mascot).
- Fail-soft: any error (no gateway/key, bad response, non-PNG) logs a warning
  and leaves heroArt blank, so image generation never blocks a draft.
- No new secret: the image endpoint is derived from GATEWAY_BASE_URL's host and
  authed with LLM_API_KEY, both already in the step env.
- Hero art / byline drop from the mandatory-human checklist to review-only.

Co-authored-by: Isaac

* fix(ci): scope gateway URL to image step, guard heroArt rewrite

Address Polly review on the hero-image change:

- Scope GATEWAY_BASE_URL to the image-generation Python invocation only,
  instead of the whole Draft posts step. The unsandboxed drafter run no longer
  inherits it, so it can't reach the drafter's stdout (which is embedded in the
  PR body and only scanned for LLM_API_KEY).
- If the post has no double-quoted `heroArt` field to rewrite, discard the
  generated PNG and warn, instead of committing an unreferenced image.

Confirmed omnigent-site's .gitignore only ignores /public/pagefind, so the
generated public/images/blog/<slug>.png commits normally.

Co-authored-by: Isaac

* fix(ci): sync draft-PR boilerplate with auto hero, harden slug path

Address Polly non-blocking notes:

- The "Open draft PRs" body still told reviewers to "add hero art, set the
  author byline" — now auto-generated. Reword to say the hero image and
  `author: omnigent` byline are generated and only need review, keeping the
  demo + voice pass as the human tasks.
- Re-validate slug as strict kebab-case at the point the hero PNG path is
  built (defense-in-depth; slug is already validated upstream but this is the
  one place it names a new file).

Left as-is per review: inline GATEWAY_BASE_URL expansion is intentional (env:
would re-expose it to the drafter run), and max_tokens on the image endpoint
is harmless.

Co-authored-by: Isaac
2026-07-20 17:17:46 +08:00
Serena Ruan 315b08d7fd perf(runtime): speed up changed-files git status on large repos (#2905)
* perf(runtime): speed up changed-files git status on large repos

The changed-files panel runs `git status --porcelain --untracked-files=all`
with a hardcoded 5s cap. On large repos that walk is slow and the panel fails
hard (HTTP 500 / git_status_failed) when it exceeds the cap. Three changes:

- Make the git-subprocess timeout configurable via
  OMNIGENT_GIT_STATUS_TIMEOUT_SECONDS and bump the default 5s -> 30s so slow
  (but not hung) repos get more headroom before erroring.
- Enable core.untrackedCache=true best-effort on registry init so
  `git status` stops re-stat'ing every untracked path (upstream git >= 2.8).
- Pass `:(exclude)` pathspecs for _SKIP_DIRS so git never walks large
  untracked build/cache trees (node_modules/, .venv/ ...) that we discard
  anyway; the root-level post-filter stays as a safety net.

Adds functional tests for the timeout knob, the skip-dir pathspecs, and the
untracked-cache init (including graceful degradation on config failure).

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

* perf(runtime): make untracked-cache config a one-shot per git-root

The host fallback path (server reading the host filesystem directly when the
runner is offline) builds a fresh WorkspaceReader — and thus a fresh
GitFilesystemRegistry — for every fs request, unlike the runner path which
caches registries per session. That meant the new core.untrackedCache config
write re-spawned a `git config` subprocess on every host changes/diff/list/
search request.

Guard the write with a process-global set keyed by git-root so it runs at most
once per root per process. Idempotent and thread-safe; adds a test asserting
repeated registry construction on the same root issues the config write once.

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

* perf(runtime): gate untracked-cache on git's --test-untracked-cache probe

Enabling core.untrackedCache unconditionally risks stale results on
filesystems with unreliable directory mtimes — a newly-untracked file could
then be missing from the changed-files panel. Git's own guidance is to run
`git update-index --test-untracked-cache` first, which exits non-zero on such
filesystems.

Gate the config write on that read-only probe: only enable the cache when the
probe passes. Failures anywhere still degrade silently (pure speedup). Adds a
test asserting the config is left unset when the probe fails.

Addresses a non-blocking review comment on #2905.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-20 16:58:25 +08:00
Serena Ruan 1cf7d0a004 fix(sessions): don't show runner_disconnected error on intentional stop (#2903)
* fix(sessions): don't show runner_disconnected error on intentional stop

Clicking "Stop session" in the web UI on a host-spawned session showed a
red "Error · runner_disconnected / Runner disconnected unexpectedly."
card even though the user stopped it on purpose. Stop deliberately tears
the runner's WS tunnel down (_stop_session_host_runner) so runner_online
flips false, which makes the SSE relay hit the same
except (httpx.HTTPError, ConnectionError) path a genuine runner death
takes. That block couldn't tell an intentional stop from a crash, so it
published a failed status with runner_disconnected and persisted durable
error labels that also polluted snapshots and child summaries.

Add a one-shot _intentional_stop_sessions marker set alongside the
existing _interrupt_fenced_sessions. The stop handler marks the session
right before tearing the tunnel down (host-spawned branch only), and the
relay's disconnect handler consults it: an intentional drop resolves to a
quiet idle with cleared error labels, while a genuine disconnect still
surfaces runner_disconnected as before. Safety-net discards on the next
running edge and on session delete keep a stale marker from swallowing a
later real disconnect.

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

* fix(sessions): clear intentional-stop marker on every relay exit path

Address a correctness regression flagged in review: the one-shot
_intentional_stop_sessions marker could outlive the turn that set it and
silently downgrade a LATER genuine runner disconnect to a quiet idle,
defeating the runner_disconnected surfacing the relay was built to
provide.

Two holes are fixed:

- The running-edge discard was nested under
  `if session_id in _interrupt_fenced_sessions`. A Stop typically emits a
  terminal response.cancelled first, which clears the fence, so the outer
  guard was false on every subsequent running edge and the marker could
  never be cleared there. Move the discard into the fence-independent
  session.status running branch so a new turn always clears it. The
  terminal branch is deliberately NOT used: on an intentional stop the
  terminal event arrives over the tunnel before the tunnel drops, so the
  marker must survive it to be consumed by the disconnect handler.

- A best-effort stop that never dropped the tunnel (host offline, ack
  timeout, host-reported failure) left the marker set with no disconnect
  to consume it. _stop_session_host_runner now returns whether teardown
  was actually delivered, and the stop handler discards the marker when it
  wasn't. A finally-block discard in the relay is added as a belt-and-
  suspenders clear for clean/cancelled exits.

Add test_relay_running_edge_clears_stale_intentional_stop_marker covering
the stop -> terminal event clears fence -> new running edge -> later
genuine disconnect sequence; it fails without the running-edge fix.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-20 16:52:23 +08:00
Brad Groux d906938854 fix: avoid shared e2e policy allow response (#508)
* fix: avoid shared e2e policy allow response

Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>

* chore: align e2e policy helper typing

Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>

---------

Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-20 08:46:32 +00:00
Serena Ruan 2d00b60abd fix(web): show busy spinner on new-session Send while create is in flight (#2907)
* fix(web): show busy spinner on new-session Send while create is in flight

The new-session landing screen awaits the full backend round-trip (session
bootstrap + git worktree setup) before navigating to /c/{id}. During that
multi-second window the Send button only went disabled with no other feedback,
so the click read as "frozen" — the typed message just sat in the composer and
users assumed nothing was sent.

Swap the Send button's static arrow for a spinning Loader2Icon while `creating`
is true, and add `aria-busy` + a "Starting session" label. The button was
already disabled via `canSubmit`, so this only adds the missing visual signal
that the click registered and work is in flight.

This is the perceived-latency fix; it doesn't change the actual backend timing.

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

* test(e2e_ui): cover the new-session Send busy spinner

Add a Playwright test that holds the create POST open with a gate so the
in-flight window is observable, then asserts the Send button flips to its busy
state (disabled + aria-busy="true" + "Starting session" label) while the create
is pending and the landing composer is still mounted, and that navigation runs
once the create resolves. Satisfies the E2E UI Required gate for the visible
submit-button behavior change.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-20 16:33:04 +08:00
Serena Ruan cecc8c9b4f test(e2e): de-flake custom-theme randomize color picker (#2902)
The randomize button lives inside a Radix PopoverContent that animates in
and is repositioned by Floating UI on mount. A click racing that enter
transition/reposition intermittently timed out with "element is not stable"
/ "detached from the DOM" on loaded CI runners.

Disable CSS animations/transitions on the page and wait for the popover to
fully mount (its hex input visible) before clicking randomize, so the click
lands on a settled node.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-20 16:03:24 +08:00
Nikhil Chakre 79fadd732e fix(cli): respect max_chars<=2 budget in _host_shorten (#2420)
* fix(cli): respect max_chars<=2 budget in _host_shorten

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>

*  test(cli): Assert width-two host shortening

Document the intended plain-slice behavior instead of spending half the display budget on an ellipsis.

Refs #2419

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-20 07:38:05 +00:00
David O'Keeffe fa3a420775 fix(opencode): carry user config model default into synthesized config (#2775)
Signed-off-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
Co-authored-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-20 07:36:41 +00:00
Bryan Li 559680a009 feat(sandbox): inject omnigent host config into managed sandboxes at launch (#2306)
Managed sandbox hosts boot in a fresh HOME with env-var credentials only,
so there was no way to give them config.yaml-level configuration — locking
provider-agnostic harnesses like pi out of self-hosted model gateways
(LiteLLM/vLLM) in managed sessions.

- New top-level `sandbox.host_config:` server config key — verbatim
  in-sandbox ~/.omnigent/config.yaml content (e.g. a providers: block with
  kind: gateway, default: [pi]), provider-agnostic across all managed
  launch providers.
- Validated fail-loud at server startup: mapping shape, providers block
  through the same provider_config parser omnigent itself uses (secrets
  deliberately not resolved — api_key_ref: env:VAR names sandbox env),
  inline api_key literals rejected at parse time, the block's own default
  scopes checked for collisions, plus a JSON round-trip so YAML-native
  values can't fail every launch at runtime.
- Materialized before `omnigent host` starts, from one shared rendering
  primitive so merge semantics can't drift between providers: exec-model
  providers run a self-contained python3 -c merge script (stdlib+yaml
  only) via the shared SandboxLauncher.start_host; kubernetes appends the
  same rendered command to its init-container prep script, landing the
  file on the HOME emptyDir before the main container boots the host.
- Merge mirrors cli.py's deep_merge_keys=("providers",): providers entries
  merge one level deep (injected wins), other top-level keys replace
  wholesale. The payload rides base64, so arbitrary YAML content never
  touches shell quoting.
- Server-managed replacement semantics: a marker file records what was
  injected, and each launch/resume removes those entries by name before
  merging the current payload — a renamed gateway or a removed host_config
  block cleans up on the next wake instead of stranding stale providers.
  User-created config in the sandbox survives; config and marker are
  written atomically. A missing or corrupt marker degrades to additive
  merging — never delete without evidence of what was injected.

Closes #2126

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:08:48 +00:00
Pat Sukprasert a0b23b1a80 test(e2e-ui): stabilize nightly journeys (#2896)
- Script isolated parent and child queues for multi-agent UI flows
- Route Codex mocks through /v1 and select approval actions exactly

Closes #1783

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-20 14:03:00 +08:00
Pat Sukprasert 4581b77164 fix(harness): resolve CLI binaries off the daemon's frozen PATH in readiness gates (#2805)
* fix(harness): resolve CLI binaries off the daemon's frozen PATH in readiness gates

Follow-up to the codex/claude resolver fix. The general readiness gates
still probed bare shutil.which(spec.binary), so a claude-native /
cursor-native / kiro-native / etc. CLI installed into an nvm/npm-managed
global bin dir (only on PATH via interactive shell init) could still be
reported 'binary missing' by the host daemon, whose PATH snapshot omits
that dir — the same split the codex fix closed for its own gate.

Route harness_cli_installed, missing_harness_cli, and the
harness_is_configured fallback gate through the shared resolve_cli_binary
(PATH -> global-dir ladder), so readiness matches what the launch will
see for every CLI harness. install_harness_cli keeps a bare shutil.which
check: it runs in the setup flow's own process, where the ~/.local/bin
PATH refresh (and the subsequent bare-binary login shell-outs) depend on
the binary being reachable via this process's PATH.

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

* refactor(harness): drop unreachable spec-None guards in install_harness_cli

Past harness_install_command(key), a spec-less key has already raised
KeyError, so spec is non-None — the 'if spec is not None' guards and the
trailing 'return False' were dead. Assert the invariant instead, per PR
review.

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

* test(harness): patch resolve_cli_binary, not readiness.shutil

The harness_is_configured fallback gate now resolves via resolve_cli_binary
(shutil was dropped from harness_readiness), so the community-harness
readiness test must patch that instead of the removed readiness.shutil.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-20 03:37:36 +00:00
Anthony Ivan 7da32637a5 Clarify parallel subagent title requirements (#2860) 2026-07-19 14:56:41 +09:00
leveragedloop e738ea7840 fix: derive sub-agent snapshot metadata from child spec (#2408)
* fix: derive sub-agent snapshot metadata from child spec

Signed-off-by: Thomas <thomas@Niv-Personal-Macbook.local>

* style: move session snapshot imports to module scope

Signed-off-by: Thomas <thomas@Niv-Personal-Macbook.local>

---------

Signed-off-by: Thomas <thomas@Niv-Personal-Macbook.local>
Co-authored-by: Thomas <thomas@Niv-Personal-Macbook.local>
2026-07-19 04:59:37 +00:00
Nikhil Chakre ef529843da fix(runner): clear in-flight marker on a live-turn context overflow (#2869)
A context overflow on a live (stream=true) turn raised
_ContextWindowOverflow uncaught, since only the background-turn path
caught it, so the process manager's in-flight marker never cleared and
the harness subprocess leaked forever.

Catch it inside proxy_stream() itself so both paths clean up the same
way. Adds a regression test confirmed to fail before this fix and pass
after.

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
2026-07-18 20:51:21 -07:00
Nikhil Chakre 4f2edef8a2 fix(runtime): drop parallel tool-call batches atomically in Layer-3 compaction (#2449) 2026-07-19 03:26:03 +00:00
Gautam Sharma 831fc957e9 fix: bound session stream subscriber queues (#2466) 2026-07-19 03:12:41 +00:00
dosenr 038bba66e4 fix(acp): make prompt timeout configurable (#2817)
* fix(acp): make prompt timeout configurable

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

* docs(acp): document HARNESS_ACP_PROMPT_TIMEOUT_S and tidy timeout code

Document the new prompt-timeout env var alongside the other HARNESS_ACP_*
vars in the acp_harness module docstring, its discoverability home. Hoist
the duplicated validation error string to a single _PROMPT_TIMEOUT_ERR
constant, and rework the timeout comments so each constant's comment sits
adjacent to it (the init-handshake timeout was left orphaned by the new
parsing block).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

---------

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
Co-authored-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-19 02:39:08 +00:00
Bryan Qiu 091fabf208 feat(web): gate sidebar row actions on ownership, not permission level (#2671)
* feat(web): gate sidebar row actions on ownership, not permission level

The session sidebar derived every row affordance (rename, share,
move-to-project, drag-to-file) and the My/Shared tab split from each
row's `permission_level`. That forced the server to resolve the
caller's effective grant for every listed session on each list build
and updates poll.

The sidebar only ever needs owner-vs-not, and every list row already
carries `owner`. Switch `isOwnedByViewer` to compare `owner` against
the resolved viewer id (permissive when owner is null — single-user /
legacy rows), and gate the row actions on ownership alone:

- Rename, Share, Move-to-project, and drag-to-file are now owner-only
  (Share was manage-gated, Rename/move/drag were edit-gated).
- Non-owners get a read-only row; finer-grained edit/manage affordances
  remain on the open-session view, which fetches the caller's real
  level via GET /v1/sessions/{id}.

`permission_level` is no longer read anywhere in the sidebar, so a
backend can list sessions without a per-session permission lookup.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(web): make sharing owner-only and null-safe on managed list rows

Two follow-ons to the owner-only sidebar, for backends whose session
list is owner-only and omits the caller's effective permission_level
(the Databricks-managed server):

- derivePermissionLevel no longer concludes from a sidebar row whose
  permission_level is null. That null is "level not carried", not the
  permissive null sentinel, so we skip the fast path and defer to the
  authoritative single-session snapshot / read-only fallback. A backend
  that keeps emitting a level on list rows (OSS default) is unchanged.

- The header Share affordance is now owner-only (isOwnerLevel of the
  derived level), matching the sidebar's owner-only Share gate and the
  terminal readOnly gate. Was manage-or-higher (>= 3).

- ChatPage's liveness row prefers the snapshot's permissionLevel over
  the sidebar row's, so host_offline's isOwner (who may reconnect the
  host) isn't decided by a null managed list level reading as permissive.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test(e2e): cover sidebar owner-vs-not row gating and tab placement

Adds the Playwright e2e coverage the E2E-UI-Required gate asks for on
this PR: the sidebar derives ownership (and every owner-only row action)
from the session's `owner`, not from an effective permission level.

Two flows on a dedicated multi-user server (the shared single-user
live_server hides the My/Shared tabs and the Share item, so the split
can't be observed there):

- Owner: session under "My sessions", kebab Rename + Share enabled,
  Rename opens the inline edit.
- Non-owner granted EDIT: session under "Shared with me" (absent from
  "My sessions"), kebab Rename + Share disabled — owner-only gating
  regardless of the granted level.

Test-only; no product code changes.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

---------

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-18 19:23:12 -07:00
Daniel Lok 126bac5c4e Revert "fix(claude-native): ack message delivery via hooks, not just the inpu…" (#2871)
This reverts commit f2dfe1c920.
2026-07-19 09:37:39 +08:00
Bryan Qiu afee478cff fix(web): stop double-prefixing the basename on query/hash paths (#2839)
In an embedded mount (basename e.g. `/omnigent`) the app matches absolute
paths, so `useLocation().pathname` already includes the basename. The
settings sidebar captures that location as the "Back to Omnigent" return
target — on the home page that's the bare basename plus the host's search,
`/omnigent?o=<workspace>`. The link then routes it back through
`rebasePath`, whose idempotency guard only treated `=== basename` and
`${basename}/` as "already under the basename".

`/omnigent?o=123` matches neither (the char after `/omnigent` is `?`, not
`/`), so it gets prefixed a second time → `/omnigent/omnigent?o=123`, which
404s. A conversation return path (`/omnigent/c/abc`) escaped the bug only
because it happens to start with `/omnigent/`.

Treat `/`, `?`, `#`, and end-of-string as the basename boundary, matching
the guard's documented "does not double-prefix a path already under the
basename" contract, while still rebasing a distinct sibling segment like
`/mounting`.

Adds regression coverage in routing.test.tsx for the query/hash boundary
forms (Link + rebasePath primitive) and the over-match guard.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-18 17:16:48 -07:00
Sabhya Chhabria 3fea7693cc 🔨 chore(repo): Remove Playwright output (#2861)
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-18 10:02:07 -07:00
Bryan Li e03c01a21a chore: remove accidentally committed local session notes (#2858) 2026-07-18 14:30:48 +00:00
Jackson Zheng f8b333b6ca Add automatic session titles (#2778)
* Add automatic session titles

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Document Codex title adapter boundary

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Centralize automatic title prompts

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Harden automatic title prompt gating

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Document framework instruction boundary

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Document framework-owned instructions

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Harden automatic session renaming

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Fix automatic title CI coverage

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(e2e): avoid flaky REPL ready marker

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-17 21:38:56 -07:00
Kunyu Chen a5b6241f2e Slack integration to support approval / elicitation flow (#2820)
Slack integration to support approval / elicitation flow
2026-07-18 02:47:13 +00:00
Sabhya Chhabria e2fbcd9488 🔨 chore(repo): Remove Playwright output (#2844)
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-17 18:55:39 -07:00
Zeyi (Rice) Fan a93c6246bb feat(cli): friendly crash handler with pre-filled GitHub issue filing (#2841)
Replace Python's raw wall-of-red traceback with a calm, branded crash
screen and a one-tap path to file a GitHub issue from the repo's
bug_report.yml template.

On crash: amber header, compact traceback (shortened paths, collapsed
library frames, first-party packages always visible), report path
next to the [Y/n] prompt. On yes: opens a pre-filled GitHub issue
(template, title, version, OS, traceback in Description). Clipboard
as backup. URL drops body if >8000 chars.

New: omnigent/crash_ui.py, omnigent/crash_handler.py,
tests/cli/test_crash_handler.py (21 tests).
Wired into omnigent/cli.py:main().

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 17:02:58 -07:00
Zeyi (Rice) Fan d52fd157dc docs: add finishing-task and deprecation guidance to AGENTS.md (#2836)
Add two new sections to the agent guidance:

- Finishing a task: agents should print explicit testing instructions
  (commands, inputs, reproduction steps) when completing a task so the
  user can verify the work without guessing.
- Deprecating features: record the target removal version in code (e.g.
  a @deprecated tag/comment naming the release) and in the PR/commit
  description, so the feature can be cleaned up when that version ships.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 16:36:36 -07:00
Matei Zaharia bc8c5008e7 (feat) Make the agent info panel appear on hover over the (i) button, not just on click #2736 (#2742)
* feat(web): open agent info panel on hover over the (i) icon (#2736)

The agent info popover (agent name, session cost, model usage, etc.)
only opened on click. Make it also open when the pointer hovers the (i)
icon and stay open while the pointer is on the icon or the panel — a
short close delay bridges the gap between them so it doesn't flicker
shut mid-move, and re-entering either side cancels the pending close.

Click and keyboard still toggle the panel, so touch devices (no
mouseenter) and keyboard users are unaffected. Hover-open suppresses
Radix's auto-focus into the panel (which would steal focus / scroll)
while click and keyboard opens keep it. The redundant "Agent tools &
policies" tooltip is hidden while the panel is open.

Co-authored-by: Isaac

* fix(web): gate agent-info hover-open to mouse pointers so taps still open (#2736)

In-browser testing (real Chrome via CDP) surfaced a touch regression the
unit tests missed: a tap synthesizes pointerenter + click, so the
mouseenter-based hover-open fired on the pointerenter and then Radix's
synthetic click toggled the panel straight back shut — a tap could never
open the panel.

Switch the hover wiring from onMouseEnter/Leave to onPointerEnter/Leave
gated on `pointerType === "mouse"`. Touch/pen now fall through to Radix's
native click-to-open, while mouse hover-open (with the stay-open bridge
and close delay) is unchanged. Verified end-to-end in a browser: hover
opens, moving onto the panel keeps it open, leaving both closes after
~150ms, click toggles, and a touch tap now opens the panel.

Add regression tests for the touch-tap-opens path and the
hover-then-click-closes path.

Co-authored-by: Isaac

* test(e2e-ui): cover agent-info popover hover interaction

Add a Playwright e2e under tests/e2e_ui for the agent-info (i) popover's
hover flow (issue #2736): hover opens the panel, the 150ms close-delay
bridge keeps it open when the pointer crosses from the icon onto the
panel, leaving both closes it after the delay, click toggles, and a
touch tap falls through to native click-to-open. The existing coverage
was component/unit only; this exercises the pointer-type gating and the
hover→panel bridge in a real browser.

Co-authored-by: Isaac

* test(e2e-ui): strengthen agent-info hover bridge + click coverage

Two test-quality fixes so the popover tests prove the behavior rather
than passing incidentally:

- Bridge test now walks the pointer down through the real vertical gap
  between the icon and the panel (computed from bounding boxes), dwelling
  in the empty space past a fraction of the close delay, then lands on the
  panel. A bridge-less (zero-delay) implementation closes the panel during
  the transit and fails the test — verified by temporarily setting
  HOVER_CLOSE_DELAY_MS=0.
- Click test now drives a real mouse pointer (hover + click) instead of
  dispatch_event("click"): on a mouse the pointer must move onto the icon
  first (hover-opens), so the meaningful click behavior is toggling the
  open panel shut and keeping it shut (no double-open). Click-to-open on a
  hover-less pointer stays covered by the touch-tap test.

Co-authored-by: Isaac

* fix(web): keep AgentInfo click-to-open reliable under the hover model

A mouse click's own pointer arrival hover-opens the panel (pointerenter →
setOpen(true)) before the click's Radix trigger toggle runs. On a slow render
the hover-open commits open=true first, so the controlled toggle reads true and
flips it back to false — the panel never opens. This regressed click-to-open
(and re-open after a modal dialog closes) on slow/CI machines, failing
test_agent_info_policy_add_and_remove.

Swallow an onOpenChange(false) that lands within a short grace window
(HOVER_CLICK_GRACE_MS) of a hover-open: those two events are one gesture, so the
close is the racy self-toggle, not a dismiss. A deliberate hover-then-click
dismiss dwells far past the window, so click-to-dismiss, the hover bridge, and
the touch-tap fix are all unchanged.

Co-authored-by: Isaac
2026-07-17 22:46:49 +00:00
Zeyi (Rice) Fan 6b765a54ff ci(android): add version-code input to bundle workflow (#2835)
## Related issue

N/A

## Summary

- Add a required `version-code` input to the `workflow_dispatch` trigger in the Android Bundle workflow. The value is passed to Gradle via `-PversionCode=N` and read in `build.gradle.kts` so each CI-built AAB gets a unique, Play-compatible `versionCode` without manual edits to the build file.

## Test Plan

- Verified locally: `./gradlew -PversionCode=99 assembleDebug` produces an APK with `versionCode='99'`.
- Verified fallback: `./gradlew assembleDebug` (no property) still defaults to `versionCode=2`.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Verified the Gradle property override produces the correct versionCode in the built APK via `aapt dump badging`.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 15:11:06 -07:00
Zeyi (Rice) Fan f9b2c737b4 ci(android): build unsigned release AAB in CI for local signing (#2830)
## Related issue

N/A

## Summary

- Add a `workflow_dispatch`-triggered GitHub Actions workflow that builds an unsigned release AAB (`./gradlew bundleRelease`) and uploads it as a workflow artifact. Download the artifact and sign it locally with the upload keystore — no secrets in CI, no signing key on GitHub.

## Test Plan

- Triggered the workflow manually on this branch; verified the build succeeds and the AAB artifact is produced.
- Verified `bundleRelease` produces an unsigned AAB when no keystore credentials are present (existing `build.gradle.kts` behavior).

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Triggered the workflow on the branch; confirmed the AAB is built and uploaded as an artifact.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 14:26:55 -07:00
Edwin He 2347ed45fd fix(web): hide "Create custom agent" on a managed sandbox (#2826)
* fix(web): disable "Create custom agent" on a managed sandbox

Selecting a managed sandbox as the target and then creating a custom
agent leaves the affordance offered but unsupported: the sandbox
provisions its runner from a baked image and has no create path for an
uploaded bundle. Gate the "Create custom agent" picker item on
`sandboxSelected` — when a sandbox is the target, render it disabled with
an explanatory tooltip (mirroring the disabled New-Sandbox row) instead
of opening the dialog. On a connected host it stays enabled and opens the
dialog as before.

Adds vitest coverage (disabled on sandbox, enabled on host) and a
Playwright e2e test under tests/e2e_ui/start_session.

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>

* fix(web): hide "Create custom agent" on a sandbox instead of disabling

Follow-up on the sandbox gating: rather than showing the "Create custom
agent" picker item disabled with a tooltip on a managed sandbox target,
omit it entirely. On a connected host it is shown and opens the dialog as
before. Tests updated to assert the item is absent on a sandbox and
present on a host.

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>

* fix(web): drop redundant sandboxSelected prop comment

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>

* fix(web): drop a selected pending custom agent on a sandbox target

Hiding the "Create custom agent" button stops a new pending agent from
being created on a sandbox, but a pending agent selected before switching
to a sandbox would still be submitted through the unsupported multipart
path. Gate the pending pick on `!sandboxSelected`: on a sandbox the
selection falls back to a real agent (`effectiveAgentId`) and the pending
row is hidden from the picker. Off the sandbox the pending pick is kept.

Adds vitest + Playwright e2e coverage for the host->sandbox deselection.

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>

* fix(web): drop redundant pendingAgent prop comment

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>

---------

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>
2026-07-17 14:07:26 -07:00
Zeyi (Rice) Fan 1ab4dda515 feat(android): floating server switcher pill with dropdown menu (#2829)
## Related issue

N/A

## Summary

- Add a floating server-switcher pill to the Android WebView shell, mirroring the iOS `ServerSwitcher`. The pill is always visible at the top center of the screen, shows the current server's host, and opens a dropdown menu with recent servers, Reload, and Connect to New Server — giving users a universal recovery path when the server is unreachable or a non-Omnigent page loads.
- Add an Android-specific scroll-fade gradient so the chat transcript fades smoothly into the pill area, starting at the pill's bottom edge. The fade offsets are driven by CSS variables (`--omnigent-android-switcher-margin/height`) so they stay in sync with the pill dimensions.
- Theme-aware pill styling via the app's brand color resources (light/dark).

## Test Plan

- `./gradlew :app:assembleDebug :app:lintDebug` — 0 lint errors, build succeeds.
- Manual: installed on a Pixel 9a via `adb install`, verified the pill renders with correct theme colors, the dropdown menu opens with recent servers and actions, switching servers reloads the bridge for the new origin, and the scroll-fade gradient appears below the pill.
- Verified the pill stays visible across page loads (always-visible default, backward compatible with older web builds).

## Demo

N/A — tested on physical device; screenshots taken via `adb screencap` during development.

## Type of change

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

## Test coverage

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

## Coverage notes

Manual verification on a Pixel 9a (API 35): confirmed pill rendering, theme-aware colors (light/dark), dropdown menu with group dividers, server switching via `reloadWithNewServer` (removes old bridge, re-registers for new origin), scroll-fade gradient position, and backward-compatible always-visible default. Existing Robolectric unit tests fail due to Maven Central network blocking (pre-existing, unrelated to this change).

## Changelog

Android app shows a floating server switcher pill with a dropdown menu for quick server switching

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 13:58:39 -07:00
Zeyi (Rice) Fan c4682ba50c feat(web): add QR code for opening a session in the mobile app (#2824)
* feat(web): add QR code for opening a session in the mobile app

The share dialog (PermissionsModal) gains an "Open in mobile app"
button next to "Copy link". Clicking it opens a separate modal with
a QR code encoding the session's
deep link — the same scheme the desktop shell's deep-link handler
parses (electron/src/deepLink.js). The QR sits on a fixed white tile
with error-correction level M so it stays scannable in dark mode.

- getDeepLink() derives the host (with port when non-default) from
  the same shareable URL getShareableLink() resolves, so standalone
  and embedded (host-transformed) origins agree on the same server.
- The QR modal is a sibling Dialog inside the share Dialog, so closing
  it returns the user to the share dialog rather than dismissing both.
- Tests pin host resolution for standalone origin, non-default port,
  and the embedded host-transform case.

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

* test(e2e_ui): add QR code modal test to permissions modal suite

Add a Playwright e2e test covering the new "Open in mobile app" QR code
flow in the share dialog: the button opens a second dialog with the QR
code, and closing it returns to the share modal.

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

---------

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 13:22:50 -07:00
Zeyi (Rice) Fan 143595d85e fix(electron): use public npm registry in lockfile to fix Windows CI (#2823)
The Electron Build workflow's Windows job failed at `npm ci` with
ETIMEDOUT because 5 packages in web/electron/package-lock.json had
`resolved` URLs pointing at npm-proxy.cloud.databricks.com — an
internal proxy unreachable from public GitHub Actions runners.

- Rewrite all 5 internal proxy URLs to registry.npmjs.org in
  web/electron/package-lock.json
- Add web/electron/.npmrc pinning the public registry so future
  `npm install` runs don't reintroduce internal proxy URLs
- Add scripts/normalize_package_lock_registry.py (fixer + --check mode),
  mirroring the existing normalize_uv_lock_registry.py for npm
- Wire normalize-package-lock-registry into .pre-commit-config.yaml for
  all three package-lock files (web, web/electron, editors/vscode)
- Add a pre-`npm ci` guard step in the workflow that uses the shared
  script to fail fast if internal registry URLs are detected
- Split Linux AppImage and .deb into separate downloadable artifacts

Signed-off-by: Zeyi Fan <zeyi.fan@databricks.com>
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-17 11:51:16 -07:00
Jackson Zheng c1d58c7bd9 fix(claude-sdk): compaction error when resuming sessions with attachments (#2784)
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-17 11:29:06 -07:00
Tomu Hirata 617e4b8995 feat(policies): show config-file policies in admin policy page (#2807)
* feat(policies): show config-file policies in admin policy page

Policies loaded from the server --config YAML (RuntimeCaps.default_policies)
were applied to every session but invisible in the admin UI, which only read
from the database. The GET /v1/policies response now appends them as read-only
entries tagged with source: "config".

The frontend renders them with a "Config" badge and omits the toggle/delete
controls, since they are managed via the config file rather than the admin UI.

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

* test(policies): cover config-file policies in GET /v1/policies

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-18 00:49:49 +09:00
Hubert 764afb6ed4 Revert "feat(web): cache chat transcripts for instant switch-back (#2688)" (#2810)
This reverts commit ebf8d432fe.

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-07-17 16:01:17 +02:00
Nikhil Chakre 372e22d701 test(runtime): add coverage for the model-change respawn happy path (#2755)
get_client's model-change branch (a concrete harness, different model requested for the same conversation, respawn) had no direct test coverage despite running in production via post_responses. Adds test_get_client_respawns_on_model_change, covering both the respawn-on-change case and the no-respawn-on-same-model case.

Follow-up to the discussion on #2226.

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
2026-07-17 13:25:58 +00:00
Jenny df4ca2a49e fix(web): wrap loose inline runs so markdown files with bare images open (#2729)
@tiptap/markdown (beta) can hand back a bare inline image with no wrapping
paragraph — a standalone image in document flow (blank lines around it, or
after ---) or an image-first list item (1. ![x](y)). The doc and listItem
content models are block+, which cannot hold a bare inline node, so the
parsed doc is schema-invalid; nodeFromJSON loads it without validating and
the first transaction (a user edit, or StarterKit's TrailingNode on load)
throws "Called contentMatchAt on a node with invalid content", crashing the
whole file panel ("Page failed to load") and leaving the conversation
bricked until the session is stopped.

This is the known residual documented in #2320 (which fixed block-FIRST
list items via block+ but could not cover bare INLINE children). Fix it the
way #2320's follow-up note prescribed: generalize #2004's toBlockContent
guard from blockquote-only to every block container, as a post-parse
normalization on MarkdownManager.parse (same runtime-patch pattern as the
existing serializer patch in tiptapMarkdownPatches.ts).

Verified against the real triggering file: pre-fix, its only schema
violation is the doc-level standalone image (its :::list-table nested lists
are already handled by #2320); post-fix the file loads, edits, and
round-trips.

Fixes the crash family of #2559 / #2004 / #2320.

Signed-off-by: Jenny <jenny.sun@databricks.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:18:01 +02:00
Arya Buddha ebf8d432fe feat(web): cache chat transcripts for instant switch-back (#2688)
Switching to a previously-viewed chat blanked the view and blocked on
two network fetches before rendering, every time — including switching
back to a chat opened seconds ago. Cache each conversation's rendered
transcript per client and paint it synchronously on switch-back, then
revalidate in the background: bindStream still refetches metadata and
history and reconciles by item id, so items committed while away still
land. In-flight live previews are never cached, the history cursor is
restored atomically so scroll-up paging keeps working, and the cache is
bounded by an LRU cap.
2026-07-17 15:12:52 +02:00
Serena Ruan f97e5cb637 fix(fs): forward per-file line counts on the host-served changed-files list (#2802)
The changed-files panel gained per-file +N/-M line counts, threaded from
the filesystem registry through the runner endpoint to the web UI. But
the changed-files list has a second server-side builder: when a session's
runner is offline and the host holding the workspace answers over the fs
tunnel, WorkspaceReader.changes() shapes its own entry dict — and it
dropped the new lines_added / lines_removed fields, so the counts silently
vanished whenever the list was host-served.

Forward both fields there too, matching the runner endpoint exactly. The
underlying registry already populates them (host and runner share
create_filesystem_registry), so this is purely payload parity.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-17 19:08:23 +08:00
Serena Ruan 4a3c0fad56 feat(ci): drafter emits BlogPostHeader instead of a plain H1 (#2804)
omnigent-site now renders each blog post's title + author + date + reading-time
byline via a <BlogPostHeader slug="..." /> component. Update the drafter prompt
so generated posts use it: export the `meta` object (title/date/category/
author/heroArt), render <BlogPostHeader slug="SLUG" /> as the first body
element, and never hand-write a `# H1` title (the component draws it, so an H1
would duplicate the title).

Co-authored-by: Isaac
2026-07-17 19:07:44 +08:00
Pat Sukprasert 18c782f1f1 fix(codex,claude): resolve CLI binary beyond the daemon's frozen PATH (#2788)
The host daemon snapshots PATH at spawn and never refreshes it, so a
codex or claude CLI installed into an nvm/npm-managed global bin dir
(only added to PATH by interactive shell init) is invisible to
shutil.which. Native Codex readiness then reports 'binary-missing' and
the claude-sdk executor can't find its system CLI — even though a
foreground launch works, because that runs in the interactive shell's
PATH.

Add a shared resolve_cli_binary(name, env_var) in _platform.py:
override env var -> PATH -> a ladder of common global install dirs
(~/.local/bin, /usr/local/bin, /opt/homebrew/bin, ~/.npm-global/bin).
Route _find_codex_cli (OMNIGENT_CODEX_PATH) and _find_system_claude
(OMNIGENT_CLAUDE_PATH) through it, and the codex readiness gate too, so
the readiness verdict and the actual launch can't disagree. Update the
codex binary-missing UI message and the ImportErrors to point at the
real fix (restart the host, or set the override) instead of 'omnigent
setup', which doesn't address a stale PATH snapshot.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-17 18:48:44 +08:00
Yi Lyu f2dfe1c920 fix(claude-native): ack message delivery via hooks, not just the input box (#2591)
* Add claude native delivery ack

* fix nit

* fix test
2026-07-17 18:45:00 +08:00
Serena Ruan c4f46e8656 chore(ci): update area owners in areas.json (#2801)
Update the reviewer/assignee owner list for the web area in
.github/areas.json.

Co-authored-by: Isaac
2026-07-17 18:39:09 +08:00
Serena Ruan 0998bd2c4e fix(ci): make feature-blog drafts read user-facing, not machine-generated (#2798)
* fix(ci): make feature-blog drafts read user-facing, not machine-generated

The first drafted posts leaked the prompt's skeleton labels as literal text
("Who it's for:", "The problem it solves"), buried the reader in
implementation detail (per-harness verification status, internal component
names, harness ids), and overused " — " dashes that read as AI-generated.

Rework the drafter prompt:
- The 5 items are the post's SHAPE, not headings or sentence lead-ins. Only the
  H1 title is a heading; everything else is flowing prose. Explicitly ban the
  label phrases as headings or sentence starts.
- Add a "Voice and content rules" section: write what the user can DO (not how
  it's built/verified); never list harness ids / component names / PR numbers /
  verification caveats — say "works with any agent you run in Omnigent"; cap the
  whole post at one dash; plain, active, no marketing adjectives.

Co-authored-by: Isaac

* feat(ci): surface drafted post body for dry-run review

A dry_run=true run opens no PR and the workflow didn't upload the drafted
page.mdx, so the actual post body was invisible — you could only see the
drafter's narration + summary. Copy each drafted post to /tmp/post_<i>.mdx
(added to the uploaded artifact) and render it into the job summary inside a
collapsible block, so the post can be reviewed on a dry run without opening a
PR. Also rename the upload step to reflect that it runs on success too.

Co-authored-by: Isaac

* fix(ci): find drafted post via -uall (untracked dir hid page.mdx)

`git status --porcelain` collapses a brand-new untracked directory to
"app/blog/<slug>/" and never names page.mdx inside it, so `grep page.mdx`
returned empty and `$post` was blank. That silently skipped everything guarded
on $post: the CTA footer, the HTML-comment guard, and the drafted-post
copy/summary — the post still committed via `git add -A`, so it looked fine.
Add -uall to both porcelain reads so individual new files are enumerated.

Co-authored-by: Isaac
2026-07-17 18:36:02 +08:00
Serena Ruan ade8ee9b5b chore(ci): stop the Reviewer SLA scheduled sweep (#2799)
Remove the daily weekday cron trigger from the Reviewer SLA workflow so it
no longer auto-pings reviewers, adds second reviewers, and labels open PRs
awaiting review. Keeps workflow_dispatch so the sweep can still be run
manually if needed.

Co-authored-by: Isaac
2026-07-17 18:19:28 +08:00
Anthony Ivan 3481126e26 feat(UI): Show per-file line-change counts in changed-files panel (#2526)
* Show per-file and total line-change counts in changed-files panel

Add +N/-M line-change counters beside the A/D/M badge for each file in the
changed-files panel, plus totals in the "Changed N" header. Line counts come
from git numstat, computed at the record source and threaded through the
runner API to the web UI (also used by desktop and iOS webview clients).
Binaries and non-git workspaces render no count. No backend consumer outside
the web UI.

* Refine changed-files line counts: right-align status, drop size and untracked/total stats

- Move the A/D/M status badge to the right of each row; left-align the
  filename with a muted parent-directory suffix.
- Remove the per-row file-size label from the changed-files list.
- Only surface line counts from `git diff HEAD` (numstat); untracked files
  no longer read off disk to count lines, matching VS Code / Cursor.
- Drop the +/- line totals from the "Changed" header pill.

Co-authored-by: Isaac

* Hoist git subprocess timeout into a shared _GIT_TIMEOUT_SECONDS constant

All four git calls backing the changed-files view shared a literal
timeout=5. Name it once so the cap can be tuned in a single place.

Co-authored-by: Isaac

* Hide the line-count badge for mode-only changes; clarify rename docstring

- A chmod-only edit surfaces in numstat as 0/0; suppress the "+0 −0" badge
  (it's noise) while still rendering a real deletion's −N.
- Clarify the _run_git_numstat docstring: with --no-renames a pure rename
  shows +N on the destination, not (None, None).

Co-authored-by: Isaac

---------

Co-authored-by: Anthony Ivan <anthony.ivan@example.com>
Co-authored-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-17 18:05:09 +08:00
Tomu Hirata 0b8cb87914 feat(benchmarks): track server CPU and memory usage in nightly benchmark (#2795)
Sample the omnigent server process's CPU% and RSS memory in a 1-second
background thread (BenchEnvironment._sample_resources via psutil) for the
full duration of each benchmark run. Summarise as mean/min/max/samples and
emit under a top-level 'resource_usage' key in the JSON report.

Schema bumped to version 3 so the workspace ETL can branch on it.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 09:57:10 +00:00
Abhay Singh ca261289b7 fix(inner): default the terminal pane to a UTF-8 locale for native TUI harnesses (#2440)
Native TUI CLIs that read LC_ALL / LANG directly (opencode, pi, hermes)
rather than calling POSIX setlocale render multibyte UTF-8 as mojibake when
the inherited env has an empty LANG and no LC_ALL (only a UTF-8 LC_CTYPE,
as in a minimal container). They fall back to an ASCII/Latin-1 codeset and
re-encode their own UTF-8 output byte-by-byte; because the corrupt bytes
are what the CLI physically writes to the tmux pane, the garbling shows up
in the raw terminal view too. CLIs that call setlocale (claude, codex) are
unaffected because glibc honors LC_CTYPE.

TerminalInstance.launch now forces LANG=LC_ALL=C.UTF-8 into the pane spawn
env when the inherited env carries no UTF-8 signal in the vars those CLIs
actually read. A UTF-8 LC_CTYPE alone is not treated as a signal (it does
not help them). Operator-provided UTF-8 locales are preserved; a pinned
non-UTF-8 LC_ALL is corrected; no-op on Windows (tmux panes are POSIX-only).
C.UTF-8 is used because it needs no locale archive and so is present on
minimal images where en_US.UTF-8 is not.

Helpers _is_utf8_locale_value / _has_utf8_locale / _apply_utf8_locale_default
are pure and unit-tested: codeset parsing, POSIX LC_ALL-over-LANG precedence,
the LC_CTYPE-only repro config, operator-locale preservation, non-UTF-8
LC_ALL correction, and the Windows no-op.

Closes #2427

Signed-off-by: abhay-codes07 <abhaysingh0293@gmail.com>
2026-07-17 09:48:07 +00:00
Nikhil Chakre 4004cf7a04 fix(sessions): stop running child sub-agents, not just the parent, before archive/delete (#2673)
* fix(sessions): stop running child sub-agents, not just the parent, before archive/delete

_best_effort_stop used the child-rollup status only to decide whether to act, then always issued the stop against the parent's own session id. A parent that had gone idle while a sub-agent child kept running got a no-op stop, and the child was then orphaned by the recursive subtree delete/archive (still running, but unreachable via the API).

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>

* fix(sessions): walk the full sub-agent tree, not just direct children

_best_effort_stop only checked one level of children, but delete_conversation's recursive subtree delete has no depth limit. A running grandchild (or deeper descendant) was invisible to the one-level check and stayed orphaned exactly like the original bug. Now walks the whole descendant tree level by level and stops every running/waiting descendant at any depth.

Addresses review feedback from TomeHirata on PR review.

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>

---------

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
2026-07-17 09:45:40 +00:00
Serena Ruan 4a92f358d1 feat(ci): list drafted blog PR links in the feature-blog job summary (#2797)
The Open-draft-PRs step created the PRs but only logged the already-open case
to the job summary, so a normal run left no clickable link to the drafts it
opened. Capture `gh pr create`'s stdout URL and write a "Draft blog PRs"
section with a markdown link per feature (both newly created and
force-push-updated existing drafts).

Co-authored-by: Isaac
2026-07-17 17:41:54 +08:00
Serena Ruan 50555b809d fix(ci): emit MDX comment for the demo marker, guard against HTML comments (#2794)
The drafter emitted the demo placeholder as an HTML comment
(`<!-- DEMO REQUIRED ... -->`), which is invalid in MDX — only `{/* ... */}`
works. It passed prettier's fmt:check but broke the site's `next build`
(page.mdx:36 "Unexpected character !"), so every generated blog PR failed CI.

- Change the drafter's demo marker to an MDX comment `{/* DEMO REQUIRED ... */}`
  and update the summary reference to match.
- Add a fail-fast guard in the workflow: if the drafted page.mdx contains any
  `<!--`, abort before opening the PR so we never ship a build-red PR again.

Co-authored-by: Isaac
2026-07-17 17:25:44 +08:00
Abedegno 9da9d2be8b fix(claude-native): bound subagent_delivery_not_confirmed 503 retries (L2) (#1471)
The forwarder's _PostRetryTracker exhausts only permanent 4xx failures
(_is_permanent_http_error = 400 <= status < 500); a 503 is treated as
transient and retried forever with backoff. The runner's
`subagent_delivery_not_confirmed` 503 -- a terminal sub-agent result that
could not be delivered to the parent inbox -- is usually a brief dispatch
race and should be retried, but when the parent host is gone the condition
is permanent, so unbounded retries let a single orphaned sub-agent flood
the shared server indefinitely.

Add `_is_subagent_delivery_not_confirmed()` (a 503 whose JSON body carries
error == "subagent_delivery_not_confirmed") and bound this class to
_SUBAGENT_DELIVERY_NOT_CONFIRMED_MAX_ATTEMPTS (12). The budget spans the
backoff schedule (capped at 30s) -- a few minutes, comfortably covering the
dispatch race -- after which the entry is dropped as exhausted (and
non-permanent, since the failure is environmental). Generic 5xx retry
behaviour is unchanged.

Signed-off-by: abedegno <jon@jonwilliams.org.uk>
2026-07-17 17:10:08 +08:00
Tomu Hirata e725156a14 fix(web): use correct query key when invalidating session items cache (#2790)
chatStore was invalidating ["conversation", convId, "items"] on turn
completion, but useSessionItems registers its cache under
["session", sessionId, "items", "raw"]. The key mismatch meant the
execution-logs panel's cache was never invalidated by SSE, so the
panel stayed stale after a turn ended and relied solely on its 3s
refetchInterval to show new items.

Import sessionItemsQueryKey from useSessionItems and use it in the
invalidateQueries call so the hook's cache is actually invalidated
when a session turn completes.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 18:07:47 +09:00
Serena Ruan 3028c61d21 feat(ci): let feature-blog dispatch pick how many posts to draft (#2792)
Add a `max_posts` workflow_dispatch input (default 3) so a manual run can ask
for more or fewer blog drafts. The guard step sanitizes it to a positive
integer, and the value is threaded into both the scout prompt (told to return
at most N, ranked) and the parse step's defensive cap (cands[:max_posts]),
replacing the hardcoded 3. The scout config's cap wording now defers to the
run-supplied limit. A real release cut (workflow_run) still uses the default.

Co-authored-by: Isaac
2026-07-17 17:07:32 +08:00
Serena Ruan 92387878c5 fix(ci): drafter writes only the post page, not blog infra (#2791)
The omnigent-site blog surface now exists on main (app/blog/ layout + index +
lib/blog.js scanner + nav link, from omnigent-site#334). The drafter must stop
scaffolding it — its runs were nondeterministic (one candidate invented the
whole layout/index/nav, others wrote only the post), producing incoherent,
merge-order-dependent PRs. Tighten the prompt so the drafter creates ONLY
app/blog/<SLUG>/page.mdx, reads existing posts + lib/blog.js read-only to match
conventions, and flags any missing infra under "Manual review needed" rather
than inventing site plumbing that can break the build.

Co-authored-by: Isaac
2026-07-17 16:55:42 +08:00
Serena Ruan 134b48412c fix(ci): prettier-format drafted blog files before committing (#2786)
The omnigent-site CI gates on `prettier --check .`, and LLM-generated MDX/JS
(plus the CTA footer the workflow appends) is rarely prettier-clean, so draft
PRs fail `fmt:check` on arrival. Run `prettier --write` on the drafter's
changed files from inside the site checkout — so it picks up the site's
.prettierrc.json + .prettierignore — before staging and committing. Pinned to
prettier@3 (the site's major). Non-fatal: a formatting failure logs a warning
and commits anyway, since these are human-reviewed draft PRs and CI still
reports residual issues.

Co-authored-by: Isaac
2026-07-17 16:18:47 +08:00
Tomu Hirata 4d6f42c9dc perf(policies): skip engine build in evaluate_policy when no policies apply (#2783)
Add any_policies_apply() to builder.py — a cheap check that returns False
when the combined policy list (session + agent guardrails + server defaults)
would be empty. Call it in POST /policies/evaluate after loading the agent
spec, returning POLICY_ACTION_ALLOW immediately when nothing would fire —
matching what the engine returns when all policies pass.

This avoids the engine build and its associated conversation-store reads
(labels, state, usage) on every tool call hook for sessions with no policies
configured — the common case. The session-policy check uses the existing
LRU cache so it's a cache hit after the first call per session. Mid-session
policy additions invalidate the cache immediately, so newly added policies
are visible on the very next evaluate call.

sys_add_policy TOOL_CALL events always bypass the fast path: the engine
unconditionally injects _ASK_ON_ADD_POLICY_SPEC to require human approval
before an agent can install session policies. Passing phase and tool_name
to any_policies_apply() ensures that gate is never skipped.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 08:13:16 +00:00
Tomu Hirata 09d02d9b15 fix(policies): thread turn-initiating created_by as policy actor via runner (#2771)
* fix(policies): thread turn-initiating created_by as policy actor via runner

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

* test(policies): verify runner-supplied actor overrides request identity at evaluate and MCP proxy

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

* fix(policies): stash turn actor server-side to prevent body-based spoofing

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

* fix(policies): bound _session_turn_actor with LRUCache; skip None on stash; fix test cleanup

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

* fix(host): silently refresh Databricks token on /v1/me 401 before failing

When omnigent-host.service starts in headless mode and the stored OIDC
token has expired, _ensure_databricks_server_auth probes /v1/me, gets
401, and immediately raises ClickException — crashing the daemon before
the tunnel is ever attempted.

Fix: before giving up, attempt a silent SDK token refresh via
_databricks_workspace_token (which calls _resolve_databricks_auth and
mints a fresh bearer from the cached OAuth grant). If the retry succeeds
(HTTP 200), return normally so the daemon continues to start. Only raise
the ClickException if the SDK has no valid grant either.

This is the root cause of the mass runner-stranding incident, where an
expired OAuth token caused 32+ crash-loop restarts of the host daemon,
killing all 48 runner processes simultaneously.

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

* fix(policies): persist turn actor to conversation labels for cross-replica safety

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

* style: ruff format sessions.py

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

* fix(policies): guard omnigent.turn_actor label against client writes; drop unrelated cli.py change

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

* fix(policies): guard omnigent.turn_actor on multipart bundle-create path; drop dead created_by runner body field

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

* refactor(policies): simplify turn-actor label guard; trim comment; drop redundant None check

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

* docs(policies): document turn-serialization gap and native-terminal bypass; restore None guard on mcp_conv

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 17:08:37 +09:00
Serena Ruan c1ab8fc038 fix(fork): drop CLI-specific launch args when a fork switches harness (#2780)
* fix(fork): drop CLI-specific launch args when a fork switches harness

Forking a Claude Code session onto pi failed to start with
`required_terminal_exited`. The fork copied the source's
`terminal_launch_args` verbatim, so `--permission-mode auto` (a Claude
Code flag) reached the pi argv; pi rejects the unknown option and exits 1
at launch, taking the required terminal — and the session — down with it.

Launch flags are CLI-specific and must not survive a cross-CLI switch:
- `fork_conversation` gains `copy_terminal_launch_args` (default True);
  the fork route passes `not switching_agent`, so a same-agent fork still
  inherits flags but an agent switch starts with clean args.
- `switch_conversation_agent` (in-place claude->pi switch, same latent
  bug) now clears `terminal_launch_args` alongside `external_session_id`.

Co-authored-by: Isaac

* test(fork): teach route-test fake store the copy_terminal_launch_args arg

The route fake's fork_conversation lacked the new keyword-only parameter,
so every forking route test raised TypeError. Add it to the signature,
record it in fork_calls, and assert the route's switch-gated wiring:
False on an agent switch, True on a same-agent fork.

Co-authored-by: Isaac
2026-07-17 15:40:25 +08:00
Tomu Hirata 46d10a48e6 fix(runner): recover cold-resume context when server GET returns null external_session_id (#2776)
* fix(runner): recover cold-resume context when server GET returns null external_session_id

On reconnect, the GET /v1/sessions/{id} may return external_session_id=null
due to a workspace-scope ContextVar defaulting to 0 on fresh tasks. The runner
then launches a fresh Claude session and loses all conversation context.

- app.py: after the GET block in _auto_create_claude_terminal, fall back to
  read_claude_session_id(bridge_dir) if session_external_id is still None; the
  local bridge state file survives reset_transcript_forward_state and holds the
  previous claude_session_id, so we use it as the resume hint.

- claude_native_forwarder.py: on a 400 PATCH rejection in
  _maybe_mirror_external_session_id, fetch the server-bound external_session_id
  and include both the rejected sid and the server-bound sid in the warning so
  operators can identify which session retains the context.

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

* fix(runner): capture bridge claude_session_id before prepare_bridge_dir wipes it

The cold-resume fallback read read_claude_session_id(bridge_dir) after
prepare_bridge_dir had already deleted _STATE_FILE, so it always returned
None and the fallback was dead code.

Fix: read read_claude_session_id from the pre-wipe bridge dir (computed via
bridge_dir_for_bridge_id using the bridge_id already resolved at that point)
before the prepare_bridge_dir call, stash the result, and use the stash in
the fallback block.

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

* test(runner): assert cold-resume fallback reads bridge sid before prepare_bridge_dir wipes it

Adds a test for the ES-2065116 fix: when the server snapshot omits
external_session_id (workspace-scope miss), the runner falls back to the
claude_session_id written in state.json by the prior launch. The test
pre-populates state.json before _auto_create_claude_terminal runs and
asserts _ensure_local_claude_resume_transcript is called with the local
sid, proving the read happens before prepare_bridge_dir deletes the file.

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

* revert(forwarder): remove diagnostic GET on 400 PATCH rejection

The extra snapshot fetch on 400 was purely for logging and adds an
unnecessary round-trip. Restore the original single-line warning.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 07:39:21 +00:00
Daniel Lok c9f6a5fc63 feat(benchmarks): Add cold restart journey (#2761)
- Stop the existing session runner outside each timed sample\n- Measure automatic relaunch from user message to first response
2026-07-17 15:36:52 +08:00
Serena Ruan 00d6b23a94 refactor(ci): reuse run-omnigent-agent action in feature-blog workflow (#2782)
PR #2764 extracted the LLM-runner scaffold (uv + Claude Code CLI + gateway
provider config + agent run + stdout secret-scan) into the composite action
.github/actions/run-omnigent-agent, now shared by draft-release-notes.yml and
publish-changelog.yml. feature-blog.yml still inlined all of it.

Replace the five setup steps + the scout run + its secret-scan with one
`uses: ./.github/actions/run-omnigent-agent` for the tools-less scout (−54
lines). The per-candidate drafter loop still calls `omnigent run` directly —
it interleaves git operations between invocations, which the single-shot
action can't model — and reuses the environment (PATH, ~/.omnigent, .venv)
the action provisions when the scout runs.

Co-authored-by: Isaac
2026-07-17 15:33:04 +08:00
Pat Sukprasert f8acee6a12 test(proc): de-flake process_alive nondestructive-probe PID-recycling race (#2770)
* test(proc): de-flake process_alive nondestructive-probe PID-recycling race

Pin the child via psutil.Process(pid) so the post-teardown liveness
assertion can't be fooled by a recycled PID masquerading as the reaped
child, removing the process_alive(pid) TOCTOU race in the test.

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

* test: pin psutil handle in terminate_tree test to kill PID-recycling race

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-17 05:38:00 +00:00
Kunyu Chen 698f71f1f8 Slack integration with auth support (#2739)
* adjust slack bot behavior so that in channels only @ trigger omnigent, but in DMs, threads strictly map to sessions

streaming text and take advatange of markdown_text support; build towards multi-user support in the slack integration

improve placeholder experience and the ability to handle closed streams

device grant to support accounts-based auth for slack integration

slack integration now supports both accounts and oidc auth

* pre-commit clean-up

* slack socket server security enhancement

* improve security posture

* update uv.lock

* fix test failures: CI builds no web SPA, so the SPA catch-all mount at / is absent
2026-07-17 04:41:03 +00:00
dosenr f4662018ef feat(auth): read the OIDC email identity from a configurable id_token claim (#2223)
* feat(auth): read the OIDC email identity from a configurable id_token claim

_resolve_oidc_email reads only the email claim and hard-fails when it is
absent. Microsoft Entra ID commonly issues id_tokens that carry the user
identity in preferred_username (the UPN) with no email claim at all, so
native OIDC login against Entra fails with "Could not determine user
email" and nothing actionable in the logs.

Add OMNIGENT_OIDC_EMAIL_CLAIM (default: email), mirroring oauth2-proxy's
--oidc-email-claim: the operator names the id_token claim that carries
the email identity. The default path is unchanged. A custom claim always
requires the existing OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION opt-out:
email_verified refers to the email claim (OIDC core), so it vouches
nothing about a custom identity claim, and a token carrying
email_verified true for a different address must not smuggle the custom
claim past the gate. The absent-claim rejection now logs the configured
claim and the claim names present.

Only the generic-OIDC path is affected; GitHub OAuth has no id_token.

Tests: a UPN-only token mints a session with the claim configured plus
the opt-out; a custom claim without the opt-out is rejected both with no
verified marker and with email_verified true referring to a different
email claim; a token missing the configured claim is rejected even when
a verified email claim is present (no silent fallback).

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

* fix(auth): reject malformed OIDC identity claims

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

---------

Signed-off-by: Robert Dosen <robert.dosen@gmail.com>
Signed-off-by: rdosen <robert.dosen@gmail.com>
2026-07-17 04:39:34 +00:00
astasdf1 598f3b86ae fix(runner): thread session workspace cwd into spawned harness subprocesses (#1896)
A session's selected working folder (snapshot.workspace) was honored by the
Files panel / primary OS environment (see per-session-workspace fix) but NOT by
the spawned harness subprocess. _build_spawn_env_from_spec received the runtime
cwd and forwarded it only to pi/kimi; codex, claude-sdk, cursor, qwen, goose,
and copilot builders never set their HARNESS_<H>_CWD env var, so the harness
subprocess (e.g. codex reading HARNESS_CODEX_CWD) fell back to cwd=None and
inherited the runner's launch directory instead of the session workspace.

Thread cwd into all six builders (set HARNESS_<H>_CWD when provided) and pass
cwd=cwd at the dispatch call sites. Mirrors the existing pi/kimi handling.
Adds a parametrized regression test locking cwd threading for all six.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Co-authored-by: jykim-bagel <jykim@bagel-labs.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:31:19 -07:00
Serena Ruan 143dc8a46c docs(releases): curated feature posts with auto-linked docs sections (#2769)
* docs(releases): match the real MLflow release-post format

The first pass mirrored the whole release body — every feature bulleted into a
numbered section, a "Fixes & improvements" section, and PR refs carried through.
The actual mlflow.org/releases posts are curated: only the outstanding features
get a section, there is no bug-fixes section, and there are no PR links.

Rework the release-post-formatter prompt to:
- curate down to the ~4-6 outstanding features and drop minor items entirely,
- omit the bug-fixes section (comprehensive changes live behind Full Changelog),
- drop all PR references from the post,
- write each feature as what-it-is + how-to-use-it, and
- emit per-feature demo and docs-link placeholders (literal TODO) for a human to
  fill in on the auto-opened PR, since the release body carries no media or URLs.

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

* docs(releases): pre-fill real docs links, omit when none match

Instead of a blanket TODO "Learn more" placeholder, give the formatter the list
of the site's real /docs pages (URL + title) and have it link each feature to a
matching page — or omit the line entirely when nothing fits.

- publish-changelog.yml builds a docs index from a blobless sparse checkout of
  the public omnigent-site app/docs tree (no token) and feeds it to the prompt;
  best-effort, so a fetch failure just yields an empty index (links omitted).
- The formatter links only to a verbatim URL from that list, never guesses or
  emits a TODO doc link. The demo image stays a TODO placeholder for a human.

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

* docs(releases): link features to the most specific docs section

Page-level docs links are coarse — an ACP-harness feature should point at
/docs/build/harnesses#custom-acp-agents, not the whole page. Index each doc
page's h2/h3 section anchors alongside the page itself and let the formatter
pick the most specific match.

- The docs-index step now emits indented `url#slug <TAB> title` rows per section,
  computing the slug with the same algorithm the site's HeadingAnchors uses so
  the anchor resolves. It skips fenced code blocks and reduces `[label](url)`
  headings to their label (the site slugs rendered text).
- The formatter prompt prefers a matching #section anchor over the bare page,
  and still omits the "Learn more" line when nothing fits.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-17 12:30:39 +08:00
Tomu Hirata d499489928 fix(policies): wire PolicyStore in Docker entrypoint; thread session owner as actor (#2763)
* fix(policies): wire PolicyStore in Docker entrypoint and thread session owner as actor

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

* fix(policies): prefer authenticated caller over session owner as actor

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

* fix(policies): skip get_session_owner DB call when user_id is present; add actor fallback tests

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

* revert(policies): remove get_session_owner fallback from actor resolution

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 04:30:31 +00:00
Pat Sukprasert 7b41efea2d test(terminals): de-flake control-bridge burst-then-exit tail test (#2766)
test_control_bridge_burst_then_exit_delivers_full_tail relied on a fixed
sleep(10.0) to let the reader drain the tmux control stream, which was slow
and still racy under load. Add two inert, default-None asyncio.Event hooks
(reader_done / forward_done) to bridge_tmux_control_to_websocket that fire
when the reader and forwarder finish, and switch the test to wait on those
events instead of a wall-clock sleep.

The hooks default to None, so the hot path is unchanged for real callers;
only the test opts in. Target test now completes in ~2s (was ~10s).

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-17 04:19:53 +00:00
Pat Sukprasert a12bd79956 fix(deps): cap openai for agents sdk compatibility (#2713)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-17 03:46:27 +00:00
Serena Ruan 3cd67bc0ca docs(releases): narrative, prose-driven website release posts (#2764)
* docs(releases): reformat website release posts in MLflow narrative style

The website /releases/<version> post was a verbatim mechanical mirror of the
GitHub Release body (emoji bullets). Reformat it into the narrative, prose-driven
style of mlflow.org/releases, while leaving the GitHub Release notes untouched.

- New release-post-formatter agent rewrites the curated release body into an
  intro summary + numbered prose feature sections (no emoji), preserving every
  PR ref and inventing nothing. Same tools-less security posture as
  release-notes-drafter.
- publish-changelog.yml gains the LLM machinery to run it, degrading to the raw
  release body on any failure, plus a workflow_dispatch dry_run mode that renders
  and prints the page (log + job summary) without minting a token or opening a PR.
- release_to_mdx.py adds MLflow-style site chrome the release body can't carry: a
  byline (date + read time + author) and a "What's Next" footer. Keeps the exact
  _Released <date>_ token the site index reads.

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

* refactor(ci): extract shared LLM-runner into a composite action

The publish-changelog release-post formatter reused ~150 lines of the
draft-release-notes LLM machinery (uv, venv cache, Claude CLI, provider config,
agent run, output secret-scan) verbatim. Extract it into a
.github/actions/run-omnigent-agent composite action and call it from both
workflows, so the runner scaffold lives in one place.

- The action takes a workdir input so it works whether the repo is checked out
  at the workspace root (draft-release-notes) or in an omnigent/ subdir
  (publish-changelog), driving the venv path, cache key, and uv --project/agent
  paths off it.
- The action now always secret-scans the agent output when it runs (gated by the
  caller's creds check), instead of the old outcome=='success' gate that also
  skipped the scan when the step was skipped.
- Callers keep their own prompt-build, output-extract/fallback, and artifact
  redaction; only the shared scaffold moved.

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

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-17 11:29:06 +08:00
Dhruv Gupta e177a15ebe fix(sandbox): grant the private scratch tmpdir before the spawn-time wrap (#2759)
* fix(sandbox): grant the private scratch tmpdir before the spawn-time wrap

A darwin_seatbelt claude-sdk seat booted the sandbox-exec wrap but then
died with `FileNotFoundError: No usable temporary directory` — the
follow-up to the seatbelt cluster (#2743/#2749).

run_launcher runs twice for spawn-wrap backends: the host pass builds the
wrap (baking the seatbelt SBPL profile / bwrap binds) and execvp's into
it; the in-wrap pass activates and runs the target. The private scratch
tmpdir was minted only in the in-wrap pass, via mkdtemp() against $TMPDIR
= the system tempdir root — which the already-baked profile only granted
a subpath of. bwrap masked this via its --tmpfs /tmp fallback, so only
seatbelt (no tmpfs, $TMPDIR always set on macOS) hit it.

Mint + grant the scratch dir on the host BEFORE the wrap (the pattern
_HelperProcessClient._start_locked already uses), re-encode the policy so
both the profile and the in-wrap pass see the granted root, and hand the
path to the in-wrap pass via a marker env var so it adopts that exact dir
and owns cleanup. The marker is retained through the spawn-env prune;
using it (not _scratch_tmpdir re-derivation) for cleanup avoids rmtree'ing
a spec-supplied write root like /tmp.

Verified on a real Mac: the reported FileNotFoundError reproduces pre-fix
and is gone post-fix; a jailed claude-sdk seat boots through to the
provider. Adds macOS-gated (seatbelt) and Linux-gated (bwrap) end-to-end
regression tests driving the full create_exec_launcher -> run_launcher
two-pass re-exec.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* test(bwrap): allow .venv under the granted project-root read root

The dotfile masker tmpfs-masks hidden dirs under read roots, which hid
the project .venv from the in-wrap re-exec — the inline import of
omnigent.inner.sandbox died with ModuleNotFoundError: yaml before the
tmpdir path ever ran. The seatbelt twin already carries this allowance.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-16 20:02:04 -07:00
Abderrahmen Gharsallah 59aa7613c7 fix: use world-writable /tmp safely in modal sandbox and test guardrails (#647)
* fix: use a private mode-700 dir for the modal foreground pidfile

exec_foreground recorded the remote pid at a fixed, predictable path in the
world-writable /tmp (/tmp/oa-foreground.pid). A co-tenant process in the
sandbox could pre-seed that path as a symlink (so `echo $$ > ...` writes
through it) or overwrite its contents (so `kill $(cat ...)` signals an
arbitrary pid).

Record the pid in a private, unpredictably-named dir created with
`mkdir -m 700` (no -p, so it fails closed if the path already exists), and
only signal a numeric pid read back from that file before removing the dir.
Update the tests to assert the new structure instead of the fixed path.

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* fix: resolve symlinks before trusting a SQLite path as a test DB

looks_like_test_db accepted a file-backed path on its 'test' name token or its
temp-dir location without resolving symlinks first. A symlink planted in a
world-writable dir like /tmp (e.g. sqlite:////tmp/test.db) could therefore
point a 'throwaway' test DB at a real database and pass the guardrail.

Resolve the path before the token and temp-dir checks so the resolved target
is what gets classified, and add a regression test covering a test-named
symlink that resolves outside any temp root.

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* fix: share safe foreground-pidfile helper across sandbox launchers

Extract a single fail-closed foreground-pidfile implementation into
base.py (foreground_pidfile / foreground_record_prefix /
foreground_kill_command) and route Modal, CoreWeave (cwsandbox), and
OpenShell through it, closing the same /tmp symlink-redirect + pid-spoof
vector the Modal-only fix addressed in two other shipped providers.

- cwsandbox: drops the vulnerable fixed /tmp/oa-foreground.pid and
  unvalidated 'kill $(cat ...)' — now uses the private mode-700 dir
  with a numeric-gated kill. Adds exec_foreground regression tests
  (none existed before) and extends the cwsandbox fake to record exec
  commands and raise on wait.
- openshell: drops the predictable {sandbox_id} pidfile template and
  unvalidated kill for the shared, numeric-gated path.
- modal: drops its inline copy and imports the helper; behavior
  unchanged for the security properties.
- All three: clean up the run dir on normal exit too (previously only
  on Ctrl-C), so a successful run no longer orphans a mode-700 dir.
- Helper hardening: shlex.quote the derived run_dir/pidfile inside
  foreground_record_prefix and foreground_kill_command so the public
  API stays injection-safe even if a future caller passes a non-hex
  path. Hex paths quote harmlessly.

All 268 tests/onboarding/sandboxes tests pass; ruff check + format clean.

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

---------

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>
Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
Co-authored-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-16 19:14:12 -07:00
Kevin Lin 3de0196eda feat(web): add PDF text selection comments with highlight overlays (#2677)
* Support commenting in PDF viewer

Signed-off-by: kevin-lyn <kevin.lin@databricks.com>

* Apply prettier formatting to PDF comment helpers.

* Add e2e coverage for PDF comment selection and highlights.

Exercise the full PdfViewer flow: text-layer drag selection, floating add-
comment button, pending/saved highlight overlays, and PDF geometry anchors
via the comments API.

* e2e test

Signed-off-by: kevin-lyn <kevin.lin@databricks.com>

---------

Signed-off-by: kevin-lyn <kevin.lin@databricks.com>
2026-07-17 10:05:05 +08:00
Sabhya Chhabria 6200a25829 feat(cli): Add Hermes setup installer (#2751)
- Offer the trusted vendor installer from the Hermes setup menu
- Refresh ~/.local/bin so configuration can continue without restarting

Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-16 18:26:55 -07:00
Tomu Hirata 811457829e fix(benchmark-pr): continue-on-error for PR comment step on fork PRs (#2753)
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-17 01:21:51 +00:00
Yi Lyu 7053a5b941 fix(harnesses): run cursor bridge in the declared workspace cwd (#2244)
cursor-sdk's AsyncBridge.launch spawns the bridge subprocess without a
cwd=, so the bridge -- and the shell tools Cursor runs inside it --
inherited the runner daemon's directory instead of the spec's
os_env.cwd. --workspace only routes indexing, not command execution, so
pwd / git / relative paths operated on the wrong tree.

Set the process cwd to the resolved workspace across
AsyncClient.launch_bridge and restore it afterwards, serialised by a
process-global lock so an overlapping launch can't observe a
half-applied cwd. The underlying Popen(cwd=...) fix belongs upstream in
cursor-sdk; this compensates from the executor since the SDK is an
external dependency.

Refs #2111
2026-07-16 17:47:23 -07:00
Enes Yilmaz 2192b0f682 fix(cursor): fail closed in the policy hook when evaluation is unavailable (#2664)
cursor_policy_hook is the preToolUse gate for the Cursor SDK harness's native tools. On two failure branches it returned {"permission": "allow"}, so a transient Omnigent-server outage (resp is None after the retry budget) or a malformed response silently skipped DENY/ASK policy enforcement.

Fail closed with deny on both, matching hermes_policy_hook and the native hooks' fail_closed_hook_output (PR #163), and honoring post_evaluate_with_retry's documented contract that the caller handles None as fail-closed. The no-server, stdin-parse, and import-error branches keep failing open, exactly as the sibling hooks do.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
2026-07-17 00:40:50 +00:00
Sabhya Chhabria 6364d0bc1e [cursor] Clarify CLI setup readiness (#2733)
* 🐛 fix(cursor): Clarify CLI setup readiness

- Keep Cursor CLI and SDK configuration under one setup entry
- Prioritize cursor-agent install/login readiness over API-key state
- Surface actionable install and login guidance in the web picker

* 📸 docs(cursor): Add setup guidance demo

* 🎨 style(web): Apply locked Prettier formatting

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

* 🧪 test(web): Cover Cursor setup guidance end to end

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>
2026-07-16 17:39:19 -07:00
Enes Yilmaz 4c5161364a fix(claude-sdk): evict the cached client when a turn is cancelled (#2169)
A watchdog-cancelled turn raises asyncio.CancelledError, which is a
BaseException and bypasses run_turn's except-Exception cleanup boundary.
The wedged ClaudeSDKClient stayed cached in _clients, so every resume
reused it, emitted no events, and re-tripped the 240s idle watchdog;
the session was unrecoverable until a daemon restart.

Catch CancelledError at the same boundary, synchronously pop the client
and force-close it in a background task (awaiting a graceful close there
could itself be cancelled), then re-raise. The session is not crash-marked:
the next turn rebuilds a fresh client and replays history through the
text-prefix path.

Closes #2109

Signed-off-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>
2026-07-16 17:39:18 -07:00
Dhruv Gupta 103996733c fix(claude-sdk): degrade instead of crashing when the CLI wrap is infeasible (#2749)
Wrap failures used to kill the seat at connect time: resolve_sandbox
raised straight out of prepare_claude_cli_path, and wrap-time OSErrors
(un-grantable interpreter layout, profile-size cap, cwd-scan overflow)
fired inside run_launcher where they surface as an opaque exit-71 /
60s connect timeout.

Probe the wrap at prepare time — the last point where degrading is
still safe — and on failure return the CLI unwrapped with native tools
disabled plus a WARNING: the same confinement shape as the
OMNIGENT_CLAUDE_SDK_NO_SANDBOX bypass (file/shell access stays on the
independently sandboxed sys_os_* helpers, which fail closed on their
own). run_launcher itself stays fail-closed for every other lane.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-17 00:37:05 +00:00
Dhruv Gupta ac756cf7d9 fix(sandbox): grant exec-chain symlink hops + launcher target in seatbelt (#2743)
Port the two bwrap visibility behaviours seatbelt never got:

- Walk argv[0]'s symlink chain hop-by-hop and grant a literal read on
  every uncovered symlink (uv's version-floating cpython-3.12 dir hop
  was denied, EPERM-ing every jailed helper execvp at boot).
- Stop discarding the launcher target: grant its symlink chain plus a
  narrow subpath on the resolved binary's own directory so the wrapped
  CLI (e.g. claude) is readable inside the sandbox. Never raises —
  un-grantable layouts degrade to a literal grant plus a WARNING.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-16 17:10:13 -07:00
Tomu Hirata cfcc076358 perf(policies): remove unused trajectory DB read from policy evaluation (#2701)
* perf(policies): remove unused trajectory DB read from policy evaluation

EvaluationContext.trajectory was populated on every POST /policies/evaluate
call via a list_items() query (last 10 conversation items), but no policy
implementation ever read it — FunctionPolicy, PromptPolicy, and LabelPolicy
all ignore ctx.trajectory. The fetch was dead work on every tool call hook.

Remove _populate_trajectory, _TRAJECTORY_WINDOW, EvaluationContext.trajectory,
and the now-unused ConversationItem import. Eliminates one DB read per
policy evaluation, which fires multiple times per turn across all harnesses.

* fix(ci): remove trajectory test, fix hosts_changed e2e health mock

- Delete test_engine_trajectory.py: tested EvaluationContext.trajectory
  which no longer exists after removing the trajectory DB read
- Fix test_hosts_changed_frame_updates_host_badge: stub /health to return
  empty sessions so liveOnline stays undefined; without this the health
  poll sets liveOnline=null (no real host bound), overriding the useHosts
  mock and preventing the badge from ever showing "online"
2026-07-17 09:07:21 +09:00
Dhruv Gupta 690eeff6d6 fix(server): key HostRegistry by canonical host id so legacy host_<hex> lookups hit (#2741)
Since #2228 the tunnel route registers hosts under the bare-hex id,
but REST callers can still present the legacy host_<hex> spelling
(pre-migration config.yaml + older CLIs). Every DB path normalizes
via uuid_to_bytes, so GET /v1/hosts reported such hosts online while
the launch path's exact-string registry lookup missed the live
tunnel and 409'd "host is offline" — deterministically, straight
through the CLI's transient-409 retry ladder.

Canonicalize the key inside HostRegistry itself (register / get /
deregister), falling back to the verbatim string for ids that are
not uuid-shaped. One guard at the choke point covers
_host_launch.py, _workspace_validation.py, and any future caller,
and keeps HostConnection.host_id consistent with its storage key
(send_text's replaced-connection check relies on that).

Fixes #2740

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-17 00:05:40 +00:00
Aditya, Devarapalli c297974a5e fix(sandbox): keep bwrap helper spawnable when interpreter lives under a masked dotdir (#1) (#1951)
A bwrap-sandboxed helper became unspawnable when the sandbox cwd was an
ancestor of the helper interpreter and the interpreter lived under a
dotdir (e.g. a `uv tool`-installed omnigent at
`~/.local/share/uv/tools/omnigent/bin/python` with cwd=$HOME). The
dotfile masker `--tmpfs`-masks `.local`, and since the mask is emitted
last to win over broad binds, it hid the interpreter and bwrap died with
`execvp ...: No such file or directory`.

Two interacting causes, both fixed:

- bwrap masker: `_ensure_executable_visible` emitted no explicit binds
  for an interpreter that cwd nominally covers, so the `--tmpfs` mask
  hid it with nothing to restore it. Now, after the mask, re-expose the
  interpreter (and target) chain scoped strictly inside the masked dir,
  so it layers over the mask and reaches exactly the interpreter subtree
  — `.local` stays masked, only the interpreter dirs poke through.

- claude-sdk cwd: a relative `os_env.cwd` (the default ".") resolved
  against `os.getcwd()` landed on the runner daemon's $HOME when no
  workspace was selected — rooting the sandbox at the whole home dir and
  disagreeing with the tmux terminal. Resolve relative cwds against
  OMNIGENT_RUNNER_WORKSPACE (both sandbox-wrapping paths) and fall the
  harness CLI cwd back to it, mirroring the kimi/pi/hermes harnesses.

Signed-off-by: Aditya Devarapalli <adityareddyd2@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:03:23 -07:00
Raul Hernandez 6abce78440 fix: macOS seatbelt sandbox blocks claude-sdk subscription runs (Bun fstat + OAuth-token daemon allowlist) (#2647)
* fix(seatbelt): allow file-read-metadata globally so Bun's startup fstat() survives the sandbox

The bundled `claude` CLI runs on Bun. Bun's WriteStream constructor calls
fstat(2) on its inherited stdout/stderr pipe file descriptors at startup for
ANSI-color / TTY detection (internal:util/colors, fs/streams:244). Pipe fds
have no filesystem vnode path, so they match no path-scoped
`(allow file-read-metadata "...")` literal. Under the seatbelt profile's
deny-by-default policy the fstat returns EPERM, crashing the Bun process
before it emits any stream-json. The SDK connect handshake then never
completes and dies with "Claude SDK connect timed out after 60s". The failure
presents as a network/timeout bug but is a sandbox denial on a metadata syscall.

Only reproducible on the intersection macOS + darwin_seatbelt + claude-sdk;
with `sandbox.type: none` the same run succeeds, confirming the sandbox (not
the harness/auth) is the cause.

Fix: grant `file-read-metadata` globally (no path filter) in the SBPL
baseline, right after the existing global `(allow file-ioctl)`. This allows
fstat() on any fd including pipes. It grants inode metadata only
(stat/fstat/access/getattrlist) and does NOT grant file data access
(file-read* is unchanged), directly analogous to the baseline's existing
global `(allow file-ioctl)`.

Security note (stated honestly): this widens a metadata oracle — a sandboxed
agent can confirm file existence anywhere on the filesystem (it still cannot
read contents). Acceptable for single-tenant developer/operator use; an inline
caveat flags it for multi-tenant deployments, where maintainers may prefer a
narrower scope (metadata only on the inherited fds, or scoped to the sandbox's
own tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): add CLAUDE_CODE_OAUTH_TOKEN to the local daemon env allowlist

CLAUDE_CODE_OAUTH_TOKEN is in HARNESS_CREDENTIAL_ENV_VARS
(omnigent/host/connect.py) so _build_runner_env forwards it host->runner, and
an existing comment there already notes it is needed "for `claude setup-token`
subscription auth". But the daemon env is built earlier by
_build_host_daemon_env (omnigent/cli.py), which admits only
_RUNNER_ENV_ALLOWLIST + _LOCAL_DAEMON_ENV_ALLOWLIST. CLAUDE_CODE_OAUTH_TOKEN
was in neither list, so it was stripped from the daemon's environment at
launch. The daemon then came up without the token, and _build_runner_env had
nothing to forward — the HARNESS_CREDENTIAL_ENV_VARS membership was moot
because the value had already been dropped one layer up.

Net effect: on a local (non-cloud) macOS run with the managed daemon, a
claude-sdk agent authenticated via `claude setup-token` (subscription) behaves
as if it has no credentials. ANTHROPIC_API_KEY does not hit this because it IS
in _LOCAL_DAEMON_ENV_ALLOWLIST — which is exactly why API-key auth works and
subscription auth doesn't.

Fix: add CLAUDE_CODE_OAUTH_TOKEN to _LOCAL_DAEMON_ENV_ALLOWLIST so it survives
the cli->daemon env strip and is then available for _build_runner_env to
forward to the runner.

Security: it's a credential and is treated as one — it joins the same
allowlist that already holds ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN and the
other provider keys. No new class of secret is exposed; a subscription token is
placed on identical footing to the API key alongside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:02:56 -07:00
Abderrahmen Gharsallah 68c4539e8c chore: centralize "session not found" NOT_FOUND errors behind a factory. (#564)
Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>
2026-07-16 17:00:27 -07:00
Abhay Jalisatgi 7c997dbe84 fix(electron): remove macOS WebAuthn platform authenticator (fixes YubiKey SSO) (#2036)
On signed, packaged macOS builds, registerWebAuthn() called
app.configureWebAuthn(...), enabling the macOS Secure-Enclave platform
authenticator. That routes the whole WebAuthn ceremony through Apple's
provider, which cannot complete a roaming USB security-key request (e.g.
YubiKey) against a third-party SSO relying party (Okta) — the ceremony dies
with an opaque NotAllowedError ("The operation either timed out or was not
allowed").

Remove the platform-authenticator machinery entirely (per review), rather
than gating it. The platform authenticator served no supported Databricks
sign-in path: Touch ID sign-in goes through Okta FastPass (Okta Verify over
the localhost loopback — handled by the LNA-permission code in main.js,
unrelated to WebAuthn), and browser-registered passkeys are invisible to the
Electron keychain access group anyway. With it gone, security keys always
drive Chromium's built-in CTAP path, so YubiKey/opt-out sign-in works.

Removed:
- registerWebAuthn(), the WEBAUTHN_KEYCHAIN_ACCESS_GROUP constant, and the
  call site in app.whenReady().
- The now-dead keychain-access-groups entitlement (entitlements.mac.plist)
  and its Developer ID provisioning profile (signing/omnigent.provisionprofile
  + the provisioningProfile ref in package.json), which existed solely for
  this feature. Removing them also eliminates the documented AMFI-SIGKILL
  foot-gun those three coupled pieces created.
- The stale Passkeys (WebAuthn) section in README.md, rewritten to explain
  why the platform authenticator is intentionally not enabled.
- The keychain-access-groups example in entitlements.mac.inherit.plist,
  replaced with a general restricted-entitlement caution.

Because no restricted entitlements remain, a Developer ID certificate alone
is sufficient for signing — no embedded provisioning profile is needed.

Co-authored-by: Isaac <isaac@omnigent.ai>
2026-07-16 16:53:30 -07:00
Dhruv Gupta 5f5d16e233 fix(cli): drop the OpenRouter example from the Gateway setup option (#2738)
The model-setup add menu offered both "Gateway — custom base URL + key
(e.g. OpenRouter)" and a standalone "OpenRouter — API key" option, which
read as two ways to do the same thing and confused users during setup.
Drop OpenRouter from the Gateway label and description; users who want
OpenRouter should pick its dedicated option.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-16 16:08:14 -07:00
Zeyi (Rice) Fan 6594028a2c ## Related issue (#2661)
N/A

## Summary

- Adds `omnigent://<hostname>/c/<session_id>` deep links to the iOS app, mirroring the Electron desktop shell (`designs/desktop-deep-link.md`): an OS-routed link opens that session on that server.
- Window handling: same-server → navigate in-place via the SPA router (no reload), deferred until the page finishes loading so a cold-start link isn't lost; known server (in recents / saved) → switch + load the conversation directly, no prompt; unknown server → native confirmation (pinning a new origin is a privilege grant), with the workspace-mount probe running ONLY after consent so a link to an attacker-chosen host makes no pre-consent network request.
- The conversation path never enters the saved server URL or recents (only the load URL carries it), so a later deep link resolves against a clean server identity; a new `omnigent:open-path` main→renderer channel (separate from the notification channel) routes in-place.

## Test Plan

- `xcodebuild build -project web/ios/Omnigent.xcodeproj -scheme Omnigent -destination 'platform=iOS Simulator,name=iPhone 17'` → BUILD SUCCEEDED.
- `xcodebuild test -only-testing:OmnigentTests ...` → TEST SUCCEEDED; 21 tests pass (8 new DeepLinkTests, 2 new SettingsStoreTests for knownServerURL, 11 existing), 0 failures.
- swift-format + swift-format lint + prettier pre-commit hooks pass on all changed files.
- Manual (simulator): `xcrun simctl openurl booted 'omnigent://<reachable-https-host>/c/<id>'` — same-server navigates in-place; a known server switches to it; an unknown server shows the consent alert. Requires the web UI rebuilt (`cd web && npm run build`) so the served SPA has the `onOpenPath` subscriber.

## Demo

N/A — no visible UI change beyond in-app navigation / a consent alert triggered by an external link. (QR-code scanning routes through the same `.onOpenURL` path, so a QR encoding the link opens the installed app identically.)

## 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 parser (`DeepLinkTests`: scheme inference, port preservation, IPv6, trailing-slash normalization, rejections) and the known-server lookup (`SettingsStoreTests.knownServerURL`). The orchestration (`AppRootView.handleDeepLink`, the SwiftUI `.onOpenURL`/alert wiring, in-place deferral in `WebShellView`) isn't unit-testable without a UI harness, so it was verified by a clean build + simulator `simctl openurl` dispatch on a reachable https server.

## Changelog

`omnigent://<hostname>/c/<session_id>` links open that session in the iOS app, reusing the open window on that server in-place
2026-07-16 13:54:59 -07:00
Sabhya Chhabria 3f1084de15 ♻️ refactor(ui): Generalize goal mode controls (#2728)
- Route the provider-neutral composer surface through a generic goal API facade while preserving the Codex backend
- Rename goal components, state, selectors, and tests without changing the Codex-only capability gate

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-16 12:59:15 -07:00
Sabhya Chhabria 9df2abd985 feat(cli): add bounded batch chat imports (#2724)
Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-16 12:21:35 -07:00
Edwin He 38595d23ab Add cross-replica live-state mirror for the session sidebar (#2574)
* Add cross-replica live-state mirror for the session sidebar

Under replica sharding, a session list / WS /v1/sessions/updates request can
land on any replica, but the sidebar's live fields — runner_online, turn
status, and the pending-approval count — historically lived only in the
in-memory caches of the replica holding a session's runner tunnel. This
mirrors them to three nullable columns on omnigent_conversation_metadata,
written by the tunnel-holding replica and readable anywhere:

- runner_last_seen: epoch seconds the bound runner's tunnel was last seen;
  runner_online is derived from freshness (90s TTL), so an ungraceful
  death self-corrects. Stamped on connect and each runner-tunnel ping-loop
  tick (inside the handler's workspace_scope), cleared on graceful disconnect.
- live_status: last relay-observed turn status (enum_codecs.SESSION_LIVE_STATUS).
- pending_elicitation_count: outstanding approval-prompt count.

Writes funnel through one best-effort chokepoint (server/session_live_state.py):
ordered (single-worker executor), deduplicated, off the event loop, and run
inside a copy of the caller's contextvars so the per-request workspace_scope —
which every store query filters on — reaches the worker thread. A bare executor
would run the write at the default workspace, so on a multi-tenant replica every
UPDATE ... WHERE workspace_id == ... would match no rows and the mirror would
silently no-op; the read path (_bulk_session_liveness via asyncio.to_thread)
already propagates the context, so this makes the write path symmetric. A
dropped best-effort write evicts its dedupe entry so the next identical publish
retries rather than being swallowed. Writes never bump conversations.updated_at
(it drives sidebar ordering). The read path checks the in-memory registry first
and falls back to the row's freshness, so a replica that doesn't hold the tunnel
still reports correctly. The unread-dot baseline moves client-side (localStorage
+ server-seed max-merge) so it no longer depends on the serving replica.

Migration d7f1a2b3c4e5 adds the three nullable columns; NULL degrades to
today's behavior. This is the OSS SQLAlchemy path only — the managed EStore
store implements the same abstract methods separately, and host_id slice-key
routing is a separate PR.

Tests: workspace-scoped store round-trip through the chokepoint (fails on a bare
executor, passes with copy_context), contextvar propagation, ping-loop re-stamp,
dedupe stale-on-drop eviction, and cross-replica /health derivation from a
fresh / past-TTL / cleared row.

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

* Drop the drain_for_tests hook; tests poll the observable effect

Remove the test-only drain_for_tests() from the production session_live_state
module — a test seam has no business in the shipped chokepoint. Tests now wait
on the observable effect of each background write (the recording store's
captured writes, the DB row, or the dedupe-map eviction) with a short polling
deadline, mirroring the host-tunnel route tests' _wait_* helpers.

The dedupe stale-on-drop test now gates its retry on the dedupe entry actually
leaving the map (the exact contract under test) rather than on the first store
call, closing a race the drain hook had been masking.

No production behavior change; 225 affected tests pass.

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

* Drop unencodable live statuses before enqueue

persist_live_status forwarded any relay-observed status straight to the
store, but SessionStatusEvent.status permits "launching" (runner-local
sub-agent bookkeeping) which the live-status codec can't encode. Enqueuing
it made the store write raise; the best-effort failure hook then cleared
the dedupe entry, so every republish re-attempted and re-logged rather than
settling.

Guard in persist_live_status: statuses outside the codec's known set
(derived from SESSION_LIVE_STATUS so the two can't drift) are dropped before
the enqueue, warned once (deduped), and never reach the store. Latent today
(no producer emits "launching" as an external session.status), addresses a
Polly non-blocking note.

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

* Update sidebar unread-dot e2e for browser-durable read-state

The mark-unread e2e's docstring asserted the OLD contract — read-state is
server-backed with "no localStorage", so a dot reappearing after reload
proved the server round-trip. This PR inverts that: read-state is now
localStorage-durable, mirrored best-effort to a per-replica server copy.

Rewrite the docstring to the new contract and add a case that pins the
pod-independence: after mark-unread + reload, stub GET /v1/sessions to
return viewer_unread=false / viewer_last_seen=null (a replica whose seed
never saw the PUT), and assert the dot still lights — proving it was
restored from localStorage, not the server seed. Fails on pre-localStorage
code (read-state-less seed → row reads seen → no dot).

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

* Fix flaky live-state chokepoint test: wait for all writes, not the first

test_live_state_writes_via_chokepoint_land_in_scoped_workspace enqueues
three writes on the chokepoint's ordered single-worker executor
(touch_runner_liveness, persist_live_status, persist_pending_count) but
polled only for the first (runner_last_seen) before asserting all three.
On a loaded CI runner (Pytest stores shard, 8-way xdist) the read raced
the later two, so live_status read None -> "assert None == 'running'".

Poll until ALL three fields are observed, and raise the deadline (2s to
10s; a passing predicate returns immediately, so the ceiling only matters
on a real failure). Also raise the _wait_until default in the live-state
unit tests to 10s for the same load-robustness. Verified: 162 passed 3x
under 8-way parallel pytest, and 15x sequentially on the target test.

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

* Gate persisted pending-count fallback on runner binding

_build_session_list_item merged the in-memory elicitation index with the
persisted row via max(index, row). For an UNBOUND session that produced a
load-dependent flake: resolve() drops the index to 0 synchronously, but the
row's 0-write is async on the live-state executor, so a list read that beat
the write saw max(index=0, row=1)=1 — a stale-high badge. Deterministic
locally (fast SQLite), it surfaced under the stores/server-integration
shard's 8-way parallelism as "assert 1 == 0".

The persisted count is a CROSS-REPLICA mirror: only meaningful when a runner
tunnel exists on some replica, whose holder writes the row and whose
non-holders fall back to it. An unbound session (no runner_id) has no tunnel
anywhere, so the local index is authoritative and the lagging row must not
override it. Consult the row only when conv.runner_id is not None; otherwise
use the index directly.

Adds test_list_sessions_pending_count_falls_back_to_row_for_bound_session
pinning the fallback still fires for a bound session (index empty, row set),
complementing the existing unbound/index-authoritative test. Verified: full
server-integration suite 867 passed under -n 4, and the unbound test 20x with
no flake (row column never read on that path -> timing-independent).

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

---------

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-07-16 12:01:13 -07:00
Dalton Luce 0beca0bb81 fix(cli): surface clean errors when binding a session runner times out (#2572)
A slow or unreachable Omnigent server made bind_session_runner leak a raw
httpx transport exception, so the CLI printed a full traceback (e.g. bare
`omnigent` -> run -> bind against a degraded backend) instead of an
actionable message.

Wrap the PATCH call and map each transport failure to a clean
ClickException, distinguishing unreachable (connect error / connect
timeout -> check URL & connection) from reachable-but-slow (read timeout
-> retry shortly). Honors the function's documented contract.
2026-07-17 00:59:45 +08:00
Matt Van Horn 50383adf44 feat(web): collapsible dropdown for nested subagents (#975)
Rebased onto main after the UI code moved from ap-web/ to web/.
Kept main's list/graph view toggle alongside the new per-row
collapse state.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-16 18:06:28 +02:00
Pat Sukprasert 7341295eff fix(web): keep terminal session links in-app (#2639) 2026-07-16 23:47:53 +08:00
Shantanu Deshpande 1e0e422e3c feat(codex-native): stream command output to web (#2652)
Signed-off-by: Shantanu Deshpande <shantanu.n.deshpande@gmail.com>
2026-07-16 15:53:37 +02:00
Pat Sukprasert ea1647a809 ci: require DCO in merge ready (#2707)
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-16 21:13:27 +08:00
Daniel Lok ae8878ad70 fix(server): ask the host if a runner is coming before the connect grace (#2699)
* fix(server): ask the host if a runner is coming before the connect grace

A host-bound session's first message waits up to _HOST_BOUND_RUNNER_CONNECT_GRACE_S
for the pinned runner's tunnel to register before relaunching. That wait is
correct for a booting new-session runner but pure latency for one that will
never connect — a non-sticky Stop dropped it, or the host restarted and lost
it. Neither case writes a host.runner_exited report (Stop pops the runner
before terminating; a dead host never sends the frame), so the old blind wait
burned the full grace every time on restart.

The host is the authoritative owner of runner-process liveness — it holds the
Popen. Add a host.runner_status frame pair so the server can ask: alive
(booting/serving → wait), dead (tracked but exited → relaunch now), or unknown
(stopped, crashed, or lost to a host restart → relaunch now). The dispatch
path races this query against the connect grace: the runner connecting (or a
crash report) always wins if it lands first, and a dead/unknown verdict cuts
the wait short so the relaunch runs immediately. Running the query alongside
the wait — not before it — keeps it strictly a speed-up: a host that is
offline, too old to answer, or slow yields no verdict and the grace runs its
normal course with no added latency.

Host-absent at dispatch skips the grace entirely (there is no one to query)
and falls through to the existing relaunch/503, unchanged.

Co-authored-by: Isaac

* Address code-quality review on the runner-status query

- Drain cancelled tasks via asyncio.gather(..., return_exceptions=True)
  instead of `await task` inside contextlib.suppress, in both the race
  helper and the integration test. Functionally identical, but avoids the
  bare-expression-statement the static analyzer flagged as "no effect"
  (it doesn't model `await` as side-effecting).
- Harden _query_host_runner_status: map any unexpected exception (e.g. a
  future resolved with an error) to None so the query can only ever speed
  up the connect grace, never break the message POST. CancelledError stays
  a BaseException and still propagates, so the race helper's cancel/drain
  is unaffected. Covered by a new test that resolves the pending future
  with an exception.

Co-authored-by: Isaac

* test(e2e): stub /health so the host-badge push test isolates useHosts status

test_hosts_changed_frame_updates_host_badge failed at its first
assertion (before any hosts_changed frame): the badge read "status
unknown" instead of the stubbed "online". The test intercepts the
WS /v1/sessions/updates stream to keep liveOnline undefined, but the
open-session GET /health poll is a second, independent source of
host_online — and the real endpoint emits host_online: null for a
session it finds without a host binding. That null reaches
useSessionHostOnline as a live signal, which HostBadge treats as
authoritative "unknown", overriding the useHosts status the test drives.

Patch /health to drop the seeded session from the batch sessions map so
useSessionHostOnline stays undefined ("not observed yet") and the badge
falls back to the useHosts status field — matching the test's stated
intent and the existing snapshot/list route patches. HostBadge behavior
is unchanged; this only repairs the test's mock world, which had the
snapshot claiming host-bound while /health said otherwise.

Co-authored-by: Isaac
2026-07-16 12:38:44 +00:00
1102 changed files with 209617 additions and 97714 deletions
@@ -71,8 +71,10 @@ Three transports, easy to confuse:
agy models # exits 0 and lists models only when signed in; else 'Please sign in'
```
`False` / non-zero → run `agy` once and sign in. agy's token lives under
`~/.gemini` (`oauth_creds.json` on macOS, `antigravity-cli/antigravity-oauth-token`
on Linux).
`~/.gemini` (`oauth_creds.json` on macOS through 1.0.10,
`antigravity-cli/antigravity-oauth-token` on Linux); agy 1.1.7+ on macOS
writes no token file and keeps the credential in the Keychain, which is why
`gemini_login_detected()` falls back to `agy models` there.
4. **`tmux` is on PATH.** The agy terminal is a runner-owned tmux pane; the CLI
attaches to it and the executor drives it via `tmux send-keys`
(`_preflight_local_tools` hard-fails without tmux).
@@ -86,7 +88,7 @@ Three transports, easy to confuse:
```bash
cd /path/to/omnigent
.venv/bin/omni server start # detached managed server on a free loopback port
.venv/bin/omni server --background # detached managed server on a free loopback port
.venv/bin/omni server status # prints the URL, e.g. http://127.0.0.1:6767
SERVER=http://127.0.0.1:6767 # use the printed URL below
curl -s "$SERVER/health" # {"status":"ok"}
@@ -47,7 +47,7 @@ tests.
```bash
cd /path/to/omnigent
.venv/bin/omni server start # spawns a detached server on a free loopback port
.venv/bin/omni server --background # spawns a detached server on a free loopback port
.venv/bin/omni server status # prints the URL, e.g. http://127.0.0.1:6767
```
@@ -134,7 +134,7 @@ streaming, harness.
7. **Turns take ~1060s** — always wrap in `timeout 280`.
8. **Local-runner topology:** `omni run <bundle> --server <url>` runs the
harness from your **current checkout**; the server only holds state. The
managed `omni server start` server runs from whatever venv launched it.
managed `omni server --background` server runs from whatever venv launched it.
9. **Never print/echo the Gemini key** in logs or commands.
## Code & tests
+1 -1
View File
@@ -44,7 +44,7 @@ the unit tests.
```bash
cd /path/to/omnigent
.venv/bin/omni server --port 7788 --no-open # foreground; or `omni server start` for detached
.venv/bin/omni server --port 7788 --no-open # foreground; or `omni server --background` for detached
curl -s http://127.0.0.1:7788/health # {"status":"ok"}
```
+2 -2
View File
@@ -35,7 +35,7 @@ Cursor as SDK `custom_tools`. This skill is the proven recipe for running it
```bash
cd /path/to/omnigent
.venv/bin/omni server start # spawns a detached server on a free loopback port
.venv/bin/omni server --background # spawns a detached server on a free loopback port
.venv/bin/omni server status # prints the URL, e.g. http://127.0.0.1:6767
```
@@ -116,7 +116,7 @@ that works, the full stack is good: key, egress, bridge, harness.
5. **Turns take 3090s** — always wrap in `timeout 280`.
6. **Local-runner topology:** `omni run <bundle> --server <url>` runs the
harness from your **current checkout**; the server only holds state. The
managed `omni server start` server runs from whatever venv launched it.
managed `omni server --background` server runs from whatever venv launched it.
7. **Never print/echo the Cursor key** in logs or commands.
## Code & tests
+1 -1
View File
@@ -76,7 +76,7 @@ Two ways a turn reaches Pi — test both:
```bash
cd /path/to/omnigent
.venv/bin/omni server start # detached managed server on a free loopback port
.venv/bin/omni server --background # detached managed server on a free loopback port
.venv/bin/omni server status # prints the URL, e.g. http://127.0.0.1:6767
SERVER=http://127.0.0.1:6767 # use the printed URL below
curl -s "$SERVER/health" # {"status":"ok"}
+1 -1
View File
@@ -118,7 +118,7 @@ right vendor, cross-reviews). That is the live recipe.
### Run a live turn
```bash
.venv/bin/omni server start && .venv/bin/omni server status # prints $SERVER, e.g. http://127.0.0.1:6767
.venv/bin/omni server --background && .venv/bin/omni server status # prints $SERVER, e.g. http://127.0.0.1:6767
SERVER=http://127.0.0.1:6767
timeout 280 .venv/bin/omni run examples/polly \
-p "Investigate how the runner enforces tool-call policies and report file:line evidence." \
+2
View File
@@ -10,11 +10,13 @@ dhruv0811
Edwinhe03
fanzeyi
kerryspchang
kunyuchen
lisancao
mahesh-venkatachalam
mateiz
newfront
PattaraS
rahulrav1
SabhyaC26
serena-ruan
shivam5
@@ -0,0 +1,128 @@
name: Run Omnigent agent
description: >-
Set up uv + the Claude Code CLI + an Omnigent gateway provider, run a tools-less
Omnigent agent headlessly on a prompt file, and secret-scan its output. Shared by
the release-cut (draft-release-notes) and publish (publish-changelog) workflows so
the LLM-runner scaffold lives in one place. The caller mints no write-token until
after this action returns — the only secret here is the model key.
inputs:
workdir:
description: >-
Repo checkout dir relative to the workspace (`.` when checked out at the
root, `omnigent` when checked out into a subdir). Drives the venv path, the
cache key, and the uv --project / agent paths.
required: false
default: "."
agent:
description: Agent directory name under <workdir>/.github/agents/.
required: true
prompt-file:
description: Absolute path to the file holding the agent prompt.
required: true
output-file:
description: Absolute path to write the agent's stdout to.
required: true
stderr-file:
description: Absolute path to write the agent's stderr to.
required: false
default: /tmp/omnigent-agent-stderr.log
gateway-base-url:
description: Base URL of the Anthropic-compatible gateway.
required: true
llm-api-key:
description: Model API key (referenced by the provider config, used to scan output).
required: true
claude-code-version:
description: "@anthropic-ai/claude-code npm version to install."
required: false
default: 2.1.212
runs:
using: composite
steps:
- name: Set up uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Cache virtualenv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ inputs.workdir }}/.venv
key: venv-${{ runner.os }}-${{ hashFiles(format('{0}/.python-version', inputs.workdir)) }}-${{ hashFiles(format('{0}/uv.lock', inputs.workdir)) }}
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.workdir }}
run: uv sync --extra all --extra dev
- name: Install Claude Code CLI
shell: bash
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
CLAUDE_CODE_VERSION: ${{ inputs.claude-code-version }}
run: |
set -euo pipefail
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR"
cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
shell: bash
env:
GATEWAY_BASE_URL: ${{ inputs.gateway-base-url }}
run: |
set -euo pipefail
mkdir -p "$HOME/.omnigent"
python3 -c "
import pathlib, os, json
gw = os.environ['GATEWAY_BASE_URL']
cfg = {'providers': {'databricks-gateway': {
'kind': 'gateway', 'default': ['anthropic'],
'anthropic': {
'base_url': gw + '/anthropic',
'api_key_ref': 'env:LLM_API_KEY',
'models': {'default': 'databricks-claude-opus-4-8'},
}}}}
pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2))
"
- name: Run the agent
shell: bash
env:
LLM_API_KEY: ${{ inputs.llm-api-key }}
WORKDIR: ${{ inputs.workdir }}
AGENT: ${{ inputs.agent }}
PROMPT_FILE: ${{ inputs.prompt-file }}
OUTPUT_FILE: ${{ inputs.output-file }}
STDERR_FILE: ${{ inputs.stderr-file }}
run: |
set -euo pipefail
project="${GITHUB_WORKSPACE}/${WORKDIR}"
prompt="$(cat "$PROMPT_FILE")"
uv run --project "$project" omnigent run \
"${project}/.github/agents/${AGENT}" \
-p "$prompt" --no-session \
2>"$STDERR_FILE" | tee "$OUTPUT_FILE" \
|| { echo "::warning::agent exited non-zero — caller keeps its fallback"; cat "$STDERR_FILE"; }
# ::add-mask:: only redacts rendered logs; the caller still redacts artifact
# files before upload. This aborts the run outright if the key leaked to stdout.
- name: Scan agent output for secrets
shell: bash
env:
LLM_API_KEY: ${{ inputs.llm-api-key }}
OUTPUT_FILE: ${{ inputs.output-file }}
run: |
set -euo pipefail
if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" "$OUTPUT_FILE" 2>/dev/null; then
echo "::error::Agent output contains LLM_API_KEY — aborting."
exit 1
fi
-37
View File
@@ -1,37 +0,0 @@
name: "setup-node"
description: "Set up Node and pin npm, with npm dependency caching keyed on the web lockfile."
# Single source of truth for the JS toolchain across CI. Pins npm to the
# EXACT version that regenerates the lockfile in oss-regenerate-and-smoke.yml
# (npm 11.12.1); without this, jobs use whatever npm Node 20 bundles
# (npm 10.x) and the `package-lock.json` freshness gate in lint.yml would
# flake on version-skew churn (dev/extraneous flags, metadata). Keep this
# version in lockstep with the regen workflow so generation and
# verification never diverge.
inputs:
node-version:
description: "Node version to use."
default: "20"
required: false
cache:
description: "Package-manager cache to enable (passed to actions/setup-node)."
default: "npm"
required: false
cache-dependency-path:
description: "Lockfile path used as the cache key."
default: "web/package-lock.json"
required: false
runs:
using: "composite"
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ inputs.node-version }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Pin npm
shell: bash
run: npm install -g npm@11.12.1
+21
View File
@@ -0,0 +1,21 @@
name: "setup-pnpm"
description: "Set up Node + pnpm for the web workspace"
inputs:
node-version:
description: "Node version to use."
default: "22"
required: false
runs:
using: composite
steps:
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
standalone: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
+108 -41
View File
@@ -17,9 +17,9 @@ name: feature-blog-drafter
description: >-
Drafts a single feature-blog post on omnigent-site for a scout-selected
feature. Inspects the live site to match conventions, confirms facts against
the omnigent code, writes a short one-screen post following the 5-part
skeleton, and marks the mandatory demo for a human. Writes blog prose only
never product code — and never commits or pushes (the workflow does that).
the omnigent code, and writes a short one-screen user-facing post, marking the
mandatory demo for a human. Writes blog prose only (never product code) and
never commits or pushes (the workflow does that).
executor:
type: omnigent
@@ -64,6 +64,15 @@ prompt: |
NEVER write product source code or tests, and you NEVER edit anything in the
omnigent code repo.
## Write ONLY the post page — the blog surface already exists
The blog infrastructure is already in place on omnigent-site: `app/blog/`
layout + index page auto-discover posts via `lib/blog.js`, and the nav links to
it. Your ONE and ONLY output file is `app/blog/<SLUG>/page.mdx`. You MUST NOT
create or edit any layout, index (`app/blog/page.js`), sidebar, `lib/` scanner,
navigation, or other site plumbing — dropping in the post page is enough for it
to appear. If you think infra is missing, flag it under "Manual review needed"
rather than scaffolding it (inventing plumbing can break the site build).
## Inputs (in the run prompt)
- `SITE_REPO` — absolute path to the omnigent-site checkout. It is your ONLY
WRITE target — write the new post there.
@@ -83,54 +92,112 @@ prompt: |
that detail rather than guess. You may read the omnigent code checkout to
confirm a command or a docs path.
## Step 2 — Inspect the live site and match conventions
## Step 2 — Match the existing post conventions (read-only)
This is why you have the whole site checked out. Before writing, read an
existing post under `app/blog/` (or, if none exists yet, a sibling MDX page such
as `app/releases/<version>/page.mdx`) and copy its frontmatter shape and JSX
conventions EXACTLY — the import lines, the metadata/frontmatter helper, and the
body structure. Find where blog posts are indexed/registered (an index page or
a nav array) and wire the new post in the same way the existing ones are.
existing post under `app/blog/` and copy its frontmatter shape and JSX
conventions EXACTLY — the import lines, the metadata/frontmatter helper, the
frontmatter fields (`date`, `category`, `author`, `heroArt`), and the body
structure. Posts are auto-discovered by `lib/blog.js`, so you do NOT register
the post anywhere — matching the existing post shape is all that's needed. Read
`lib/blog.js` only to confirm which frontmatter fields it expects; do not edit
it. Every post's title + author + date + reading-time header is rendered by the
`<BlogPostHeader slug="SLUG" />` component (registered globally in
`mdx-components.js`, so no import is needed) — use it as the first thing in the
body and never hand-write a `# H1` title (item 1 below).
## Step 3 — Write the post (short, one-screen, in-style)
Create `app/blog/<SLUG>/page.mdx` (adjust to match the site's actual blog path
convention if it differs). Follow this 5-part skeleton — keep the whole post to
roughly one screen; it is a changelog-blog entry, NOT a long-form article:
Create `app/blog/<SLUG>/page.mdx` — this is the ONLY file you write. Keep the
whole post to roughly one screen; it is a changelog-blog entry, NOT a long-form
article.
1. **What's new + who it's for** — the benefit `HEADLINE` as the title/H1, plus
a one-line "who it's for". Frontmatter carries `date: DATE`,
`category: CATEGORY`, and `author: "omnigent"` (default; a human may
overwrite it during review).
2. **The problem it solves** — 23 sentences. Benefit before mechanism: lead
with the user outcome, then the how. Keep our wedge as the through-line
(Omnigent is the orchestration layer over many agents, any device, with
governance) — imply it, don't sloganeer.
3. **Demo** — you CANNOT produce the screenshot/recording. Emit EXACTLY this
marker where the demo belongs, with a one-line suggestion of what to show:
`<!-- DEMO REQUIRED: 1530s recording or light/dark screenshot pair, realistic data. No sanitized mockups. Suggested: <what to show> -->`
4. **How to use** — a copy-pasteable fenced command block (only commands/flags
grounded in `MATERIAL_FILE`) and a link to the relevant docs page.
5. **What's next** — an optional one-line forward look ONLY if the material
supports it; otherwise omit the line. Do NOT write the closing CTA / star
ask — the workflow appends a fixed footer.
The five items below are the SHAPE of the post, in order — they are NOT section
headings and NOT sentence lead-ins. Write flowing prose. Do NOT emit label text
like "Who it's for:", "The problem it solves", "How to use", or "What's next" —
neither as headings nor at the start of a sentence. Do NOT write a Markdown
`# H1` title at all — the title and byline are rendered by the header component
(see item 1); a `#` heading would duplicate it. Use `##` for any in-body
subheadings only if genuinely needed (usually none for a one-screen post).
Do not add a hero image — leave any `heroArt` frontmatter blank for a human.
Be accurate and concise — no marketing fluff. Ground every fact in the
material; if unsure, omit it and note it under "Manual review needed".
1. Frontmatter first: after the `import { pageMeta } from "@/lib/og";` line and
the exported `metadata`, export a `meta` object carrying
`title: "<the benefit HEADLINE>"`, `date: "DATE"`, `category: "CATEGORY"`,
`author: "omnigent"` (default; a human may overwrite it during review), and
`heroArt: ""`. Then, as the FIRST thing in the body, render the header:
`<BlogPostHeader slug="SLUG" />` (use the exact SLUG you were given). This
component draws the title + author + date + reading-time byline, so do not
repeat the title as text. Follow it with a short opening paragraph that says
who it helps and what they can now do as a natural sentence ("If you drive
long agent runs in the web UI, you can now line up your next few messages
instead of waiting for each turn to finish."), NOT as a "Who it's for:" label.
2. In 23 sentences, describe the problem this removes and the outcome, in the
user's terms. Lead with what the user gets, then just enough of how it works
to be concrete. Let the "orchestration layer over many agents, any device,
with governance" wedge show through the framing; never sloganeer it.
3. The demo. You CANNOT produce the screenshot/recording, so emit EXACTLY this
marker where it belongs, with a one-line suggestion of what to show. It MUST
be an MDX comment (`{/* ... */}`), NOT an HTML comment (`<!-- ... -->`);
HTML comments are invalid in MDX and break the site build:
`{/* DEMO REQUIRED: 1530s recording or light/dark screenshot pair, realistic data. No sanitized mockups. Suggested: <what to show> */}`
4. Show how to use it: a short prose sentence plus, when the feature has one, a
copy-pasteable fenced command block (only commands/flags grounded in
`MATERIAL_FILE`), and a link to the relevant docs page. If it is a UI feature
with no command, describe the click path in one or two sentences instead.
5. Optionally close with one plain sentence on what is coming next, ONLY if the
material clearly supports it; otherwise stop. Do NOT write the closing CTA /
star ask — the workflow appends a fixed footer.
## Voice and content rules (IMPORTANT — the last drafts failed these)
- **User-facing, not implementation.** Write about what the reader can now DO,
never about how it is built or verified. Do NOT list harness ids, internal
component names, per-harness verification status, PR numbers, flags, or
"verified for X, still being verified for Y" caveats. If a capability works
across harnesses, say "works with any agent you run in Omnigent" — not a list
of `claude-sdk, codex-sdk, ...`. When the material is full of engineering
detail, translate it into the one user outcome that matters and drop the rest.
- **Few dashes.** Do NOT use " — " (spaced em/en dashes) as a sentence
connector; it reads as AI-generated. Write separate sentences, or use a comma,
"and", parentheses, or a colon. At most ONE dash in the whole post, and only
if nothing else fits. Do not use "not X but Y" or "It's not just … it's …"
constructions.
- **Plain and concrete.** Short sentences, active voice, no marketing adjectives
("powerful", "seamless", "effortless", "game-changing"), no hype. Prefer a
real example over an abstraction.
Leave `heroArt: ""` in the frontmatter. The workflow generates the hero image
from your `IMAGE_PROMPT` (below) and fills `heroArt` in; do not set it yourself.
Ground every fact in the material; if unsure, omit it and note it under
"Manual review needed".
## Output contract (your final assistant text)
On the line IMMEDIATELY BEFORE `<!-- BLOG_DRAFT_SUMMARY -->`, emit a single
`BLOG_PR_TITLE:` line — a concise, imperative summary grounded in the feature
(e.g. `BLOG_PR_TITLE: add feature blog for side-by-side harness sessions`).
Keep it under 60 characters, no trailing period, and do NOT prefix it with
`blog:` (the workflow adds that). This becomes the blog PR title.
Emit these two single-line fields (each on its own line), then the summary
block. Extraction is by prefix, so order between the two does not matter, but
`BLOG_PR_TITLE:` MUST be the line immediately before `<!-- BLOG_DRAFT_SUMMARY -->`.
- `IMAGE_PROMPT:` — one sentence describing a concrete visual SCENE that
depicts THIS feature's content, for an illustrated hero image. Describe the
subject only (what is happening, the objects/actors and their relationship),
grounded in what the feature actually does. Examples: for a queue/steer
feature, "a person lining up a stack of chat message cards that feed one at a
time into a working AI agent, with a hand redirecting one mid-flight"; for a
multi-harness feature, "several distinct robot agents plugging into a single
central hub that routes their work". Rules: NO text, words, letters, logos,
UI screenshots, charts, or watermarks in the scene; do NOT mention colors,
art style, aspect ratio, or "flat vector / navy / starfish" — the workflow
appends the fixed brand style. Just the subject.
- `BLOG_PR_TITLE:` — a concise, imperative summary grounded in the feature
(e.g. `BLOG_PR_TITLE: add feature blog for side-by-side harness sessions`).
Keep it under 60 characters, no trailing period, and do NOT prefix it with
`blog:` (the workflow adds that). This becomes the blog PR title.
Then, after a line containing exactly `<!-- BLOG_DRAFT_SUMMARY -->`, emit:
- `## Post drafted` — the path of the post file you created, plus any index /
nav file you edited: `path — what changed`.
- `## Post drafted` — the path of the single post file you created
(`app/blog/<SLUG>/page.mdx`). You should not have edited any other file.
- `## Manual review needed` — a checklist: `- [ ] <item> — <why>`. Always
include the mandatory demo line (the `<!-- DEMO REQUIRED -->` marker you left)
and the hero art + author byline as items a human must complete before merge.
Add any fact you had to omit for lack of grounding.
include the mandatory demo line (the `{/* DEMO REQUIRED */}` marker you left).
Note that the hero image is auto-generated from your `IMAGE_PROMPT` and the
author byline defaults to `omnigent`; list each as "review / optionally
replace" rather than a blocking task. Add any fact you had to omit for lack
of grounding.
Then STOP. Do NOT `git commit`, push, or open a PR — the workflow does that.
Leave your edits in SITE_REPO's working tree and print the summary.
@@ -22,7 +22,8 @@ spec_version: 1
name: feature-blog-scout
description: >-
Selects which features from a release's merged PRs (if any) are big enough to
warrant a feature-blog post. Applies a signal-based bar, caps at the top 23,
warrant a feature-blog post. Applies a signal-based bar, caps at the requested
limit (default top 23),
and emits a ranked BLOG_CANDIDATES JSON block (often empty). No tools, no
sub-agents — a pure selection turn.
@@ -72,8 +73,9 @@ prompt: |
thin to show.
- Collapse related PRs into ONE feature (as release notes do) — a feature is a
theme, not a PR.
- **Cap: the top 23, ranked strongest-first.** Even if more clear the bar,
return at most 3.
- **Cap: rank strongest-first and return at most the number of features the
run asks for** (the run prompt states the limit; default is the top 23).
Even if more clear the bar, never exceed that limit.
- **Final self-check per candidate — drop it if it fails:** can you picture the
1530s demo, and does a benefit headline beat naming the mechanism? (Signal 3
and this check are the same demo test — apply it as a filter and as a veto.)
@@ -0,0 +1,150 @@
# release-post-formatter — a tiny, single-purpose agent used by the
# publish-changelog.yml workflow at release-PUBLISH time.
#
# The GitHub Release notes stay as they are (crisp emoji bullets under
# "Major new features" / "Bug fixes"). This agent turns that already-published body
# into the narrative post the WEBSITE wants (mlflow.org/releases/<v>-style): an
# intro summary plus numbered sections for the OUTSTANDING features only — minor
# items and bug fixes are dropped — each explaining what the feature is and how to
# use it, with demo + docs-link placeholders a human fills in before merge. No PR
# links, no emoji. It invents no new facts, versions, or flag names.
# It has NO tools and NO sub-agents, so a run is fast, cheap, and can't hang. The
# workflow drops its output into the site page; on any failure the publish step
# falls back to the raw release body.
#
# Run headlessly: omnigent run .github/agents/release-post-formatter -p "<release body>" --no-session
#
# Security posture (mirrors release-notes-drafter / doc-drafter):
# - Runs only on an ALREADY-PUBLISHED, maintainer-curated release body, at
# publish time on the trusted default branch.
# - The only secret in this process's env is LLM_API_KEY. The omnigent-site
# write-token that opens the release-post PR is minted by the workflow AFTER
# this agent finishes, so it never coexists with model input.
# - Its input is maintainer-written release text — a prose prompt-injection
# surface. The workflow secret-scans this agent's stdout for LLM_API_KEY
# (abort on hit) and redacts artifacts, and a human reviews the site PR before
# merge. Honest residual risk: with network allowed and LLM_API_KEY in env, an
# injection could drive an outbound request that exfiltrates the key; a
# network-denying sandbox is the real mitigation but is not used here for the
# same CI-fragility reason documented in release-notes-drafter/config.yaml.
# We accept the same residual risk already accepted for release-notes-drafter.
spec_version: 1
name: release-post-formatter
description: >-
Turns an already-curated GitHub Release body into the narrative website post: a
short intro summary plus a handful of numbered sections for the OUTSTANDING
features only (minor items and bug fixes are dropped), each explaining what the
feature is and how to use it. Links features to a real site docs page when one
matches (from a provided list), else omits the link; leaves a demo placeholder
for a human. No PR links, no emoji. Emits the post between RELEASE_POST markers.
No tools, no sub-agents.
executor:
type: omnigent
config:
harness: claude-sdk
prompt: |
You are the Omnigent release-POST formatter. A version has just been published.
You are given two inputs: the curated GitHub Release body — crisp, emoji-prefixed
bullets under headings like "Major new features" and "Bug fixes", each bullet
ending with the contributing PR references, e.g. `(#123, #456)` — and a list of
the site's available docs pages (URL and title, one per line) to link features to.
Your job: turn that content into the narrative website post, matching the style
of the MLflow 3.14.0 release post (https://mlflow.org/releases/3.14.0/) — see
"The MLflow 3.14.0 style" below for exactly what that means. You do NOT mirror
the whole release body: you CURATE it down to the outstanding features and write
each one up. You must not invent features, versions, or flag names.
## Output shape (STRICT)
Emit ONLY the following, between the markers, and nothing else — no preamble, no
top-level `# vX.Y.Z` heading (the site adds the title, date, and byline):
<!-- RELEASE_POST -->
<one-paragraph intro summary — see the intro pattern below; flowing prose, no
bullet list>
## 1. <Feature title — a noun phrase naming the feature/command>
![TODO: add a demo screenshot or GIF for "<feature title>"](TODO)
<1-3 short paragraphs of prose, present tense, addressing the reader as "you":
first what the feature IS and the problem it solves, then HOW to use it — the
command, menu, or workflow. No PR references anywhere.>
_Learn more in the [<matching docs page title>](<its URL from the docs list>)._
## 2. <Next outstanding feature>
...
Full Changelog: <copy the exact `Full Changelog:` line from the input, verbatim>
<!-- /RELEASE_POST -->
## The MLflow 3.14.0 style (match this)
- CURATE, don't mirror. Pick only the ~4-6 OUTSTANDING, headline features and
give each its own numbered section. DROP minor features, small tweaks, and
everything under "Bug fixes". There is NO "Bug fixes" / "Fixes & improvements"
section — omit it entirely. (MLflow 3.14.0 has 6 feature sections and no fixes
section; comprehensive changes live behind the Full Changelog link only.)
- Intro: ONE flowing paragraph. First sentence follows the shape
"Omnigent <version> is a major release focused on <core theme>, from <X> to
<Y>." — theme and X→Y span drawn from the outstanding features. Then a sentence
or two naming the biggest ones as prose. No bullets.
(MLflow's reads: "MLflow 3.14.0 is a major release focused on closing the GenAI
development loop, from getting an app instrumented in the first place to
reviewing, testing, and iterating on it.")
- Headings: `## N. <Title>` — a NOUN PHRASE naming the feature, including the
concrete command/flag/UI name when the input gives one (e.g.
"## 1. Omnigent for iOS"). Never a verb phrase.
- Each feature section, in order: a demo placeholder line, then the prose, then —
only when a docs page genuinely matches — a "Learn more" link (see "Demo
placeholder" and "Docs links" below). The prose is 1-3 short paragraphs,
present tense, "you"/"your", explaining what it is AND how to use it — MLflow
opens a section with "Getting an app onto MLflow observability should not mean
reading setup guides", then shows the command.
- Tone: hybrid marketing-technical — name the developer friction and the
practical workflow, in approachable language. Conversational, not cutesy.
## Demo placeholder (a human fills this before merge)
The release body carries no demo media, so you cannot produce it — emit a clear
placeholder immediately under EACH feature heading:
`![TODO: add a demo screenshot or GIF for "<feature title>"](TODO)`
Use the literal token `TODO` so a reviewer can grep for it. Never fabricate a
real-looking image path.
## Docs links (link to the most specific real page/section, or omit the line)
The "## Available docs pages and sections" input lists every real docs URL and
its title; INDENTED lines below a page are `#section` anchors within that page
(URL already includes the `#slug`). For each feature, add the "Learn more" line
ONLY when a listed entry is clearly about that feature:
`_Learn more in the [<that entry's title>](<that entry's URL>)._`
Prefer the MOST SPECIFIC match: if an indented `#section` anchor is about the
feature, link that anchor rather than the whole page (e.g. link
`/docs/build/harnesses#custom-acp-agents` for an ACP-harness feature, not the
bare `/docs/build/harnesses`). Fall back to the page URL only when no section
fits better.
If nothing clearly matches — or the list is empty / says none available — OMIT
the "Learn more" line for that feature entirely. Do NOT emit a `TODO` link, do
NOT guess a URL, and do NOT link a loosely-related page just to have a link.
## Fidelity rules
- NO PR references. Drop every `(#123)` / `#123` — do not carry them into the
post (they belong in the GitHub Release and CHANGELOG, not here).
- Prose, not bullets: turn "- 📱 X — Y" into sentences. Drop ALL emoji.
- Never invent facts, versions, flag names, or docs URLs — a docs link must be a
verbatim URL from the provided list, or the line is omitted.
- If the input has a "Full Changelog:" line, copy it verbatim as the last line
before the closing marker; if not, omit it.
- Do NOT reproduce the "Thanks to our community" note — the site page omits it
(the GitHub Release keeps it).
## Security
You are running in CI with access to secrets. Never echo secrets, tokens, or
credentials, and never make outbound network calls.
## Act in the same turn you announce
Never end a turn after only saying what you will do — produce the RELEASE_POST
block in the same turn.
+1 -2
View File
@@ -58,8 +58,7 @@
],
"owners": [
"serena-ruan",
"daniellok-db",
"hzub"
"daniellok-db"
]
},
{
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex, pi).",
"dependencies": {
"@anthropic-ai/claude-code": "2.1.163",
"@anthropic-ai/claude-code": "2.1.212",
"@earendil-works/pi-coding-agent": "0.79.0",
"@openai/codex": "0.139.0"
}
+37 -6
View File
@@ -3,14 +3,18 @@
The website's `/releases/<version>` post is the *concise, curated highlights* —
it mirrors the GitHub Release notes a maintainer already hand-edits in the
draft→edit→publish flow. This module does a small mechanical transform so that
GitHub-flavoured Markdown renders cleanly through the site's MDX pipeline
(`@next/mdx`):
draft→edit→publish flow. The narrative body (intro summary + numbered feature
sections) is written by the release-notes-drafter agent; this module does a small
mechanical transform so that GitHub-flavoured Markdown renders cleanly through the
site's MDX pipeline (`@next/mdx`), and wraps it in the site-only chrome the
release body can't carry (a byline and a "What's Next" footer):
* unwrap `<https://…>` autolinks (angle brackets are JSX in MDX),
* escape `{`, `}`, and any remaining `<` so MDX never tries to evaluate them,
* linkify bare `#1234` references to the PR,
* prepend a `# vX.Y.Z` heading + a `_Released <date>_` line the index reads.
* prepend a `# vX.Y.Z` heading + a byline (`_Released <date>_` the exact token
the site index reads — plus estimated read time and author),
* append a static "What's Next" footer (install command + community links).
No LLM, no reflow — the curation is the human's; we only make it MDX-safe.
"""
@@ -28,6 +32,22 @@ _AUTOLINK_RE = re.compile(r"<((?:https?://)[^>\s]+)>")
# "# Title" (space after #), so they never match.
_PR_REF_RE = re.compile(r"(?<![\w/#])#(\d+)\b")
AUTHOR = "Omnigent maintainers"
# Average adult reading speed; used only for the "N min read" byline estimate.
_WORDS_PER_MINUTE = 200
WHATS_NEXT = """## What's Next
Install or upgrade Omnigent:
```bash
uv tool install --python 3.12 omnigent # or: pip install "omnigent"
```
- Star the project and file issues on [GitHub](https://github.com/omnigent-ai/omnigent).
- Join the conversation on our [Discord](https://discord.gg/omnigent).
- Browse the [docs](https://omnigent.ai/docs) to go deeper."""
def mdx_escape(text: str) -> str:
"""Make GitHub-flavoured Markdown safe to parse as MDX."""
@@ -44,6 +64,12 @@ def linkify_pr_refs(text: str, repo: str) -> str:
)
def _read_time_minutes(text: str) -> int:
"""Estimate reading time in whole minutes (>=1) from a word count."""
words = len(text.split())
return max(1, round(words / _WORDS_PER_MINUTE))
def release_body_to_mdx(tag: str, date: str, body: str, repo: str) -> str:
"""Render the MDX page for one release."""
transformed = linkify_pr_refs(mdx_escape(body or ""), repo)
@@ -52,8 +78,13 @@ def release_body_to_mdx(tag: str, date: str, body: str, repo: str) -> str:
+ tag
+ ". Edit the GitHub Release, not this file. */}"
)
header = f"{comment}\n\n# {tag}\n\n_Released {date}_\n\n"
return header + transformed.strip() + "\n"
# Byline mirrors the MLflow release-post layout: keep the exact
# `_Released <date>_` token the site index regex reads, then append the
# read-time estimate and author on the same line.
minutes = _read_time_minutes(transformed)
byline = f"_Released {date}_ · {minutes} min read · {AUTHOR}"
header = f"{comment}\n\n# {tag}\n\n{byline}\n\n"
return header + transformed.strip() + "\n\n" + WHATS_NEXT + "\n"
def _tag_date(tag: str) -> str:
@@ -3,7 +3,7 @@
# `e2e_matrix` and `integration_matrix`.
#
# We test `main` (the checked-out code = client + tests, always) against each
# non-rc release tag AT OR ABOVE the backcompat floor (MIN_VERSION, default
# final (non-prerelease) release tag AT OR ABOVE the backcompat floor (MIN_VERSION, default
# 0.2.0 — the first release with the mock-LLM e2e infra; see below), on BOTH
# axes — and ONLY those cells:
# (server=main, runner=<release>) — new server vs a previously-shipped runner
@@ -17,8 +17,9 @@
#
# Env in:
# VERSIONS optional comma-separated override of the version set used for
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all non-rc tags.
# Blank entries are dropped and surrounding whitespace trimmed.
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all final
# (non-prerelease) tags. Blank entries are dropped and
# surrounding whitespace trimmed.
# NUM_SHARDS e2e shard count per cell (default 4).
# Out (GITHUB_OUTPUT):
# e2e_matrix={"include":[{"server":..,"runner":..,"shard_id":..,"num_shards":..}, ...]}
@@ -61,9 +62,12 @@ if [ -n "${VERSIONS:-}" ]; then
IFS=',' read -ra raw <<<"$VERSIONS"
else
raw=("main")
# `[^a-z]rc[0-9]` so we drop vX.Y.ZrcN without over-excluding tags that merely
# contain the substring "rc" (e.g. a hypothetical "...march").
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])rc[0-9]')
# Drop pre-release tags (vX.Y.ZrcN / .devN / preN — same trio github-release.yml
# skips): they are snapshots of main, so main-vs-them is not a compat signal, and
# under the 256-job cap they would evict the oldest FINAL releases from coverage.
# `[^a-z]` guards against over-excluding tags that merely contain the substring
# (e.g. a hypothetical "...march"). An explicit VERSIONS override still accepts them.
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]')
fi
# Trim whitespace, drop blanks, reject invalid tokens, drop below-floor releases.
+4
View File
@@ -7,6 +7,7 @@
# Generated file -- do not hand-edit; it is replaced wholesale on every sync.
REQUIRED=(
"DCO"
"Pre-commit checks"
"Docker build"
"Pytest (runtime-harnesses)"
@@ -32,6 +33,7 @@ REQUIRED=(
"E2E UI Tests (shard 0/3)"
"E2E UI Tests (shard 1/3)"
"E2E UI Tests (shard 2/3)"
"UI Snapshot (visual baselines)"
"Integration (claude-sdk)"
"Integration (openai-agents)"
"Integration (codex)"
@@ -62,6 +64,7 @@ ALLOW_SKIP=(
"E2E UI Tests (shard 0/3)"
"E2E UI Tests (shard 1/3)"
"E2E UI Tests (shard 2/3)"
"UI Snapshot (visual baselines)"
"Integration (claude-sdk)"
"Integration (openai-agents)"
"Integration (codex)"
@@ -79,6 +82,7 @@ workflow_for() {
"Pytest ("*) echo "CI" ;;
"E2E Tests (shard "*) echo "E2E Tests" ;;
"E2E UI Tests (shard "*) echo "E2E UI Tests" ;;
"UI Snapshot (visual baselines)") echo "UI Snapshot" ;;
"Integration ("*) echo "Integration Tests" ;;
*) echo "" ;;
esac
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""Keep the waiting-on-author pull request label actionable."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
from email.message import Message
from typing import Any
LABEL = "waiting-on-author"
WAITING_DAYS = 7
CANONICAL_REPO = "omnigent-ai/omnigent"
MAX_CLOSURES_PER_RUN = 30
def label_names(item: dict[str, Any]) -> list[str]:
return [
label.get("name", label) if isinstance(label, dict) else label
for label in item.get("labels", [])
]
def has_waiting_label(item: dict[str, Any]) -> bool:
return LABEL in label_names(item)
def parse_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def days_between(start: str, end: datetime) -> int:
return int((end - parse_time(start)).total_seconds() // 86400)
def latest_waiting_label_at(timeline: list[dict[str, Any]]) -> str | None:
latest: str | None = None
for event in timeline:
if event.get("event") != "labeled" or not event.get("created_at"):
continue
label = event.get("label") or {}
name = label.get("name") if isinstance(label, dict) else label
if name != LABEL:
continue
if latest is None or parse_time(event["created_at"]) > parse_time(latest):
latest = event["created_at"]
return latest
def close_message(label_applied_at: str) -> str:
return "\n".join(
[
f"Closing this PR because it has been labeled `{LABEL}` for "
f"{WAITING_DAYS} days without an author reply or new commit.",
"",
f"The label was last applied on {label_applied_at}. If you are "
"ready to continue, please reopen this PR or open a new one.",
]
)
class GitHubAPI:
def __init__(self, token: str, repo: str):
self.token = token
self.repo = repo
def request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> tuple[Any, Message]:
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(
f"https://api.github.com{path}",
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request) as response:
raw = response.read()
parsed = json.loads(raw.decode()) if raw else None
return parsed, response.headers
def paginated(self, path: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
next_path: str | None = path
while next_path:
page, headers = self.request("GET", next_path)
items.extend(page or [])
next_path = next_link(headers.get("Link", ""))
return items
def get_pull(self, pull_number: int) -> dict[str, Any]:
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
return pull
def remove_label(self, issue_number: int, label: str) -> bool:
quoted = urllib.parse.quote(label, safe="")
try:
self.request("DELETE", f"/repos/{self.repo}/issues/{issue_number}/labels/{quoted}")
except urllib.error.HTTPError as error:
if error.code == 404:
return False
raise
return True
def list_waiting_issues(self) -> list[dict[str, Any]]:
query = urllib.parse.urlencode({"state": "open", "labels": LABEL, "per_page": 100})
return self.paginated(f"/repos/{self.repo}/issues?{query}")
def list_timeline(self, issue_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/issues/{issue_number}/timeline?per_page=100")
def list_issue_comments(self, issue_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/issues/{issue_number}/comments?per_page=100")
def list_review_comments(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/comments?per_page=100")
def list_reviews(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/reviews?per_page=100")
def list_commits(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/commits?per_page=100")
def close_pull(self, pull_number: int) -> None:
self.request("PATCH", f"/repos/{self.repo}/pulls/{pull_number}", {"state": "closed"})
def create_comment(self, issue_number: int, body: str) -> None:
self.request("POST", f"/repos/{self.repo}/issues/{issue_number}/comments", {"body": body})
def next_link(link_header: str) -> str | None:
for part in link_header.split(","):
url_part, _, rel_part = part.partition(";")
if 'rel="next"' not in rel_part:
continue
url = url_part.strip()[1:-1]
parsed = urllib.parse.urlparse(url)
return f"{parsed.path}?{parsed.query}"
return None
def remove_waiting_label(api: GitHubAPI, issue_number: int, reason: str) -> bool:
removed = api.remove_label(issue_number, LABEL)
if removed:
print(f"Removed {LABEL} from #{issue_number}: {reason}")
else:
print(f"#{issue_number} no longer has {LABEL}; nothing to remove.")
return removed
def user_login(item: dict[str, Any]) -> str | None:
login = item.get("user", {}).get("login")
return login.lower() if login else None
def is_after(timestamp: str | None, since: str) -> bool:
return bool(timestamp and parse_time(timestamp) > parse_time(since))
def authored_after(items: list[dict[str, Any]], author: str, since: str, key: str) -> bool:
return any(user_login(item) == author and is_after(item.get(key), since) for item in items)
def commit_after(commits: list[dict[str, Any]], since: str) -> bool:
for commit in commits:
authored_at = commit.get("commit", {}).get("author", {}).get("date")
committed_at = commit.get("commit", {}).get("committer", {}).get("date")
if is_after(authored_at, since) or is_after(committed_at, since):
return True
return False
def author_activity_since_label(api: GitHubAPI, pull: dict[str, Any], since: str) -> str | None:
author = pull.get("user", {}).get("login")
if not author:
return None
author = author.lower()
pull_number = pull["number"]
if authored_after(api.list_issue_comments(pull_number), author, since, "created_at"):
return "the author commented"
if authored_after(api.list_review_comments(pull_number), author, since, "created_at"):
return "the author replied to a review comment"
if authored_after(api.list_reviews(pull_number), author, since, "submitted_at"):
return "the author submitted a review response"
if commit_after(api.list_commits(pull_number), since):
return "new commits were pushed"
return None
def clear_on_author_activity(event_name: str, payload: dict[str, Any], api: GitHubAPI) -> bool:
pull_number: int | None = None
actor: str | None = None
reason: str | None = None
author_activity = False
if event_name in {"pull_request", "pull_request_target"} and payload.get("pull_request"):
if payload.get("action") != "synchronize":
return False
pull_number = payload["pull_request"]["number"]
reason = "new commits were pushed"
author_activity = True
elif event_name == "issue_comment" and "pull_request" in payload.get("issue", {}):
pull_number = payload["issue"]["number"]
actor = payload.get("comment", {}).get("user", {}).get("login")
reason = "the author commented"
elif event_name == "pull_request_review_comment" and payload.get("pull_request"):
pull_number = payload["pull_request"]["number"]
actor = payload.get("comment", {}).get("user", {}).get("login")
reason = "the author replied to a review comment"
elif event_name == "pull_request_review" and payload.get("pull_request"):
pull_number = payload["pull_request"]["number"]
actor = payload.get("review", {}).get("user", {}).get("login")
reason = "the author submitted a review response"
else:
return False
if pull_number is None or reason is None:
return False
pull = api.get_pull(pull_number)
if pull.get("state") != "open" or not has_waiting_label(pull):
return False
if not author_activity:
author = pull.get("user", {}).get("login")
author_activity = bool(actor and author and actor.lower() == author.lower())
if not author_activity:
return False
return remove_waiting_label(api, pull_number, reason)
def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
now = now or datetime.now(UTC)
closed = 0
for issue in api.list_waiting_issues():
if closed >= MAX_CLOSURES_PER_RUN:
break
if "pull_request" not in issue or not has_waiting_label(issue):
continue
try:
label_applied_at = latest_waiting_label_at(api.list_timeline(issue["number"]))
if label_applied_at is None:
print(
f"::warning::#{issue['number']} has {LABEL} but no label timestamp "
"in the timeline; skipping."
)
continue
pull = api.get_pull(issue["number"])
reason = author_activity_since_label(api, pull, label_applied_at)
if reason:
remove_waiting_label(api, issue["number"], reason)
continue
if days_between(label_applied_at, now) < WAITING_DAYS:
continue
api.close_pull(issue["number"])
api.create_comment(issue["number"], close_message(label_applied_at))
closed += 1
print(f"Closed #{issue['number']}; {LABEL} was applied at {label_applied_at}.")
except Exception as error: # noqa: BLE001 - keep the sweep moving across PRs.
print(f"::warning::Could not close #{issue['number']}: {error}")
print(f"Closed {closed} PR(s) labeled {LABEL}.")
return closed
def run(
event_name: str,
payload: dict[str, Any],
api: GitHubAPI,
repo: str,
now: datetime | None = None,
) -> None:
if repo != CANONICAL_REPO:
print(f"Skipping {repo}; waiting-on-author hygiene only runs for {CANONICAL_REPO}.")
return
if event_name in {"schedule", "workflow_dispatch"}:
close_stale_waiting_prs(api, now=now)
return
clear_on_author_activity(event_name, payload, api)
def load_event_payload() -> dict[str, Any]:
path = os.environ.get("GITHUB_EVENT_PATH")
if not path:
return {}
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def main() -> int:
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("GITHUB_TOKEN")
if not token:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Offline tests for waiting_on_author.py."""
from __future__ import annotations
import importlib.util
import pathlib
import unittest
from datetime import UTC, datetime
from typing import Any
SCRIPT_PATH = pathlib.Path(__file__).with_name("waiting_on_author.py")
SPEC = importlib.util.spec_from_file_location("waiting_on_author", SCRIPT_PATH)
waiting_on_author = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(waiting_on_author)
def pr(
number: int = 12, author: str = "alice", labels: list[str] | None = None, state: str = "open"
) -> dict[str, Any]:
labels = [waiting_on_author.LABEL] if labels is None else labels
return {
"number": number,
"state": state,
"user": {"login": author},
"labels": [{"name": label} for label in labels],
}
def issue(number: int, labels: list[str] | None = None, is_pr: bool = True) -> dict[str, Any]:
labels = [waiting_on_author.LABEL] if labels is None else labels
item: dict[str, Any] = {"number": number, "labels": [{"name": label} for label in labels]}
if is_pr:
item["pull_request"] = {}
return item
def labeled_at(iso: str, label: str | None = None) -> dict[str, Any]:
return {
"event": "labeled",
"label": {"name": label or waiting_on_author.LABEL},
"created_at": iso,
}
class FakeAPI:
def __init__(
self,
*,
pull: dict[str, Any] | None = None,
issues: list[dict[str, Any]] | None = None,
timeline_by_issue: dict[int, list[dict[str, Any]]] | None = None,
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
review_comments: dict[int, list[dict[str, Any]]] | None = None,
reviews: dict[int, list[dict[str, Any]]] | None = None,
commits: dict[int, list[dict[str, Any]]] | None = None,
):
self.pull = pull or pr()
self.issues = issues or []
self.timeline_by_issue = timeline_by_issue or {}
self.issue_comments = issue_comments or {}
self.review_comments = review_comments or {}
self.reviews = reviews or {}
self.commits = commits or {}
self.removed: list[tuple[int, str]] = []
self.closed: list[int] = []
self.comments: list[tuple[int, str]] = []
def get_pull(self, pull_number: int) -> dict[str, Any]:
return self.pull | {"number": pull_number}
def remove_label(self, issue_number: int, label: str) -> bool:
self.removed.append((issue_number, label))
return True
def list_waiting_issues(self) -> list[dict[str, Any]]:
return self.issues
def list_timeline(self, issue_number: int) -> list[dict[str, Any]]:
return self.timeline_by_issue.get(issue_number, [])
def list_issue_comments(self, issue_number: int) -> list[dict[str, Any]]:
return self.issue_comments.get(issue_number, [])
def list_review_comments(self, pull_number: int) -> list[dict[str, Any]]:
return self.review_comments.get(pull_number, [])
def list_reviews(self, pull_number: int) -> list[dict[str, Any]]:
return self.reviews.get(pull_number, [])
def list_commits(self, pull_number: int) -> list[dict[str, Any]]:
return self.commits.get(pull_number, [])
def close_pull(self, pull_number: int) -> None:
self.closed.append(pull_number)
def create_comment(self, issue_number: int, body: str) -> None:
self.comments.append((issue_number, body))
class WaitingOnAuthorTest(unittest.TestCase):
def test_latest_waiting_label_at_uses_latest_matching_label(self) -> None:
self.assertEqual(
waiting_on_author.latest_waiting_label_at(
[
labeled_at("2026-07-01T00:00:00Z"),
labeled_at("2026-07-10T00:00:00Z", "other"),
labeled_at("2026-07-12T00:00:00Z"),
]
),
"2026-07-12T00:00:00Z",
)
def test_author_issue_comment_removes_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="Alice"))
waiting_on_author.clear_on_author_activity(
"issue_comment",
{"issue": {"number": 12, "pull_request": {}}, "comment": {"user": {"login": "alice"}}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_author_review_thread_reply_removes_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"pull_request_review_comment",
{"pull_request": {"number": 12}, "comment": {"user": {"login": "alice"}}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_maintainer_comment_keeps_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"issue_comment",
{
"issue": {"number": 12, "pull_request": {}},
"comment": {"user": {"login": "maintainer"}},
},
api,
)
self.assertEqual(api.removed, [])
def test_new_commits_remove_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"pull_request_target",
{"action": "synchronize", "pull_request": {"number": 12}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_scheduled_sweep_closes_pr_after_7_days(self) -> None:
api = FakeAPI(
issues=[issue(20)], timeline_by_issue={20: [labeled_at("2026-07-17T00:00:00Z")]}
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [20])
self.assertEqual(len(api.comments), 1)
self.assertIn(waiting_on_author.LABEL, api.comments[0][1])
def test_scheduled_sweep_removes_label_after_author_comment(self) -> None:
api = FakeAPI(
pull=pr(number=23, author="alice"),
issues=[issue(23)],
timeline_by_issue={23: [labeled_at("2026-07-01T00:00:00Z")]},
issue_comments={
23: [{"user": {"login": "alice"}, "created_at": "2026-07-20T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [(23, waiting_on_author.LABEL)])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_keeps_label_after_maintainer_comment(self) -> None:
api = FakeAPI(
pull=pr(number=24, author="alice"),
issues=[issue(24)],
timeline_by_issue={24: [labeled_at("2026-07-18T00:00:00Z")]},
issue_comments={
24: [{"user": {"login": "maintainer"}, "created_at": "2026-07-20T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_removes_label_after_new_commit(self) -> None:
api = FakeAPI(
pull=pr(number=25, author="alice"),
issues=[issue(25)],
timeline_by_issue={25: [labeled_at("2026-07-01T00:00:00Z")]},
commits={25: [{"commit": {"author": {"date": "2026-07-20T00:00:00Z"}}}]},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [(25, waiting_on_author.LABEL)])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_ignores_author_comment_before_label(self) -> None:
api = FakeAPI(
pull=pr(number=26, author="alice"),
issues=[issue(26)],
timeline_by_issue={26: [labeled_at("2026-07-17T00:00:00Z")]},
issue_comments={
26: [{"user": {"login": "alice"}, "created_at": "2026-07-10T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [])
self.assertEqual(api.closed, [26])
def test_scheduled_sweep_leaves_6_day_pr_open(self) -> None:
api = FakeAPI(
issues=[issue(21)], timeline_by_issue={21: [labeled_at("2026-07-18T00:00:00Z")]}
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [])
self.assertEqual(api.comments, [])
def test_scheduled_sweep_skips_missing_label_timestamp(self) -> None:
api = FakeAPI(issues=[issue(22)], timeline_by_issue={22: []})
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [])
def test_scheduled_sweep_caps_closures_per_run(self) -> None:
issues = [issue(100 + idx) for idx in range(waiting_on_author.MAX_CLOSURES_PER_RUN + 3)]
timeline = {item["number"]: [labeled_at("2026-07-01T00:00:00Z")] for item in issues}
api = FakeAPI(issues=issues, timeline_by_issue=timeline)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(len(api.closed), waiting_on_author.MAX_CLOSURES_PER_RUN)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
name: Android Bundle
# Builds an unsigned release AAB in CI and uploads it as a workflow artifact.
# Download the artifact and sign it locally with your upload keystore — no
# secrets on GitHub, no signing key in CI.
on:
workflow_dispatch:
inputs:
version-code:
description: "versionCode (must be higher than the last uploaded to Play; starts at 3)"
required: true
type: string
version-note:
description: "Optional note appended to the artifact filename (e.g. rc1)"
required: false
default: ""
pull_request:
paths:
- ".github/workflows/android-bundle.yml"
- "web/android/**"
permissions:
contents: read
jobs:
build:
name: Build unsigned AAB
runs-on: ubuntu-latest
defaults:
run:
working-directory: web/android
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 17
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: 17
- name: Set up Gradle
uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4
with:
cache-read-only: false
- name: Build release AAB
run: ./gradlew bundleRelease --no-daemon --console=plain -PversionCode=${{ github.event.inputs.version-code }}
- name: Verify artifact
run: |
AAB=app/build/outputs/bundle/release/app-release.aab
test -f "$AAB" || { echo "::error::AAB not found at $AAB"; exit 1; }
echo "AAB size: $(du -h "$AAB" | cut -f1)"
- name: Upload artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-android-aab${{ inputs.version-note && format('-{0}', inputs.version-note) || '' }}
path: web/android/app/build/outputs/bundle/release/app-release.aab
retention-days: 30
+17 -5
View File
@@ -15,6 +15,7 @@ on:
paths:
- "omnigent/db/migrations/**"
- "omnigent/stores/**"
- "dev/benchmarks/**"
- ".github/workflows/benchmark-pr.yml"
permissions:
@@ -152,14 +153,25 @@ jobs:
} > comment_body.md
- name: Post PR comment
# Fork PRs have a read-only GITHUB_TOKEN so the comment may fail —
# that's acceptable; results are still available in the artifact.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--edit-last \
--body-file comment_body.md || \
gh pr comment "${{ github.event.pull_request.number }}" \
--body-file comment_body.md
COMMENT_MARKER="<!-- benchmark-pr-comment -->"
COMMENT_ID=$(gh api --paginate \
"repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" \
| head -1)
if [ -n "$COMMENT_ID" ]; then
gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \
-X PATCH -f body="$(cat comment_body.md)"
else
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file comment_body.md
fi
- name: Upload candidate results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+8
View File
@@ -37,6 +37,10 @@ on:
description: "Seeded items per session"
required: false
default: "200"
network_delay_ms:
description: "Simulated client→server latency per request (ms; 0 = loopback)"
required: false
default: "0"
permissions:
contents: read
@@ -51,6 +55,9 @@ env:
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
SESSIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.sessions || '5000' }}
ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items_per_session || '200' }}
# 0 on the nightly schedule (loopback, for stable trend data); dispatchable
# higher to model a real network hop when testing network optimizations.
NETWORK_DELAY_MS: ${{ github.event_name == 'workflow_dispatch' && inputs.network_delay_ms || '0' }}
concurrency:
# Never cancel a scheduled run mid-flight (each is a distinct data point).
@@ -179,6 +186,7 @@ jobs:
--database-uri "${{ steps.db.outputs.uri }}" \
--iterations "$ITERATIONS" \
--runs "$RUNS" \
--network-delay-ms "$NETWORK_DELAY_MS" \
--output "benchmark-results-${{ matrix.backend }}.json"
- name: Upload benchmark results
+1 -1
View File
@@ -139,6 +139,6 @@ jobs:
--title "Bump version to ${resolved}" \
--body "Automated version bump via \`.github/workflows/bump-version.yml\` (mode: \`${MODE}\`, input: \`${NEW_VERSION}\`).
Rewrote \`[project].version\` and sibling \`==\` pins across all three packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`), the runtime \`VERSION\` constant in \`omnigent/version.py\`, and regenerated \`uv.lock\`.
Rewrote \`[project].version\` and sibling \`==\` pins across all four packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`, \`integrations/slack\`), the runtime \`VERSION\` constant in \`omnigent/version.py\`, and regenerated \`uv.lock\`.
Generated by \`scripts/update_versions.py\`. Opened via the omnigent-ci App when configured (CI runs automatically); on the GITHUB_TOKEN fallback, re-open or push to kick CI."
+21 -6
View File
@@ -141,6 +141,17 @@ jobs:
paths: tests/db tests/deploy
extra: databricks
markexpr: databricks
# Slack integration (integrations/slack). Its tests live outside the
# top-level tests/ tree and import the decoupled `omnigent_slack`
# package, so this lane installs the `slack` extra to pull it in. The
# tests are run from the repo root on purpose: they rely on the root
# pyproject's `asyncio_mode = auto` (rootdir resolution), and
# coverage of the omnigent package is a no-op here (the code under
# test is omnigent_slack, not omnigent) — harmless, kept for a
# uniform pytest step.
- group: slack
paths: integrations/slack/tests
extra: slack
steps:
- name: Check out repo
@@ -359,14 +370,14 @@ jobs:
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install codex CLI
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm install --ignore-scripts --prefix .github/ci-deps
pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Cache virtualenv
@@ -414,8 +425,12 @@ jobs:
# Combines per-shard coverage into a coverage-summary artifact. Runs in the
# unprivileged pull_request context (read-only); code-coverage.yml consumes
# the artifact and posts the status. Report-only.
#
# Only runs when every pytest shard passed: a failed shard drops its covered
# lines from the combine, so coverage off partial data would be misleading —
# and a red run gets re-run anyway, re-triggering this.
needs: pytest
if: ${{ !cancelled() && !github.event.pull_request.draft }}
if: ${{ success() && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
+7 -3
View File
@@ -254,10 +254,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
+2 -1
View File
@@ -31,7 +31,8 @@ on:
- 'pyproject.toml'
- 'setup.py'
- 'uv.lock'
- 'web/package-lock.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/docker-build.yml'
permissions:
+15 -71
View File
@@ -89,14 +89,14 @@ jobs:
base="${INPUT_BASE:-}"
proceed=false; dry_run=false
# Does the tag look like a final release (vX.Y.Z, not rc/dev/alpha/beta)?
# Does the tag look like a final release (vX.Y.Z, not a pre-release)?
is_version=true
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_version=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_version=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*) is_version=false ;;
esac
if [ "$EVENT_NAME" = "workflow_run" ]; then
@@ -199,52 +199,6 @@ jobs:
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Set up uv
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Cache virtualenv
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
- name: Install Claude Code CLI
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
run: |
mkdir -p "$HOME/.omnigent"
python3 -c "
import pathlib, os, json
gw = os.environ['GATEWAY_BASE_URL']
cfg = {'providers': {'databricks-gateway': {
'kind': 'gateway', 'default': ['anthropic'],
'anthropic': {
'base_url': gw + '/anthropic',
'api_key_ref': 'env:LLM_API_KEY',
'models': {'default': 'databricks-claude-opus-4-8'},
}}}}
pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2))
"
- name: Build drafter prompt
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
@@ -277,30 +231,20 @@ jobs:
pathlib.Path("/tmp/draft_prompt.txt").write_text(prompt)
PYEOF
# Runs the tools-less drafter and secret-scans its output; the mechanical
# scaffold (already in /tmp/release_notes.md) is the fallback if it can't run.
# Checked out at the workspace root, so the action's workdir is the default.
- name: Run release-notes drafter
id: draft
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
prompt="$(cat /tmp/draft_prompt.txt)"
uv run --project "${GITHUB_WORKSPACE}" omnigent run \
"${GITHUB_WORKSPACE}/.github/agents/release-notes-drafter" \
-p "$prompt" --no-session \
2>draft-stderr.log | tee /tmp/draft_out.txt \
|| { echo "::warning::drafter exited non-zero — keeping mechanical draft"; cat draft-stderr.log; }
- name: Scan drafter output for secrets
if: steps.draft.outcome == 'success'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/draft_out.txt 2>/dev/null; then
echo "::error::Drafter output contains LLM_API_KEY — aborting."
exit 1
fi
uses: ./.github/actions/run-omnigent-agent
with:
agent: release-notes-drafter
prompt-file: /tmp/draft_prompt.txt
output-file: /tmp/draft_out.txt
stderr-file: /tmp/draft-stderr.log
gateway-base-url: ${{ secrets.GATEWAY_BASE_URL }}
llm-api-key: ${{ secrets.LLM_API_KEY }}
- name: Extract synthesized notes (fall back to mechanical)
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
@@ -455,7 +399,7 @@ jobs:
python3 - <<'PYEOF'
import os, pathlib
key = os.environ.get("LLM_API_KEY", "")
for f in ["draft-stderr.log", "/tmp/draft_out.txt", "/tmp/draft_prompt.txt",
for f in ["/tmp/draft-stderr.log", "/tmp/draft_out.txt", "/tmp/draft_prompt.txt",
"/tmp/release_notes.md"]:
p = pathlib.Path(f)
if not p.is_file() or not key:
@@ -472,7 +416,7 @@ jobs:
with:
name: draft-release-notes-${{ steps.guard.outputs.tag }}-${{ github.run_id }}
path: |
draft-stderr.log
/tmp/draft-stderr.log
/tmp/draft_out.txt
/tmp/release_notes.md
/tmp/mechanical_notes.md
+15 -15
View File
@@ -168,8 +168,8 @@ jobs:
with:
python-version-file: ".python-version"
- name: Set up Node 20
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -225,14 +225,11 @@ jobs:
- name: Build web SPA
# Build BEFORE pytest: Vite's emptyOutDir clobbers the static dir,
# so never run it under xdist or alongside the live server.
# --legacy-peer-deps avoids re-resolving the known React 19 peer
# conflict under @emoji-mart/react.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
# Native coding-agent harness enablement: the next steps let the
# native render-parity tests boot a real Claude Code / Codex CLI. The
@@ -241,15 +238,17 @@ jobs:
# claude-code 2.1.170, NOT the 2.1.124 in .github/ci-deps: 2.1.124
# doesn't recognise the hook events the native bridge configures and
# shows a blocking startup modal that swallows the first message.
# --ignore-scripts then run install.cjs explicitly (audited: platform
# detect + same-tree hardlink, no network/exec) and put its bin on PATH.
# Runs the package's install script so the platform-specific binary is
# linked into the temporary CLI directory and put on PATH.
# (The install.cjs script was removed in the same version the upstream
# npm package no longer ships it, so lifecycle scripts are required.)
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
# @openai/codex at the .github/ci-deps pin (same build as e2e.yml's
@@ -259,9 +258,10 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run UI e2e tests
# --ui-skip-build: the SPA was built in the previous step.
+47 -16
View File
@@ -3,9 +3,12 @@ name: Electron Build
# Manually-triggered build of the Electron desktop shell (web/electron) for
# Linux and Windows. Each platform packages on its own native runner —
# electron-builder does not reliably cross-compile installers — and uploads the
# installers as downloadable workflow artifacts. Unsigned: no signing creds are
# installers PLUS the electron-updater feed manifests (latest-linux.yml /
# latest.yml) as downloadable workflow artifacts. Unsigned: no signing creds are
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
# rather than failing when a cert is absent. No publishing / release upload.
# rather than failing when a cert is absent. No publishing to a provider / no
# release upload (`--publish never`): the artifacts are captured here for manual
# placement onto the omnigent.ai update feed (omnigent-site repo + artifact host).
#
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
# its signed/notarized build lives elsewhere.
@@ -52,37 +55,65 @@ jobs:
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Node
uses: ./.github/actions/setup-node
with:
# Node 22.x per web/electron/README.md ("Prerequisites").
node-version: "22"
cache-dependency-path: web/electron/package-lock.json
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
working-directory: web/electron
run: npm ci --no-audit --no-fund
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |-
pnpm install --frozen-lockfile --filter @omnigent/electron
pnpm install --frozen-lockfile --filter web
- name: Build ${{ matrix.platform }} app
working-directory: web/electron
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
# No signing credentials in CI: force an unsigned build instead of
# letting electron-builder fail hunting for a certificate.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# electron-builder downloads Electron/tooling from GitHub; the token
# lifts the anonymous rate limit that otherwise flakes downloads.
GH_TOKEN: ${{ github.token }}
run: npm run ${{ matrix.build-script }} -- --publish never
run: pnpm run ${{ matrix.build-script }} -- --publish never
- name: Upload installers
# One artifact per platform bundling the COMPLETE electron-updater feed —
# the installer(s), the .blockmap electron-updater needs for differential
# downloads (referenced by path inside latest*.yml; the .deb has no
# blockmap since debs aren't differentially updated), and the feed
# manifest (latest-linux.yml / latest.yml). upload-artifact zips all
# matched files into a single download, so each platform yields one zip
# whose contents can be dropped straight onto a feed root (local HTTP
# server for testing, or public/_desktop/updates/ on the artifact host).
# Ship only the distributables + feed files, not electron-builder's
# unpacked intermediates (dist/*-unpacked).
#
# electron-builder writes the latest*.yml manifests to dist/ even under
# --publish never (a publish config exists in build.*.publish, so
# update-info generation runs; --publish only skips the provider upload).
# The manifest lists each artifact with sha512 + size + relative url.
- name: Upload Linux feed
if: matrix.platform == 'linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-desktop-${{ matrix.platform }}
# Ship only the distributables, not electron-builder's unpacked
# intermediates (dist/linux-unpacked, dist/win-unpacked, blockmaps).
name: omnigent-desktop-linux
path: |
web/electron/dist/*.AppImage
web/electron/dist/*.AppImage.blockmap
web/electron/dist/*.deb
web/electron/dist/*.exe
web/electron/dist/latest-linux.yml
if-no-files-found: error
retention-days: 14
- name: Upload Windows feed
if: matrix.platform == 'win'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: omnigent-desktop-win
path: |
web/electron/dist/*.exe
web/electron/dist/*.exe.blockmap
web/electron/dist/latest.yml
if-no-files-found: error
retention-days: 14
+218 -82
View File
@@ -52,6 +52,13 @@ on:
type: choice
options: [auto, "true", "false"]
default: auto
max_posts:
description: >-
Maximum number of blog posts to draft (the scout still only picks
features that clear the bar, so it may pick fewer). Default 3.
required: false
type: string
default: "3"
permissions:
contents: read
@@ -86,12 +93,21 @@ jobs:
INPUT_TAG: ${{ inputs.tag }}
INPUT_BASE: ${{ inputs.base }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
INPUT_MAX_POSTS: ${{ inputs.max_posts }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$RUN_BRANCH}"
base="${INPUT_BASE:-}"
proceed=false; dry_run=false
# How many posts to draft at most. Only settable via manual dispatch;
# a real release cut (workflow_run) uses the default. Must be a positive
# integer, else fall back to the default.
max_posts="${INPUT_MAX_POSTS:-3}"
case "$max_posts" in
""|*[!0-9]*|0) max_posts=3 ;;
esac
# Does the tag look like a final release (vX.Y.Z, not rc/dev/alpha/beta)?
is_version=true
case "$tag" in
@@ -122,7 +138,8 @@ jobs:
echo "base=${base}" >> "$GITHUB_OUTPUT"
echo "proceed=${proceed}" >> "$GITHUB_OUTPUT"
echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT"
echo "Resolved tag=${tag} base=${base:-<none>} proceed=${proceed} dry_run=${dry_run}" \
echo "max_posts=${max_posts}" >> "$GITHUB_OUTPUT"
echo "Resolved tag=${tag} base=${base:-<none>} proceed=${proceed} dry_run=${dry_run} max_posts=${max_posts}" \
| tee -a "$GITHUB_STEP_SUMMARY"
# Trusted default branch, full history + tags for the range computation.
@@ -174,62 +191,18 @@ jobs:
[ -n "${BASE:-}" ] && args+=(--base "$BASE")
python3 .github/scripts/changelog/generate.py "${args[@]}"
- name: Set up uv
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Cache virtualenv
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
run: uv sync --extra all --extra dev
- name: Install Claude Code CLI
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }}
run: |
mkdir -p "$HOME/.omnigent"
python3 -c "
import pathlib, os, json
gw = os.environ['GATEWAY_BASE_URL']
cfg = {'providers': {'databricks-gateway': {
'kind': 'gateway', 'default': ['anthropic'],
'anthropic': {
'base_url': gw + '/anthropic',
'api_key_ref': 'env:LLM_API_KEY',
'models': {'default': 'databricks-claude-opus-4-8'},
}}}}
pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2))
"
# --- 1) Scout: which features (if any) are blog-worthy? ---
- name: Build scout prompt
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
TAG: ${{ steps.guard.outputs.tag }}
MAX_POSTS: ${{ steps.guard.outputs.max_posts }}
run: |
set -euo pipefail
python3 -u <<'PYEOF'
import os, pathlib
tag = os.environ["TAG"]
max_posts = int(os.environ.get("MAX_POSTS", "3"))
# `omnigent run -p` passes the whole prompt as one argv string, capped at
# ~128 KiB on Linux. Cap the PR list well under that.
MAX = 100_000
@@ -240,6 +213,8 @@ jobs:
note = ("\n> NOTE: the PR list was truncated — select from what's visible.\n"
if truncated else "")
prompt = f"""Select the blog-worthy features (if any) from {tag}.
Return AT MOST {max_posts} feature(s), ranked strongest-first — pick fewer
if fewer clear the bar. This overrides any other cap in your instructions.
{note}
## Merged PRs (number, title, and author changelog entries)
{pr_list}
@@ -251,34 +226,28 @@ jobs:
pathlib.Path("/tmp/scout_prompt.txt").write_text(prompt)
PYEOF
# Sets up uv + Claude CLI + the gateway provider config, runs the tools-less
# scout on the prompt file, and secret-scans its stdout — the same runner
# scaffold draft-release-notes.yml / publish-changelog.yml share. The env it
# sets up (PATH, ~/.omnigent, .venv) persists into the drafter loop below, so
# only the scout needs to invoke the action.
- name: Run feature-blog scout
id: scout
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
prompt="$(cat /tmp/scout_prompt.txt)"
uv run --project "${GITHUB_WORKSPACE}" omnigent run \
"${GITHUB_WORKSPACE}/.github/agents/feature-blog-scout" \
-p "$prompt" --no-session \
2>scout-stderr.log | tee /tmp/scout_out.txt \
|| { echo "::warning::scout exited non-zero — treating as no candidates"; cat scout-stderr.log; }
- name: Scan scout output for secrets
if: steps.scout.outcome == 'success'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/scout_out.txt 2>/dev/null; then
echo "::error::Scout output contains LLM_API_KEY — aborting."
exit 1
fi
uses: ./.github/actions/run-omnigent-agent
with:
agent: feature-blog-scout
prompt-file: /tmp/scout_prompt.txt
output-file: /tmp/scout_out.txt
stderr-file: ${{ github.workspace }}/scout-stderr.log
gateway-base-url: ${{ secrets.GATEWAY_BASE_URL }}
llm-api-key: ${{ secrets.LLM_API_KEY }}
- name: Parse candidates
id: candidates
if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
env:
MAX_POSTS: ${{ steps.guard.outputs.max_posts }}
run: |
set -euo pipefail
python3 -u <<'PYEOF'
@@ -326,8 +295,9 @@ jobs:
c["pr_refs"] = refs
cands.append(c)
# Cap at 3 defensively (the scout is instructed to, but enforce it here).
cands = cands[:3]
# Enforce the post cap defensively (the scout is told the limit too).
max_posts = int(os.environ.get("MAX_POSTS", "3"))
cands = cands[:max_posts]
pathlib.Path("/tmp/candidates.json").write_text(json.dumps(cands))
out = os.environ["GITHUB_OUTPUT"]
with open(out, "a") as f:
@@ -402,6 +372,15 @@ jobs:
"## Changelog entries (from the release harvest)", pr_list, "",
"## PR diffs"]
BUDGET = 60_000
# Tally who merged the contributing PRs so we can request review from the
# maintainer with the most context on the feature (mirrors doc-sync, but a
# blog spans many PRs so we pick the most frequent merger). Authors fall
# back for it — outside contributors may lack site access, a maintainer
# always merges. Skip bots / the CI identity.
from collections import Counter
mergers, authors = Counter(), Counter()
def _usable(login):
return bool(login) and not login.endswith("[bot]") and login != "omnigent-ci"
for pr in refs:
try:
diff = subprocess.run(
@@ -410,7 +389,25 @@ jobs:
except Exception as e:
diff = f"(diff unavailable: {e})"
parts += [f"### PR #{pr}", f"{fence}diff", diff[:BUDGET], fence]
try:
meta = json.loads(subprocess.run(
["gh", "pr", "view", str(pr), "--repo", repo,
"--json", "mergedBy,author"],
capture_output=True, text=True, timeout=60).stdout or "{}")
mb = (meta.get("mergedBy") or {}).get("login", "")
au = (meta.get("author") or {}).get("login", "")
if _usable(mb):
mergers[mb] += 1
if _usable(au):
authors[au] += 1
except Exception as e:
print(f"::notice::Could not read merger/author for PR #{pr}: {e}")
pathlib.Path(f"/tmp/material_{idx}.txt").write_text("\n".join(parts))
# Most-frequent merger wins; ties broken by Counter insertion order (PR
# order). Fall back to the most-frequent author, then empty.
reviewer = (mergers.most_common(1)[0][0] if mergers
else authors.most_common(1)[0][0] if authors else "")
pathlib.Path(f"/tmp/reviewer_{idx}.txt").write_text(reviewer)
PYEOF
# Start each candidate from a pristine tree: a prior candidate that
@@ -471,14 +468,120 @@ jobs:
# Append the fixed CTA footer to the drafted post (LLM never writes it).
# The post is a new, untracked file — find it via status (git diff
# can't see untracked paths). Porcelain lines are "XY path"; take the
# path field of the first added/modified page.mdx.
post="$(git -C "$SITE" status --porcelain | grep -m1 'page.mdx' | awk '{print $NF}' || true)"
# can't see untracked paths). Use -uall: plain porcelain collapses a
# brand-new directory to "app/blog/<slug>/" and never names the file
# inside it, so grep 'page.mdx' would miss it. Porcelain lines are
# "XY path"; take the path field of the first added/modified page.mdx.
post="$(git -C "$SITE" status --porcelain -uall | grep -m1 'page.mdx' | awk '{print $NF}' || true)"
# Fail fast on HTML comments in the MDX: `<!-- ... -->` is invalid in
# MDX (only `{/* ... */}` works) and would break the site's `next build`
# only after the PR is opened. Catch it here so we never ship a red PR.
if [ -n "$post" ] && grep -qF '<!--' "${SITE}/${post}"; then
echo "::error::Drafted ${post} contains an HTML comment (<!-- -->); MDX requires {/* */}. Aborting."
exit 1
fi
if [ -n "$post" ]; then
printf '\n---\n\n**Enjoying Omnigent?** If this is useful to you, [give us a star on GitHub ⭐](https://github.com/omnigent-ai/omnigent). Come say hi on [Discord](https://discord.gg/omnigent), or [download the latest release](https://omnigent.ai/download).\n' \
printf '\n---\n\n**Enjoying Omnigent?** If this is useful to you, [give us a star on GitHub ⭐](https://github.com/omnigent-ai/omnigent). Come say hi on [Discord](https://discord.gg/omnigent), or [check the latest release](https://omnigent.ai/releases).\n' \
>> "${SITE}/${post}"
fi
# Generate the hero illustration from the drafter's IMAGE_PROMPT (the
# per-feature subject) plus a fixed brand style suffix, via the image
# model on the same gateway host. Fail-soft: any error leaves heroArt
# blank (the index falls back to a placeholder card), never blocking
# the draft. The scene is machine-drawn from the prompt, so no secret
# can reach it; the drafted-file secret scan above already ran.
image_prompt="$(sed -n 's/^IMAGE_PROMPT:[[:space:]]*//p' "/tmp/drafter_out_${i}.txt" | head -n1)"
if [ -n "$post" ] && [ -n "$image_prompt" ]; then
# GATEWAY_BASE_URL is scoped to THIS invocation only (not the step
# env), so the unsandboxed drafter run above never sees it and it
# can't reach the drafter's scanned stdout.
GATEWAY_BASE_URL='${{ secrets.GATEWAY_BASE_URL }}' \
IMAGE_PROMPT="$image_prompt" SLUG="$slug" SITE="$SITE" POST="$post" \
python3 -u <<'PYEOF' || echo "::warning::hero image generation failed for ${slug}; leaving heroArt blank"
import base64, json, os, pathlib, re, urllib.request
gw = os.environ.get("GATEWAY_BASE_URL", "")
key = os.environ.get("LLM_API_KEY", "")
if not gw or not key:
raise SystemExit("no gateway/key for image generation")
# The image model lives on the same workspace host as the anthropic
# gateway (GATEWAY_BASE_URL = <host>/anthropic). Derive scheme://host.
m = re.match(r"(https?://[^/]+)", gw)
if not m:
raise SystemExit(f"cannot parse gateway host from {gw!r}")
model = os.environ.get("IMAGE_MODEL", "databricks-gemini-3-pro-image")
url = f"{m.group(1)}/serving-endpoints/{model}/invocations"
style = (" Flat vector illustration, dark navy tech background with subtle "
"circuit lines, teal and pink accents, 16:9 wide, no text, no words, "
"no logos.")
body = json.dumps({"messages": [{"role": "user",
"content": os.environ["IMAGE_PROMPT"] + style}], "max_tokens": 4096}).encode()
req = urllib.request.Request(url, data=body, method="POST", headers={
"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.load(resp)
# The image is the content part of type image_url (a data: URI).
uri = None
for part in data["choices"][0]["message"]["content"]:
if isinstance(part, dict) and part.get("type") == "image_url":
uri = part["image_url"]["url"]
break
if not uri or "," not in uri:
raise SystemExit("no image in model response")
raw = base64.b64decode(uri.split(",", 1)[1])
if raw[:8] != b"\x89PNG\r\n\x1a\n":
raise SystemExit("model returned non-PNG data")
slug = os.environ["SLUG"]
# Defense-in-depth: slug is validated as strict kebab-case upstream, but
# this is the one place it names a new file — re-check before writing.
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug):
raise SystemExit(f"unsafe slug for hero path: {slug!r}")
dest = pathlib.Path(os.environ["SITE"], "public", "images", "blog", f"{slug}.png")
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(raw)
# Point heroArt at the served path (public/ maps to site root).
post_path = pathlib.Path(os.environ["SITE"], os.environ["POST"])
text = post_path.read_text(encoding="utf-8")
text, n = re.subn(r'heroArt:\s*"[^"]*"', f'heroArt: "/images/blog/{slug}.png"', text, count=1)
if not n:
# No double-quoted heroArt to rewrite — drop the orphan PNG so we
# don't commit an image nothing references.
dest.unlink(missing_ok=True)
raise SystemExit("no heroArt: \"\" field to rewrite; discarding hero image")
post_path.write_text(text, encoding="utf-8")
print(f"generated hero image ({len(raw)} bytes) -> /images/blog/{slug}.png")
PYEOF
fi
# Prettier the drafter's changed files so they pass the site's
# `fmt:check` gate — LLM-generated MDX/JS (and the CTA footer appended
# above) are rarely prettier-clean. Run from inside $SITE so prettier
# discovers the site's .prettierrc.json + .prettierignore, and pin the
# site's major version. Formatting failures are non-fatal: a human
# reviews the draft PR and CI still reports any residual issue.
mapfile -t changed < <(git -C "$SITE" status --porcelain -uall | awk '{print $NF}')
if [ "${#changed[@]}" -gt 0 ]; then
( cd "$SITE" && npx --yes prettier@3 --write --ignore-unknown "${changed[@]}" ) \
|| echo "::warning::prettier --write failed for ${slug}; committing unformatted (CI will flag)"
fi
# Surface the drafted post itself: copy it to /tmp (uploaded as an
# artifact) and, on a dry run, render it into the job summary so the
# post body can be reviewed without opening a PR.
if [ -n "$post" ] && [ -f "${SITE}/${post}" ]; then
cp "${SITE}/${post}" "/tmp/post_${i}.mdx"
{
echo "<details><summary>Drafted post: ${slug}</summary>"
echo
echo '```mdx'
cat "${SITE}/${post}"
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
fi
title=$(sed -n 's/^BLOG_PR_TITLE:[[:space:]]*//p' "/tmp/drafter_out_${i}.txt" | head -n1)
[ -z "$title" ] && title="add feature blog: ${headline}"
@@ -528,26 +631,57 @@ jobs:
gh label create automated-blog --repo "$SITE_REPO" \
--color 5319e7 --description "Auto-drafted feature-blog post" 2>/dev/null || true
# Request review from the maintainer with the most context on the
# feature — the person who merged the most of its contributing PRs
# (computed in the Draft posts step). The @-mention in the body is the
# durable ping (reaches concealed org members); the --add-reviewer /
# --add-assignee calls are best-effort (GitHub 422s non-collaborators),
# so tolerate their failure and never let it block the PR.
assign_reviewer() {
local pr_ref="$1" who="$2"
[ -n "$who" ] || return 0
gh pr edit "$pr_ref" --repo "$SITE_REPO" --add-reviewer "$who" \
|| echo "::notice::Could not request review from ${who} (not addable); they're @-mentioned in the PR body."
gh pr edit "$pr_ref" --repo "$SITE_REPO" --add-assignee "$who" \
|| echo "::notice::Could not assign ${who} (not addable); they're @-mentioned in the PR body."
}
echo "## Draft blog PRs" >> "$GITHUB_STEP_SUMMARY"
while IFS=$'\t' read -r branch title idx; do
[ -z "$branch" ] && continue
git -C "$SITE" push --force "$PUSH_URL" "$branch"
if [ -n "$(gh pr list --repo "$SITE_REPO" --head "$branch" --state open --json number --jq '.[].number')" ]; then
echo "Draft PR already open for ${branch} — force-push updated it." \
| tee -a "$GITHUB_STEP_SUMMARY"
reviewer="$(cat "/tmp/reviewer_${idx}.txt" 2>/dev/null || true)"
mention=""
[ -n "$reviewer" ] && mention=" · most context @${reviewer}"
# Build the body once so the update path can refresh it too — the
# @-mention is the durable ping (--add-reviewer commonly 422s because
# the source-repo maintainer isn't an omnigent-site collaborator), so
# it must be written on BOTH the create and force-push-update paths.
summary="$(sed -n '/<!-- BLOG_DRAFT_SUMMARY -->/,$p' "/tmp/drafter_out_${idx}.txt" | tail -n +2 || true)"
body="$(printf 'Drafts a feature-blog post for **%s**, selected by `feature-blog-scout` at the %s release cut.\n\n> **This is a DRAFT.** Before merging, a human must: record the mandatory demo (replace the `DEMO REQUIRED` marker) and do a final voice pass. The hero image and `author: omnigent` byline are auto-generated — review and optionally replace them.\n\n%s\n\nSource release: %s%s\n<sub>Generated by omnigent `.github/workflows/feature-blog.yml`. Review for accuracy before merging.</sub>' "$title" "$TAG" "$summary" "$TAG" "$mention")"
existing="$(gh pr list --repo "$SITE_REPO" --head "$branch" --state open --json url --jq '.[].url' | head -n1)"
if [ -n "$existing" ]; then
gh pr edit "$existing" --repo "$SITE_REPO" --body "$body" \
|| echo "::notice::Could not refresh body for ${existing}."
assign_reviewer "$existing" "$reviewer"
echo "- [${title}](${existing})${mention} — updated existing draft (force-pushed)" \
>> "$GITHUB_STEP_SUMMARY"
continue
fi
summary="$(sed -n '/<!-- BLOG_DRAFT_SUMMARY -->/,$p' "/tmp/drafter_out_${idx}.txt" | tail -n +2 || true)"
body="$(printf 'Drafts a feature-blog post for **%s**, selected by `feature-blog-scout` at the %s release cut.\n\n> **This is a DRAFT.** Before merging, a human must: record the mandatory demo (replace the `DEMO REQUIRED` marker), add hero art, set the author byline, and do a final voice pass.\n\n%s\n\nGenerated by omnigent `.github/workflows/feature-blog.yml`.' "$title" "$TAG" "$summary")"
gh pr create \
url="$(gh pr create \
--repo "$SITE_REPO" \
--base main \
--head "$branch" \
--draft \
--title "blog: ${title}" \
--body "$body" \
--label automated-blog
--label automated-blog)"
assign_reviewer "$url" "$reviewer"
echo "- [${title}](${url})${mention}" >> "$GITHUB_STEP_SUMMARY"
done < /tmp/drafted_branches.txt
# ::add-mask:: redacts rendered logs, not artifact files — scrub the key
@@ -565,6 +699,7 @@ jobs:
files = ["scout-stderr.log", "drafter-stderr.log", "/tmp/scout_out.txt",
"/tmp/scout_prompt.txt"]
files += glob.glob("/tmp/drafter_out_*.txt") + glob.glob("/tmp/material_*.txt")
files += glob.glob("/tmp/post_*.mdx")
for f in files:
p = pathlib.Path(f)
if not p.is_file() or not key:
@@ -575,7 +710,7 @@ jobs:
print(f"redacted key from {f}")
PYEOF
- name: Upload logs on failure
- name: Upload logs and drafted posts
if: always() && steps.guard.outputs.proceed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
@@ -586,5 +721,6 @@ jobs:
/tmp/scout_out.txt
/tmp/candidates.json
/tmp/drafter_out_*.txt
/tmp/post_*.mdx
retention-days: 7
if-no-files-found: ignore
+53 -18
View File
@@ -2,13 +2,16 @@
# run after the prod PyPI publish succeeded and the draft notes are curated
# (designs/RELEASE-AUTOMATION.md).
#
# Deterministic gates first (all fail with actionable links):
# * the tag is a final vX.Y.Z with an unpublished draft release,
# Deterministic gates first (each fails with actionable links):
# * the tag is a final vX.Y.Z (input is normalized: `0.7.0` -> `v0.7.0`)
# with an unpublished draft release; a draft whose tag binding was lost
# to a web-UI edit (tag_name became `untagged-…`) is rebound automatically,
# * PyPI serves all three lockstep packages at the version (never advertise
# a release that isn't installable),
# * the auto/changelog/vX.Y.Z CHANGELOG PR isn't sitting open,
# * the docs sweep: no open PRs against omnigent-site's X.Y-docs staging
# branch (every doc staged this cycle is reviewed + merged/closed).
# * the auto/changelog/vX.Y.Z CHANGELOG PR isn't sitting open.
# The docs sweep (open PRs against omnigent-site's X.Y-docs staging branch) is
# ADVISORY only: it lists what is still unmerged but never blocks the publish —
# docs can land after the release, any time before the docs-publish PR merges.
#
# The publish job binds the `publish-release` environment (one-time setup:
# create it in repo settings with required reviewers). Approving it is the
@@ -64,16 +67,23 @@ jobs:
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
already_published: ${{ steps.draft.outputs.already_published }}
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Require a final vX.Y.Z tag
id: tag
env:
TAG: ${{ inputs.tag }}
RAW_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# Normalize the input: trim whitespace, add the leading v if omitted
# (`0.7.0` -> `v0.7.0`), so a bare version doesn't fail the dispatch.
TAG="$(printf '%s' "$RAW_TAG" | tr -d '[:space:]')"
case "$TAG" in v*) ;; *) TAG="v${TAG}" ;; esac
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::${TAG} is not a final vX.Y.Z tag — rc/dev/alpha/beta releases never finalize."
echo "::error::${RAW_TAG} is not a final vX.Y.Z tag — rc/dev/pre releases never finalize."
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Drafts are invisible to read-only tokens and unaddressable by tag
# (the get-by-tag endpoint 404s on drafts) — resolve by listing with the
@@ -93,11 +103,32 @@ jobs:
id: draft
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
--jq 'map(select(.tag_name == env.TAG)) | first // empty')"
if [ -z "$match" ]; then
# Editing a draft in the web UI can silently drop its tag binding
# (tag_name becomes `untagged-…` while the name stays vX.Y.Z; bit
# v0.5.0 and v0.7.0). Recover: match the DRAFT by name and rebind —
# only ever onto a tag that already exists, so publishing can never
# mint a new tag at main.
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
--jq 'map(select(.draft == true and .name == env.TAG)) | first // empty')"
if [ -n "$match" ]; then
if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha >/dev/null 2>&1; then
echo "::error::Draft named ${TAG} exists but the git tag does not — push the tag before finalizing."
exit 1
fi
rebind_id="$(printf '%s' "$match" | jq -r '.id')"
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${rebind_id}" \
-f tag_name="$TAG" > /dev/null
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${rebind_id}")"
echo "Rebound draft ${rebind_id} to ${TAG} (tag binding was lost, usually to a web-UI edit)." \
| tee -a "$GITHUB_STEP_SUMMARY"
fi
fi
if [ -z "$match" ]; then
echo "::error::No GitHub release found for ${TAG}. Did the tag push run github-release.yml?"
exit 1
@@ -118,7 +149,7 @@ jobs:
- name: Assert PyPI serves all three packages
if: steps.draft.outputs.already_published != 'true'
env:
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
version="${TAG#v}"
@@ -134,7 +165,7 @@ jobs:
if: steps.draft.outputs.already_published != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
open_pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "auto/changelog/${TAG}" \
@@ -145,11 +176,14 @@ jobs:
fi
echo "CHANGELOG PR for ${TAG}: merged or not needed."
- name: Docs sweep — no open PRs against the X.Y-docs staging branch
# Advisory only: docs frequently land after the release. The list tells
# the coordinator what must merge into X.Y-docs before the docs-publish
# PR does — it never blocks the publish itself.
- name: Docs sweep — list open PRs against the X.Y-docs staging branch
if: steps.draft.outputs.already_published != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
SITE_REPO: ${{ github.repository_owner }}/omnigent-site
run: |
set -euo pipefail
@@ -158,16 +192,17 @@ jobs:
open="$(gh pr list --repo "$SITE_REPO" --base "$docs_branch" --state open \
--json url,title --jq '.[] | "- \(.url) \(.title)"')"
if [ -n "$open" ]; then
count="$(printf '%s\n' "$open" | grep -c .)"
{
echo "## Docs sweep failed for ${TAG}"
echo "## Docs sweep for ${TAG} — ${count} open PR(s) still target \`${docs_branch}\`"
echo ""
echo "Open PRs still target \`${docs_branch}\` on ${SITE_REPO} — review and merge/close them, then re-dispatch:"
echo "Advisory, not blocking. Merge/close these before merging the docs-publish PR:"
echo "$open"
} | tee -a "$GITHUB_STEP_SUMMARY"
echo "::error::Open doc PRs still target ${docs_branch} — see the run summary."
exit 1
echo "::warning::${count} open doc PR(s) still target ${docs_branch} (non-blocking) — see the run summary."
else
echo "Docs sweep clean: no open PRs against ${docs_branch}." | tee -a "$GITHUB_STEP_SUMMARY"
fi
echo "Docs sweep clean: no open PRs against ${docs_branch}." | tee -a "$GITHUB_STEP_SUMMARY"
# Approving this environment attests "I reviewed the curated draft notes".
publish:
@@ -189,7 +224,7 @@ jobs:
- name: Publish the draft as Latest
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ needs.checks.outputs.tag }}
RELEASE_ID: ${{ needs.checks.outputs.release_id }}
run: |
set -euo pipefail
+11 -7
View File
@@ -238,22 +238,26 @@ jobs:
# executor adapters import at collection time.
run: uv sync --extra all --extra dev
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install binary dependencies
# Mirrors e2e.yml. ripgrep: Grep fallback for inner tests. tmux +
# bubblewrap: the e2e runner runs real agents under the linux_bwrap
# sandbox, which fails loud if `bwrap` is missing. The apparmor
# sysctl mirrors ci.yml (Ubuntu 24.04 blocks unprivileged user
# namespaces, which bwrap's unshare(CLONE_NEWUSER) needs). npm install
# with --ignore-scripts blocks postinstall; the claude-code stub needs
# its audited install.cjs run explicitly (platform detect + same-tree
# hardlink, no network/exec) for claude-sdk harness rows.
working-directory: .github/ci-deps
# namespaces, which bwrap's unshare(CLONE_NEWUSER) needs). pnpm
# install with --ignore-scripts blocks postinstall; the claude-code
# stub needs its audited install.cjs run explicitly (platform detect +
# same-tree hardlink, no network/exec) for claude-sdk harness rows.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
sudo apt-get update
sudo apt-get install -y ripgrep tmux bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps
node .github/ci-deps/node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run pytest target
+13 -12
View File
@@ -162,8 +162,8 @@ jobs:
with:
python-version-file: ".python-version"
- name: Set up Node 20
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -222,20 +222,20 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Install Claude Code CLI
# Pinned to match e2e-ui.yml (2.1.170 recognises the native bridge
# hook events). --ignore-scripts then run the audited install.cjs.
# hook events). Runs lifecycle scripts so the platform-specific binary
# is linked and put on PATH.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
# @openai/codex pinned to match e2e-ui.yml; goal-mode app-server APIs
@@ -243,9 +243,10 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run pytest target
# Inputs validated by prep. Word-splitting on $TEST_TARGET / $EXTRA_ARGS
+26 -11
View File
@@ -28,7 +28,10 @@ on:
push:
tags:
# Version tags only (v0.2.0, v0.2.0rc1, …) — `v[0-9]*` avoids triggering
# on non-release tags like `v-infra-*`.
# on non-release tags like `v-infra-*`. Pre-release tags (rcN / devN /
# preN) still match the glob but are skipped in the job below — no
# GitHub release is created for them; their installable artifacts live
# only on PyPI, and a curated release page is reserved for the final cut.
- "v[0-9]*"
# Least privilege: creating a release requires `contents: write`; nothing here
@@ -43,9 +46,30 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Skip pre-release tags (rcN / devN / preN)
id: tag
env:
TAG: ${{ github.ref_name }}
run: |
# Pre-release tags (rcN / devN / preN) get NO GitHub release — they
# live on PyPI only, and a curated release page is reserved for the
# final cut. The tag glob above still matches them, so gate here.
# A trailing digit is required so a substring like 'dev' or 'pre' in a
# mistyped tag name can't trigger a skip by accident.
case "$TAG" in
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*)
echo "Pre-release tag ${TAG} — not creating a GitHub release (rc/dev/pre releases live on PyPI only)." \
| tee -a "$GITHUB_STEP_SUMMARY"
echo "skip=true" >> "$GITHUB_OUTPUT" ;;
*)
echo "skip=false" >> "$GITHUB_OUTPUT" ;;
esac
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
if: steps.tag.outputs.skip != 'true'
- name: Draft release with a placeholder body
if: steps.tag.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
@@ -57,20 +81,11 @@ jobs:
echo "Release $TAG already exists — skipping." | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
# rc / dev / alpha / beta tags are flagged as pre-releases.
pre=""
case "$TAG" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) pre="--prerelease" ;;
esac
# $pre is intentionally UNQUOTED: it word-splits to nothing when empty,
# and is only ever "" or "--prerelease" (set just above, never from
# external input). Quoting it would pass an empty positional arg.
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft \
--verify-tag \
--notes "_Release notes are being drafted automatically — check back shortly._" \
--title "$TAG" \
$pre
--title "$TAG"
echo "Drafted release $TAG — curated notes will be filled in by draft-release-notes.yml; review and publish from the Releases page." \
| tee -a "$GITHUB_STEP_SUMMARY"
+3 -3
View File
@@ -61,14 +61,14 @@ jobs:
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
is_final=true
# Only final vX.Y.Z tags; exclude rc/dev/alpha/beta and the event's
# prerelease flag (homebrew users get stable releases from the tap).
# Only final vX.Y.Z tags; exclude rcN/devN/preN pre-releases and
# the event's prerelease flag (homebrew users get stable releases).
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_final=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_final=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*|*a[0-9]*|*b[0-9]*) is_final=false ;;
esac
if [ "${PRERELEASE}" = "true" ]; then is_final=false; fi
echo "is_final=${is_final}" >> "$GITHUB_OUTPUT"
+7 -3
View File
@@ -179,10 +179,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Set LLM credentials
if: steps.creds.outputs.available == 'true'
+21 -22
View File
@@ -76,32 +76,30 @@ jobs:
# a stale lockfile). Fix locally with `uv lock`.
run: uv sync --locked --extra dev
# Sets up Node 20 and pins npm to the same major that regenerates
# the lockfile in the OSS-regen workflows, so the freshness gate
# below doesn't flake on npm version-skew churn.
- name: Set up Node.js
uses: ./.github/actions/setup-node
# Sets up Node 20 + pnpm, with pnpm dependency caching keyed on the
# workspace lockfile. pnpm is pinned in .github/actions/setup-pnpm.
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install web dependencies
working-directory: web
# Pin the npm registry to the npmjs default.
# Pin the npm registry to the npmjs default; limit to the web package
# so the Electron package's large native devDependencies are not fetched.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: npm ci --legacy-peer-deps
run: pnpm install --frozen-lockfile --filter web
# The npm equivalent of the `uv sync --locked` gate above. `npm ci`
# only checks the lockfile is CONSISTENT with package.json; it
# tolerates cosmetic drift (dev/extraneous flags, metadata) that a
# fresh resolution would rewrite. Regenerate the lockfile and fail
# if it differs from the committed one.
- name: Check web/package-lock.json is up to date
working-directory: web
# The pnpm equivalent of the `uv sync --locked` gate above.
# `pnpm install --frozen-lockfile` only checks the lockfile is CONSISTENT
# with package.json; it tolerates cosmetic drift that a fresh resolution
# would rewrite. Regenerate the lockfile and fail if it differs from the
# committed one.
- name: Check pnpm-lock.yaml is up to date
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund
git diff --exit-code package-lock.json || {
echo "::error::web/package-lock.json is out of date. Run 'npm install --package-lock-only --legacy-peer-deps' in web/ and commit the result."
pnpm install --lockfile-only
git diff --exit-code pnpm-lock.yaml || {
echo "::error::pnpm-lock.yaml is out of date. Run 'pnpm install --lockfile-only' at the repo root and commit the result."
exit 1
}
@@ -125,11 +123,12 @@ jobs:
- name: Run formatting, lint, and typing checks
run: uv run pre-commit run --all-files --show-diff-on-failure
- name: Type-check web
working-directory: web
run: npm run type-check
# Type-checking is temporarily skipped in CI while the pnpm lockfile
# settles; `pnpm --filter web run type-check` still works locally.
# - name: Type-check web
# run: pnpm --filter web run type-check
# The three packages release in lockstep (identical versions + `==` sibling
# The four packages release in lockstep (identical versions + `==` sibling
# pins). Assert agreement on every change so drift from a bad merge or
# cherry-pick — however it happened — is caught before it reaches a release.
version-lockstep:
+21 -4
View File
@@ -27,7 +27,9 @@ on:
pull_request_target:
types: [labeled]
workflow_run:
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests]
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests, UI Snapshot]
types: [completed]
check_run:
types: [completed]
issue_comment:
types: [created]
@@ -48,7 +50,7 @@ permissions:
contents: read
concurrency:
group: merge-ready-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr || github.event.workflow_run.head_sha }}
group: merge-ready-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr || github.event.workflow_run.head_sha || github.event.check_run.head_sha }}
cancel-in-progress: true
jobs:
@@ -61,8 +63,9 @@ jobs:
actions: read # evaluate-checks.sh reads GET /actions/runs to classify missing checks
statuses: write
# Fire on automerge label adds, PR CI workflow_run completions (same-repo
# and fork), `/merge` comments, or a workflow_dispatch re-eval. Runs with no
# open PR (push to main, etc.) are dropped by the ctx step.
# and fork), DCO check_run completions, `/merge` comments, or a
# workflow_dispatch re-eval. Runs with no open PR (push to main, etc.) are
# dropped by the ctx step.
if: >-
(
github.event_name == 'pull_request_target' &&
@@ -72,6 +75,11 @@ jobs:
github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request'
) ||
(
github.event_name == 'check_run' &&
github.event.check_run.name == 'DCO' &&
github.event.check_run.app.slug == 'dco'
) ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
@@ -102,6 +110,7 @@ jobs:
# Via env, not interpolated: author-controlled, so direct
# interpolation would be a shell-injection vector.
WF_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
CHECK_RUN_SHA: ${{ github.event.check_run.head_sha }}
COMMENT_BODY: ${{ github.event.comment.body }}
PR_INPUT: ${{ inputs.pr }}
SHA_INPUT: ${{ inputs.sha }}
@@ -142,6 +151,14 @@ jobs:
fi
PR="${{ github.event.issue.number }}"
SHA=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
elif [[ "${{ github.event_name }}" == "check_run" ]]; then
SHA="$CHECK_RUN_SHA"
PR=$(resolve_pr_from_sha "$SHA")
if [[ -z "$PR" ]]; then
echo "::notice::Skipped: DCO check_run has no associated open PR"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
else
PR=$(echo "$WF_PRS" | jq -r '.[0].number // empty')
SHA="${{ github.event.workflow_run.head_sha }}"
+8 -5
View File
@@ -50,8 +50,10 @@ permissions:
contents: read
concurrency:
# Key by SHA so back-to-back merges each build; don't cancel mid-push.
group: oss-publish-images-${{ github.sha }}
# One build at a time: rc and final tags land minutes apart at a release cut,
# and built concurrently they race each other's layer cache cold and blow the
# job timeout. Serialized, the later build reuses the earlier one's layers.
group: oss-publish-images
cancel-in-progress: false
jobs:
@@ -67,9 +69,10 @@ jobs:
runs-on: ubuntu-latest
# Multi-arch: the linux/arm64 leg cross-builds under QEMU emulation on this
# amd64 runner, which roughly doubles the host-image build time (emulated
# npm/pip native steps). 30m was tight for two native amd64 builds; give the
# four-variant (server+host × amd64+arm64) build headroom.
timeout-minutes: 60
# npm/pip native steps). A cold-cache four-variant (server+host × amd64+
# arm64) build can exceed 60m, and hitting the timeout loses the release's
# images silently — give it real headroom.
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+13 -26
View File
@@ -1,5 +1,5 @@
# A maintainer comments `/regen` on a PR to regenerate the repo's lockfiles
# (uv.lock + web/package-lock.json) against public PyPI/npm and commit them
# (uv.lock + pnpm-lock.yaml) against public PyPI/npmjs.org and commit them
# ONTO that PR's branch. Use when the PR itself moved a dependency; complements
# oss-regenerate-and-smoke.yml (standalone rolling PR on dispatch).
#
@@ -164,28 +164,14 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`), recorded as
# a relative span; an env-var cutoff would stamp an absolute date and break
# later `uv sync --locked`. npm's cooldown (web/.npmrc min-release-age=7)
# is only honored by npm >= 11.10.0; node 20 ships npm 10.x which ignores it.
# Pin the EXACT version (not a range) and keep it in lockstep with
# .github/actions/setup-node (npm 11.12.1): this workflow generates the
# lockfile and that action verifies it, so a version gap would fail the
# freshness gate in lint.yml.
- name: Ensure npm honors the dependency cooldown
run: npm install -g npm@11.12.1
# Delete package-lock.json so npm RESOLVES from scratch: min-release-age
# only filters during resolution, and --package-lock-only keeps an existing
# in-range pin without re-applying the cooldown.
# --legacy-peer-deps is REQUIRED and MUST match the flag lint.yml verifies
# with (React 18 runtime vs React 19 peers would otherwise ERESOLVE-fail,
# and a flag mismatch rewrites dev/extraneous flags, failing the gate).
- name: Regenerate lockfiles against public PyPI/npm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`) and from
# pnpm-workspace.yaml (`minimumReleaseAge: 10080`), recorded as a relative
# span; an env-var cutoff would stamp an absolute date and break later
# `uv sync --locked` / `pnpm install --frozen-lockfile`.
- name: Regenerate lockfiles against public PyPI/npmjs.org
env:
REGEN_MODE: ${{ needs.authorize.outputs.mode }}
UPGRADE_PKGS: ${{ needs.authorize.outputs.pkgs }}
@@ -203,7 +189,8 @@ jobs:
else
uv lock
fi
( cd web && rm -f package-lock.json && npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund )
rm -f pnpm-lock.yaml
pnpm install --lockfile-only
# Mint the App token only AFTER `uv lock` so untrusted PR build backends
# never see it. Skipped when the App isn't configured (push then falls back
@@ -229,12 +216,12 @@ jobs:
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# --porcelain (not git diff) so first-time UNTRACKED lockfiles count too.
if [ -z "$(git status --porcelain -- uv.lock web/package-lock.json)" ]; then
if [ -z "$(git status --porcelain -- uv.lock pnpm-lock.yaml)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lockfiles already current — nothing to commit."
exit 0
fi
git add uv.lock web/package-lock.json
git add uv.lock pnpm-lock.yaml
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npm"
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF"
echo "changed=true" >> "$GITHUB_OUTPUT"
@@ -256,7 +243,7 @@ jobs:
upgraded=" (upgraded: $UPGRADE_PKGS)"
fi
if [ "$CHANGED" = "true" ]; then
base="✅ Regenerated \`uv.lock\`$upgraded + \`web/package-lock.json\` against public PyPI/npm and pushed to this PR."
base="✅ Regenerated \`uv.lock\`$upgraded + \`pnpm-lock.yaml\` against public PyPI/npmjs.org and pushed to this PR."
if [ "$APP_USED" = "true" ]; then
body="$base CI will re-run on the new commit."
else
+18 -32
View File
@@ -40,40 +40,26 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`), recorded as
# a relative span; an env-var cutoff would stamp an absolute date and break
# later `uv sync --locked`.
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`) and from
# pnpm-workspace.yaml (`minimumReleaseAge: 10080`), recorded as a relative
# span; an env-var cutoff would stamp an absolute date and break later
# `uv sync --locked` / `pnpm install --frozen-lockfile`.
- name: Regenerate uv.lock
run: uv lock
# npm's cooldown (web/.npmrc `min-release-age=7`) is only honored by
# npm >= 11.10.0; node 20 ships npm 10.x which silently ignores it.
# Pin the EXACT version (not a range) and keep it in lockstep with
# .github/actions/setup-node: this workflow generates the lockfile and
# that action verifies it, so a version gap would fail the freshness
# gate in lint.yml. 11.12.1 satisfies the >= 11.10.0 cooldown floor.
- name: Ensure npm honors the dependency cooldown
run: npm install -g npm@11.12.1
# Delete the lockfile so npm RESOLVES from scratch: min-release-age only
# filters during resolution, and --package-lock-only keeps an existing
# in-range pin without re-applying the cooldown.
#
# --legacy-peer-deps is REQUIRED: the tree pins React 18 at runtime while
# much of the UI stack (and @types/react) peer-requires React 19, so npm's
# strict resolver would ERESOLVE-fail without it. It MUST match the flag the
# freshness gate in lint.yml verifies with; generating without it resolves
# the peer graph differently and rewrites the dev/devOptional/extraneous
# flags, failing that byte-exact gate.
- name: Regenerate package-lock.json
working-directory: web
# pnpm's cooldown is configured in pnpm-workspace.yaml and respected by
# the workspace root. Delete the lockfile so pnpm RESOLVES from scratch:
# --lockfile-only keeps an existing in-range pin without re-applying the
# cooldown, so we drop it first.
- name: Regenerate pnpm-lock.yaml
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
rm -f package-lock.json
npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund
rm -f pnpm-lock.yaml
pnpm install --lockfile-only
# Validate BEFORE committing: the Docker build proves the regenerated
# locks + public registries produce a working image.
@@ -106,15 +92,15 @@ jobs:
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# --porcelain (not git diff) so first-regen UNTRACKED lockfiles count too.
if [ -z "$(git status --porcelain -- uv.lock web/package-lock.json)" ]; then
if [ -z "$(git status --porcelain -- uv.lock pnpm-lock.yaml)" ]; then
echo "Lockfiles already current — nothing to PR."
exit 0
fi
# One rolling branch, force-pushed each run, so regens update a single PR.
BRANCH="automation/oss-lockfile-regen"
git checkout -b "$BRANCH"
git add uv.lock web/package-lock.json
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npm"
git add uv.lock pnpm-lock.yaml
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npmjs.org"
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$BRANCH"
# An already-open PR just picks up the force-pushed update.
if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" ]; then
+12 -5
View File
@@ -171,19 +171,26 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
# Install outside the checked-out tree so a repo-root package.json
# can't capture this bare `npm install` and hoist it away from here.
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Set LLM credentials
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
+242 -11
View File
@@ -17,6 +17,11 @@ name: Publish Changelog
# Cross-repo writes can't use the workflow's own GITHUB_TOKEN (scoped to this
# repo), so we mint a short-lived token from the omnigent-ci GitHub App scoped to
# omnigent-site — the same App used by sync-openapi-to-site.yml.
#
# Manual dispatch with `dry_run: true` previews only — it renders the site page
# and prints it to the run log + job summary, mints no token, and opens no PR (so
# it runs from a fork too). Use it to eyeball the narrative reflow before a real
# publish.
on:
release:
@@ -27,6 +32,13 @@ on:
description: Final release tag to (re)publish, e.g. v0.3.0
required: true
type: string
dry_run:
description: >-
Preview only: render the site page and print it to the run log +
job summary, but do NOT mint a token or open any PR.
required: false
type: boolean
default: false
permissions:
contents: read
@@ -42,6 +54,7 @@ jobs:
outputs:
tag: ${{ steps.r.outputs.tag }}
is_final: ${{ steps.r.outputs.is_final }}
dry_run: ${{ steps.r.outputs.dry_run }}
steps:
- name: Resolve tag and finality
id: r
@@ -49,41 +62,55 @@ jobs:
EVENT_TAG: ${{ github.event.release.tag_name }}
PRERELEASE: ${{ github.event.release.prerelease }}
INPUT_TAG: ${{ inputs.tag }}
# Only a manual dispatch can request dry-run; a real published release
# is never a preview.
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$EVENT_TAG}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
dry_run=false
[ "${INPUT_DRY_RUN}" = "true" ] && dry_run=true
echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT"
is_final=true
# Only final vX.Y.Z tags; exclude rc/dev/alpha/beta and the
# event's prerelease flag.
# Only final vX.Y.Z tags; exclude rcN/devN/preN pre-releases and
# the event's prerelease flag.
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_final=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_final=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*) is_final=false ;;
esac
if [ "${PRERELEASE}" = "true" ]; then
is_final=false
fi
echo "is_final=${is_final}" >> "$GITHUB_OUTPUT"
echo "Resolved tag=${tag} is_final=${is_final}" | tee -a "$GITHUB_STEP_SUMMARY"
echo "Resolved tag=${tag} is_final=${is_final} dry_run=${dry_run}" | tee -a "$GITHUB_STEP_SUMMARY"
publish:
name: Open release-post PR (omnigent-site)
needs: resolve
runs-on: ubuntu-latest
# Canonical repo only; skip cleanly where the App isn't configured.
# Canonical repo only, and only where the App is configured — EXCEPT a
# dry-run, which just renders + prints (no cross-repo write), so it runs
# anywhere (e.g. a fork) to preview the page.
if: >-
needs.resolve.outputs.is_final == 'true' &&
github.repository == 'omnigent-ai/omnigent' &&
vars.OMNIGENT_BOT_APP_ID != ''
(needs.resolve.outputs.dry_run == 'true' ||
(github.repository == 'omnigent-ai/omnigent' &&
vars.OMNIGENT_BOT_APP_ID != ''))
env:
TAG: ${{ needs.resolve.outputs.tag }}
DRY_RUN: ${{ needs.resolve.outputs.dry_run }}
SOURCE_REPO: ${{ github.repository }}
SITE_REPO: ${{ github.repository_owner }}/omnigent-site
RELEASES_BRANCH: auto/releases/${{ needs.resolve.outputs.tag }}
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Checkout omnigent (for the render script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -95,7 +122,7 @@ jobs:
with:
python-version: "3.11"
- name: Render the curated release body to MDX
- name: Read the curated release body
working-directory: omnigent
# The release read uses the workflow's own token (scoped to this repo);
# only the cross-repo site write needs the App token, minted below.
@@ -110,14 +137,180 @@ jobs:
--json body,publishedAt > /tmp/release.json
jq -r '.body' /tmp/release.json > /tmp/release_body.md
date="$(jq -r '.publishedAt' /tmp/release.json | cut -c1-10)"
echo "RELEASE_DATE=${date}" >> "$GITHUB_ENV"
# The site post is the narrative reflow; the raw release body is the
# fallback if the formatter agent is unavailable or fails.
cp /tmp/release_body.md /tmp/site_body.md
# --- AI reflow (primary; degrades to the raw release body) ---
# The GitHub Release itself is untouched — this rewrites its body into the
# website's narrative, prose-driven post for the site page ONLY.
- name: Check LLM credentials
id: creds
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
if [ -z "${LLM_API_KEY:-}" ]; then
echo "::warning::No LLM credentials — publishing the raw release body."
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "::add-mask::${LLM_API_KEY}"
echo "available=true" >> "$GITHUB_OUTPUT"
fi
# Build a docs index (URL + title, one per line) from the live site so the
# formatter can link each feature to a real /docs page — or omit the link
# when nothing fits. omnigent-site is public; a blobless partial + sparse
# checkout of app/docs pulls only the page.mdx tree, no token needed. Best
# effort: on any failure the index is empty and every "Learn more" is dropped.
- name: Build docs index
if: steps.creds.outputs.available == 'true'
env:
SITE_REPO: ${{ env.SITE_REPO }}
run: |
set -euo pipefail
: > /tmp/docs_index.txt
tmp="$(mktemp -d)"
if git clone --depth 1 --filter=blob:none --sparse \
"https://github.com/${SITE_REPO}.git" "$tmp" 2>/dev/null \
&& git -C "$tmp" sparse-checkout set app/docs 2>/dev/null; then
python3 -u - "$tmp" <<'PYEOF'
import pathlib, re, sys
root = pathlib.Path(sys.argv[1])
docs = root / "app" / "docs"
def slugify(text):
# Mirror components/HeadingAnchors.js so #anchors resolve on the site.
text = text.lower()
text = re.sub(r"[^a-z0-9\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip()
lines, pages = [], 0
for page in sorted(docs.rglob("page.mdx")):
url = "/" + page.relative_to(root).parent.as_posix().removeprefix("app/")
title, sections, in_fence = "", [], False
for ln in page.read_text(encoding="utf-8", errors="replace").splitlines():
if ln.lstrip().startswith("```"): # skip fenced code blocks
in_fence = not in_fence
continue
if in_fence:
continue
m = re.match(r"(#{1,3})\s+(.*\S)", ln) # only h1-h3 get anchors
if not m:
continue
level, text = len(m.group(1)), m.group(2).strip()
# Reduce `[label](url)` to `label`: the site slugs rendered text.
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
if level == 1 and not title:
title = text
elif level > 1:
sections.append((slugify(text), text))
pages += 1
lines.append(f"{url}\t{title}" if title else url)
for slug, text in sections:
lines.append(f" {url}#{slug}\t{text}")
pathlib.Path("/tmp/docs_index.txt").write_text("\n".join(lines) + ("\n" if lines else ""))
print(f"Indexed {pages} docs pages, {len(lines) - pages} sections.")
PYEOF
else
echo "::warning::Could not fetch omnigent-site docs — 'Learn more' links will be omitted."
fi
rm -rf "$tmp"
- name: Build formatter prompt
if: steps.creds.outputs.available == 'true'
env:
TAG: ${{ env.TAG }}
run: |
set -euo pipefail
python3 -u <<'PYEOF'
import os, pathlib
tag = os.environ["TAG"]
# `omnigent run -p` passes the whole prompt as one argv string, capped at
# ~128 KiB on Linux (MAX_ARG_STRLEN). A release body is far smaller, but
# cap defensively; the raw body is the fallback if the agent can't run.
MAX = 100_000
body = pathlib.Path("/tmp/release_body.md").read_text(encoding="utf-8", errors="replace")[:MAX]
docs = pathlib.Path("/tmp/docs_index.txt")
docs_index = docs.read_text(encoding="utf-8", errors="replace").strip() if docs.is_file() else ""
docs_block = docs_index if docs_index else "(none available — omit every \"Learn more\" line)"
prompt = f"""Reformat the {tag} release notes into the website post.
## Curated GitHub Release body (rewrite this — do not add or drop facts)
{body}
## Available docs pages and sections (URL <TAB> title; indented = a
## #section anchor within the page above) — link features to these only
{docs_block}
Produce the RELEASE_POST block per your instructions."""
pathlib.Path("/tmp/format_prompt.txt").write_text(prompt)
PYEOF
# Runs the tools-less formatter and secret-scans its output; degrades to the
# raw release body (below) if the agent can't run. Omnigent is checked out
# into omnigent/, so the action's workdir is that subdir.
- name: Run release-post formatter
id: format
if: steps.creds.outputs.available == 'true'
uses: ./omnigent/.github/actions/run-omnigent-agent
with:
workdir: omnigent
agent: release-post-formatter
prompt-file: /tmp/format_prompt.txt
output-file: /tmp/format_out.txt
stderr-file: /tmp/format-stderr.log
gateway-base-url: ${{ secrets.GATEWAY_BASE_URL }}
llm-api-key: ${{ secrets.LLM_API_KEY }}
- name: Extract narrative post (fall back to raw body)
if: steps.creds.outputs.available == 'true'
run: |
set -euo pipefail
python3 -u <<'PYEOF'
import pathlib, re
raw = pathlib.Path("/tmp/format_out.txt").read_text(encoding="utf-8", errors="replace") \
if pathlib.Path("/tmp/format_out.txt").is_file() else ""
m = re.search(r"<!--\s*RELEASE_POST\s*-->(.*?)<!--\s*/RELEASE_POST\s*-->", raw, re.DOTALL)
post = (m.group(1).strip() if m else "")
if post:
pathlib.Path("/tmp/site_body.md").write_text(post + "\n")
print("Using AI-formatted narrative release post.")
else:
print("::warning::No RELEASE_POST block parsed — publishing the raw release body.")
PYEOF
- name: Render the release post to MDX
working-directory: omnigent
run: |
set -euo pipefail
mkdir -p /tmp/site_page
python3 .github/scripts/changelog/release_to_mdx.py \
--tag "$TAG" --repo "$SOURCE_REPO" --date "$date" \
--body-file /tmp/release_body.md \
--tag "$TAG" --repo "$SOURCE_REPO" --date "$RELEASE_DATE" \
--body-file /tmp/site_body.md \
--out "/tmp/site_page/page.mdx"
# Dry-run stops here: print the generated page (and the intermediate
# narrative body) to the log and the job summary. No token is minted and no
# PR is opened — every step below is gated on DRY_RUN != 'true'.
- name: Preview rendered page (dry-run)
if: env.DRY_RUN == 'true'
run: |
set -euo pipefail
{
echo "## Dry-run — release post for \`${TAG}\` at \`/releases/${VERSION}\`"
echo "### Narrative body (pre-MDX)"
echo '```markdown'; cat /tmp/site_body.md; echo '```'
echo "### Rendered \`app/releases/${VERSION}/page.mdx\`"
echo '```mdx'; cat /tmp/site_page/page.mdx; echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
echo "Dry-run: no token minted, no PR opened."
- name: Mint App token (omnigent-site)
id: app-token
if: env.DRY_RUN != 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
@@ -126,6 +319,7 @@ jobs:
repositories: omnigent-site
- name: Checkout omnigent-site (sync target)
if: env.DRY_RUN != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ env.SITE_REPO }}
@@ -133,6 +327,7 @@ jobs:
path: site
- name: Open or update the release-post PR (omnigent-site)
if: env.DRY_RUN != 'true'
working-directory: site
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -158,7 +353,7 @@ jobs:
echo "Release-post PR already open for ${RELEASES_BRANCH} — force-push updated it."
exit 0
fi
body="$(printf 'Publishes the **%s** release post at `/releases/%s`, mirroring the curated GitHub Release notes.\n\nGenerated by omnigent `.github/workflows/publish-changelog.yml`. Edit the GitHub Release, not this file.' "$TAG" "$VERSION")"
body="$(printf 'Publishes the **%s** release post at `/releases/%s` the curated GitHub Release notes reformatted into the site'"'"'s narrative, prose-driven style.\n\nGenerated by omnigent `.github/workflows/publish-changelog.yml`. Edit the GitHub Release, not this file.' "$TAG" "$VERSION")"
gh pr create \
--repo "$SITE_REPO" \
--base main \
@@ -173,6 +368,7 @@ jobs:
# when the branch doesn't exist or carries nothing beyond main (e.g. a patch
# release with no staged docs).
- name: Open docs-branch → main PR (omnigent-site)
if: env.DRY_RUN != 'true'
working-directory: site
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -206,3 +402,38 @@ jobs:
--head "$DOCS_BRANCH" \
--title "docs: publish ${VERSION%.*} docs to the live site" \
--body "$body"
# ::add-mask:: redacts rendered logs, not artifact files — scrub the key
# from artifacts (incl. the unscanned stderr) before upload.
- name: Redact secrets from artifacts
if: always() && steps.creds.outputs.available == 'true'
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
run: |
set -euo pipefail
[ -n "${LLM_API_KEY:-}" ] || exit 0
python3 - <<'PYEOF'
import os, pathlib
key = os.environ.get("LLM_API_KEY", "")
for f in ["/tmp/format-stderr.log", "/tmp/format_out.txt",
"/tmp/format_prompt.txt", "/tmp/site_body.md"]:
p = pathlib.Path(f)
if not p.is_file() or not key:
continue
t = p.read_text(encoding="utf-8", errors="replace")
if key in t:
p.write_text(t.replace(key, "***REDACTED***"), encoding="utf-8")
print(f"redacted key from {f}")
PYEOF
- name: Upload logs on failure
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: publish-changelog-${{ env.TAG }}-${{ github.run_id }}
path: |
/tmp/format-stderr.log
/tmp/format_out.txt
/tmp/site_body.md
retention-days: 7
if-no-files-found: ignore
+8 -11
View File
@@ -71,24 +71,21 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 1. Build the web UI FIRST into the package tree, clean. Ordering is
# load-bearing: the wheel packages on-disk files, so the bundle must
# exist before `uv build`. `rm -rf` backstops Vite's emptyOutDir
# against stale bundles; `npm ci` installs the exact locked deps.
# `--legacy-peer-deps` matches how web's lockfile is generated and
# validated everywhere else (lint, e2e-ui, web-tests, the regen
# jobs) — required for the React 19 peer conflict; without it `npm ci`
# rejects the lockfile ("Missing: yaml@1.10.3 from lock file").
# against stale bundles; `pnpm install --frozen-lockfile --filter web`
# installs the exact locked deps for the web workspace package.
- name: Build web UI (clean, fresh)
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
rm -rf omnigent/server/static/web-ui
npm --prefix web ci --legacy-peer-deps
npm --prefix web run build # Vite outDir -> omnigent/server/static/web-ui
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build # Vite outDir -> omnigent/server/static/web-ui
# 2. Tag-driven: the tag must match the version in all three pyprojects
# and the core package's `==` sibling-SDK pins, so the lockstep
+231 -59
View File
@@ -103,13 +103,13 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# Final X.Y.Z or a PEP 440 pre-release (a/b/rc). No dev/post here.
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]]; then
# Final X.Y.Z or a PEP 440 pre-release (rc). No dev/post/alpha/beta here.
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$ ]]; then
echo "::error::Invalid release version: ${VERSION} (expect 0.6.0 or 0.6.0rc1)"; exit 1
fi
major="${VERSION%%.*}"; rest="${VERSION#*.}"; minor="${rest%%.*}"
prerelease=false
case "$VERSION" in *a[0-9]*|*b[0-9]*|*rc[0-9]*) prerelease=true ;; esac
case "$VERSION" in *rc[0-9]*) prerelease=true ;; esac
{
echo "version=${VERSION}"
echo "tag=v${VERSION}"
@@ -182,8 +182,24 @@ jobs:
BASE_SHA: ${{ steps.state.outputs.base_sha }}
run: |
set -euo pipefail
# This gate is meta-CI, not CI itself. Every job this Release workflow
# spawns (plan, benchmark, cut, bump-main, …) leaves a check-run on
# the base commit; a single premature failure on a prior dispatch
# would otherwise poison the SHA and block every retry in a
# self-sustaining loop. Exclude *all* check-runs that belong to a run
# of this workflow, identified by run ID in details_url — not by job
# name, so a real nightly `benchmark` regression (different workflow)
# is still gated.
own_runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/release.yml/runs?head_sha=${BASE_SHA}&per_page=100&status=completed" \
--jq '.workflow_runs[].id' 2>/dev/null | paste -sd, -)"
runs="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${BASE_SHA}/check-runs?per_page=100" \
--paginate --jq '.check_runs[] | [.name, .status, .conclusion // "-"] | @tsv')"
--paginate --jq '.check_runs[] | [.name, .status, .conclusion // "-", .details_url] | @tsv')"
# Drop check-runs whose details_url references one of this
# workflow's own runs (e.g. .../actions/runs/<id>/job/<id>).
runs="$(printf '%s' "$runs" | awk -F'\t' -v rel="$own_runs" '
BEGIN { n=split(rel, a, ","); for (i=1; i<=n; i++) if (a[i]!="") runs[a[i]]=1 }
{ own=0; for (r in runs) if (index($4, "/runs/" r "/")) { own=1; break }
if (!own) print $1 "\t" $2 "\t" $3 }')"
total="$(printf '%s' "$runs" | grep -c . || true)"
pending="$(printf '%s' "$runs" | awk -F'\t' '$2 != "completed"' || true)"
# Cancelled runs are chronically present on main (superseded
@@ -230,18 +246,20 @@ jobs:
echo "| Mode | $([ "$DRY_RUN" = "true" ] && echo "DRY RUN — nothing pushed" || echo "EXECUTE") |"
} >> "$GITHUB_STEP_SUMMARY"
# Run benchmark on the release commit vs the previous stable release tag —
# same runner, back-to-back, so machine variance cancels out. A detected
# regression surfaces as an output flag that gates the benchmark-approve job
# (requiring manual sign-off) rather than failing outright. Skipped on dry
# runs, already-converged runs, and when skip_benchmark=true.
benchmark:
# Seed the corpus at the OLDER release's schema head so both servers can
# boot: the baseline (older code) reads it natively, and the candidate (newer
# code) auto-migrates it forward on startup. Migrations are forward-only, so
# seeding at the newer schema would leave a DB the older code can't read.
# The seeded bench.db is passed to baseline + candidate as an artifact so
# they run in parallel on separate runners (each migrates/reads its own copy).
benchmark-seed:
needs: [authorize, plan]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !inputs.skip_benchmark }}
outputs:
regression: ${{ steps.compare.outputs.regression }}
prev_tag: ${{ steps.prev.outputs.tag }}
has_prev: ${{ steps.prev.outputs.found }}
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 20
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
@@ -251,7 +269,6 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
# Full history so we can re-checkout the previous release tag.
fetch-depth: 0
persist-credentials: false
@@ -265,40 +282,8 @@ jobs:
with:
enable-cache: true
# Seed once with the same corpus size as the nightly (5000×200) so the
# candidate and baseline numbers are on an equal footing and directly
# comparable to the nightly trend dashboard. The DB is reused for both
# candidate and baseline runs — both check out different code but share
# the same pre-populated SQLite file, keeping conditions identical.
# Cache key mirrors benchmark.yml: schema head + seed script hash.
- name: Resolve seed cache key
id: seedkey
run: |
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
echo "key=benchdb-sqlite-${HEAD}-5000x200-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" \
>> "$GITHUB_OUTPUT"
- name: Restore seeded SQLite corpus
id: seedcache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: bench.db
key: ${{ steps.seedkey.outputs.key }}
- name: Seed SQLite corpus
if: steps.seedcache.outputs.cache-hit != 'true'
run: |
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
- name: Benchmark candidate (release base)
run: |
uv sync --extra dev
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 --runs 3 --output candidate.json
echo "Candidate benchmark complete." | tee -a "$GITHUB_STEP_SUMMARY"
- name: Install dependencies
run: uv sync --extra dev
- name: Find previous stable release tag
id: prev
@@ -317,20 +302,188 @@ jobs:
echo "tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Benchmark previous release (${{ steps.prev.outputs.tag }})
# Seed at the OLDER schema head so both servers can boot. When there is
# no previous release (first cut) seed at the current schema instead.
- name: Seed at previous release schema
if: steps.prev.outputs.found == 'true'
run: |
git checkout "${{ steps.prev.outputs.tag }}"
uv sync --extra dev
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
- name: Seed at current schema (no previous release)
if: steps.prev.outputs.found != 'true'
run: |
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri "sqlite:///bench.db" \
--sessions 5000 --items-per-session 200
- name: Upload seeded corpus
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bench-db-${{ github.run_id }}
path: bench.db
retention-days: 1
if-no-files-found: error
# Benchmark the previous release. Skipped on the first cut (no prev tag).
benchmark-baseline:
needs: [benchmark-seed]
if: ${{ needs.benchmark-seed.outputs.has_prev == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Check out previous release
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.benchmark-seed.outputs.prev_tag }}
persist-credentials: false
- name: Download seeded corpus
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bench-db-${{ github.run_id }}
path: .
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
- name: Run baseline benchmark
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 --runs 3 --output baseline.json
# Return to release base so compare.py is available.
git checkout -
echo "Baseline benchmark complete." | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload baseline results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: baseline-results-${{ github.run_id }}
path: baseline.json
retention-days: 7
if-no-files-found: error
# Benchmark the release candidate. Always runs (even on first cut, solo).
benchmark-candidate:
needs: [authorize, plan, benchmark-seed]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !inputs.skip_benchmark }}
runs-on: ubuntu-latest
timeout-minutes: 30
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Check out release base
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
persist-credentials: false
- name: Download seeded corpus
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bench-db-${{ github.run_id }}
path: .
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
# The seeded bench.db is at the previous release's schema head. The
# candidate (newer code) auto-migrates on server boot, but the z7
# binary-UUID conversion is a per-row Python loop over ~1M items that
# takes >90s — longer than the benchmark harness's health-check window.
# Pre-migrate explicitly so the server boots against an already-current DB.
- name: Migrate corpus to release-base schema
run: |
uv run --no-sync omni debug db-upgrade "sqlite:///bench.db"
echo "Corpus migrated to release-base schema head." | tee -a "$GITHUB_STEP_SUMMARY"
- name: Run candidate benchmark
run: |
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri "sqlite:///bench.db" \
--iterations 100 --runs 3 --output candidate.json
echo "Candidate benchmark complete." | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload candidate results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: candidate-results-${{ github.run_id }}
path: candidate.json
retention-days: 7
if-no-files-found: error
# Compare baseline vs candidate. Downloaded as artifacts from the parallel
# jobs. A detected regression surfaces as an output flag that gates the
# benchmark-approve job (requiring manual sign-off) rather than failing
# outright. Skipped on the first cut (no baseline).
benchmark:
needs: [authorize, plan, benchmark-seed, benchmark-baseline, benchmark-candidate]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !inputs.skip_benchmark && needs.benchmark-seed.outputs.has_prev == 'true' }}
outputs:
regression: ${{ steps.compare.outputs.regression }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
PIP_INDEX_URL: https://pypi.org/simple
steps:
- name: Check out release base
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
- name: Download results
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: "{baseline,candidate}-results-${{ github.run_id }}"
merge-multiple: true
path: .
- name: Compare results
id: compare
if: steps.prev.outputs.found == 'true'
run: |
set +e
uv run --no-sync dev/benchmarks/omnigent/compare.py \
@@ -340,11 +493,9 @@ jobs:
--output-markdown comparison.md
RC=$?
set -e
# Expose regression flag as an output so the approval job can gate on it.
echo "regression=$([ $RC -ne 0 ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
- name: Write step summary
if: steps.prev.outputs.found == 'true'
run: |
REGRESSION="${{ steps.compare.outputs.regression }}"
{
@@ -359,10 +510,10 @@ jobs:
echo ""
cat comparison.md 2>/dev/null || echo "_No comparison report generated._"
echo ""
echo "_Candidate vs ${{ steps.prev.outputs.tag }} · 100 iterations × 3 runs · SQLite · threshold 100% on P50/P95_"
echo "_Candidate vs ${{ needs.benchmark-seed.outputs.prev_tag }} · 100 iterations × 3 runs · SQLite · threshold 100% on P50/P95_"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload benchmark artifacts
- name: Upload comparison artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
@@ -370,6 +521,7 @@ jobs:
path: |
candidate.json
baseline.json
comparison.md
retention-days: 90
if-no-files-found: ignore
@@ -383,6 +535,7 @@ jobs:
!inputs.dry_run &&
needs.plan.outputs.already_done != 'true' &&
!inputs.skip_benchmark &&
needs.benchmark.result == 'success' &&
needs.benchmark.outputs.regression == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
@@ -394,8 +547,12 @@ jobs:
# Stamp + tag + push. Only reached on a real run that isn't already converged.
cut:
needs: [authorize, plan, benchmark, benchmark-approve]
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' }}
needs: [authorize, plan, benchmark-candidate, benchmark, benchmark-approve]
# benchmark (compare) + benchmark-approve are skipped on the first cut (no
# previous release to compare against); benchmark-candidate always runs and
# is the real gate. `!cancelled` lets the optional compare/approve jobs be
# skipped without blocking, while still failing the run if they actually fail.
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' && !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -490,7 +647,7 @@ jobs:
echo " -f ref=${TAG} -f destination=pypi -f dry-run=false # real publish"
echo ' ```'
if [ "$PRERELEASE" = "true" ]; then
echo "2. Validate the rc from PyPI (see RELEASING.md). The GitHub draft for ${TAG} stays unpublished."
echo "2. Validate the rc from PyPI (see RELEASING.md). No GitHub release is created for rc tags (rcs live on PyPI only) — skip straight to the next rc or the final cut."
else
echo "2. Merge the CHANGELOG PR, curate the ${TAG} draft notes, then dispatch finalize-release.yml (tag=${TAG})."
fi
@@ -500,7 +657,6 @@ jobs:
# never re-freezes and doc-sync keeps deriving the right X.Y-docs branch.
bump-main:
needs: [authorize, plan, cut]
if: ${{ !inputs.dry_run && needs.plan.outputs.branch_exists == 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -510,8 +666,24 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.plan.outputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
BRANCH_EXISTS: ${{ needs.plan.outputs.branch_exists }}
run: |
set -euo pipefail
# Gate in shell, not a job-level `if`: a CLI/API dispatch delivers
# boolean inputs as the STRING "false", which is truthy in an
# expression, so `!inputs.dry_run` silently skipped this job at the
# v0.7.0 cut. Shell string comparison is dispatch-channel-proof and
# logs its decision instead of vanishing from the run.
if [ "$DRY_RUN" = "true" ]; then
echo "Dry run — not dispatching the main bump." | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$BRANCH_EXISTS" != "false" ]; then
echo "Release branch pre-existed (not the first cut of this cycle) — main bump not needed." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
# A cut below main's current line (a throwaway rehearsal rc, or
# resurrecting an old series for a backport) must not walk main's
# version backwards.
+134
View File
@@ -0,0 +1,134 @@
# A contributor comments `/rerun` on a PR to re-run its failed CI **on the
# existing head commit** -- no empty commit, no rebase, so no push event and
# thus no dismissed approvals (branch protection keeps dismiss-stale-reviews
# on to block approve-then-swap). Use for flaky-test recovery instead of
# pushing a throwaway commit to re-trigger checks.
#
# CI here runs entirely against the in-process mock LLM (no gateway spend), so
# a re-run costs only Actions minutes; `cancel-in-progress` on each suite caps
# concurrent burn. Polly AI Review (the one real-LLM path) is gated by
# maintainer approval elsewhere and is intentionally NOT re-run here.
#
# Authorization: the PR author (so fork contributors can re-run their own PR)
# OR a write-access commenter (OWNER/MEMBER/COLLABORATOR). `issue_comment` runs
# from the base repo, so its token is writable even for fork PRs and is not
# held behind the fork-approval gate -- unlike `pull_request_target`, this
# needs no privileged `workflow_run` relay (cf. rerun-security-gate*.yml).
name: Rerun CI on /rerun comment
on:
issue_comment:
types: [created]
# Read-only at the top level; write scopes live on the job below.
permissions:
contents: read
concurrency:
# One re-run in flight per PR; a second `/rerun` supersedes the first.
group: rerun-ci-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
rerun:
name: Re-run failed CI for the PR head
permissions:
actions: write # gh run rerun
pull-requests: read # resolve the PR head SHA
issues: write # react to the comment + post the result
# PR comment, body starts with `/rerun`, not a bot, in this repo, AND the
# commenter is the PR author or has write access. `issue.user.login` is the
# PR author; `comment.user.login` is the commenter.
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/rerun')
&& !endsWith(github.actor, '[bot]')
&& (
github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'COLLABORATOR'
|| github.event.comment.user.login == github.event.issue.user.login
)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# The job `if` startsWith() also matches `/rerunfoo`; re-validate `/rerun`
# as a command (first non-space token is exactly `/rerun`, optional args).
- name: Validate command
id: cmd
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if ! grep -qE '^[[:space:]]*/rerun([[:space:]]|$)' <<<"$COMMENT_BODY"; then
echo "::notice::Comment mentions '/rerun' but not as a command; skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Acknowledge
if: steps.cmd.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
gh api "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
-f content=eyes --silent || true
- name: Re-run failed CI runs for the PR head
if: steps.cmd.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
# Integer from the payload, but sanitised to digits before shell use.
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
PR_NUMBER="$(tr -dc '0-9' <<<"$PR_NUMBER")"
[ -n "$PR_NUMBER" ] || { echo "::error::Empty PR number."; exit 1; }
# Resolve the PR's CURRENT head SHA (a push could have superseded any
# SHA recorded at comment time).
SHA="$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha')"
echo "PR #$PR_NUMBER head $SHA"
# Latest run per workflow for this SHA, restricted to `pull_request`
# events -- this is the test-suite set (CI, Lint, E2E, E2E UI,
# Integration, Docker build, web Tests). It deliberately EXCLUDES the
# merge machinery (Merge Ready, Maintainer Approval, Polly) which run
# on pull_request_target / workflow_run / issue_comment, so `/rerun`
# never re-triggers a gate or the real-LLM review.
mapfile -t FAILED < <(
gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=100" --paginate \
--jq '[.workflow_runs[] | select(.event=="pull_request")]
| group_by(.name)
| map(sort_by(.created_at) | last)
| .[] | select(.conclusion=="failure")
| "\(.id)\t\(.name)"'
)
if [ "${#FAILED[@]}" -eq 0 ]; then
echo "No failed pull_request CI runs for $SHA."
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body "🔁 \`/rerun\`: no failed CI runs on the current head (\`${SHA:0:7}\`) to re-run. If a check is stuck *pending*, it needs a push or a maintainer, not a re-run."
exit 0
fi
RERAN=""
while IFS=$'\t' read -r id name; do
[ -n "$id" ] || continue
echo "• Re-running failed jobs in '$name' (run $id)"
# --failed: re-run only the failed jobs (cheapest path for a flake).
# --repo is REQUIRED: this job has no checkout, so `gh run rerun`
# cannot infer the repo from a git remote and would fail client-side.
if gh run rerun "$id" --repo "$REPO" --failed; then
RERAN="$RERAN"$'\n'"- $name"
else
echo "::warning::Could not re-run '$name' (run $id) -- may be in progress."
RERAN="$RERAN"$'\n'"- $name ⚠️ (skipped: already running or not re-runnable)"
fi
done < <(printf '%s\n' "${FAILED[@]}")
NOTE="The \`Merge Ready\` gate re-evaluates automatically when these complete."
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body "🔁 \`/rerun\`: re-running failed jobs on \`${SHA:0:7}\`:${RERAN}"$'\n\n'"$NOTE"
+3 -5
View File
@@ -1,18 +1,16 @@
name: Reviewer SLA
# Daily (weekday) sweep that enforces a 5-working-day reviewer SLA: any open PR
# Manual-only sweep that enforces a 5-working-day reviewer SLA: any open PR
# awaiting review from a maintainer -- or open issue awaiting a maintainer
# assignee -- with no reply in 5 working days gets the assignee re-pinged in a
# comment plus a second reviewer (PR) / second assignee (issue), then a one-shot
# `review-sla-escalated` label so it's never nudged twice. All logic + safety
# notes live in review-sla.js (offline unit test: review-sla.test.js).
#
# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN; it
# reads no PR-authored code, only .github/ config + the issues/PRs API.
# Runs on the trusted default branch with the repo GITHUB_TOKEN; it reads no
# PR-authored code, only .github/ config + the issues/PRs API.
on:
schedule:
- cron: "0 8 * * 1-5" # 08:00 UTC, Mon-Fri (weekday SLA -> no weekend pings)
workflow_dispatch:
permissions:
+7 -3
View File
@@ -195,10 +195,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write gateway profile (~/.databrickscfg)
if: steps.creds.outputs.available == 'true'
+3 -3
View File
@@ -4,7 +4,7 @@ name: Backwards-Compat
# suites, over the FULL pairwise (server, runner) version matrix.
#
# The version universe is `main` (the checked-out code = client + tests, always)
# plus every non-rc release tag; we cross every server version with every runner
# plus every final (non-prerelease) release tag; we cross every server version with every runner
# version. Each cell pins the server and/or runner subprocess to that build
# (a "main" axis value leaves that component on the checked-out code) while the
# client and tests stay on main. The (main, main) cell is omitted — it pins
@@ -18,13 +18,13 @@ name: Backwards-Compat
#
# Triggers:
# workflow_dispatch manual; optional `versions` CSV overrides the set.
# schedule every 12h; full pairwise over main + all non-rc tags.
# schedule every 12h; full pairwise over main + all final tags.
on:
workflow_dispatch:
inputs:
versions:
description: "Comma-separated version set for BOTH axes (e.g. 'main,v0.2.0'). Empty = main + all non-rc tags."
description: "Comma-separated version set for BOTH axes (e.g. 'main,v0.2.0'). Empty = main + all final (non-prerelease) tags."
required: false
default: ""
schedule:
+5 -4
View File
@@ -106,7 +106,7 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- uses: ./.github/actions/setup-node
- uses: ./.github/actions/setup-pnpm
- name: Build wheels (no UI)
# Build the wheels WITHOUT the SPA so they stay small (Databricks Apps
@@ -120,10 +120,11 @@ jobs:
run: bash deploy/databricks/build.sh
- name: Build UI
working-directory: web
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Package UI assets
run: |
+14 -5
View File
@@ -80,8 +80,18 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: false
- name: Set up Node 20
uses: ./.github/actions/setup-node
# The pinned Playwright image doesn't ship Node, so install Node before
# the pnpm action (pnpm/action-setup's self-installer needs a Node binary).
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.15.1
standalone: true
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -106,9 +116,8 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Compare the baselines (no --update-snapshots)
# Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning
+17 -9
View File
@@ -95,7 +95,7 @@ jobs:
if ! files=$(gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename'); then
echo "ui=true" >> "$GITHUB_OUTPUT"; echo "file list unavailable -> render"; exit 0
fi
pattern='^(web/|tests/e2e_ui/visual/|tests/e2e_ui/conftest\.py|\.github/actions/setup-node/|\.github/workflows/ui-snapshot\.yml|pyproject\.toml|uv\.lock)'
pattern='^(web/|tests/e2e_ui/visual/|tests/e2e_ui/conftest\.py|\.github/actions/setup-pnpm/|\.github/workflows/ui-snapshot\.yml|pnpm-lock\.yaml|pnpm-workspace\.yaml|pyproject\.toml|uv\.lock)'
if printf '%s\n' "$files" | grep -qE "$pattern"; then
echo "ui=true" >> "$GITHUB_OUTPUT"
echo "render-affecting files changed:"
@@ -106,7 +106,7 @@ jobs:
fi
ui-snapshot:
name: UI Snapshot (visual baselines) [non-blocking]
name: UI Snapshot (visual baselines)
needs: detect
# Skipped (not failed) when no render input changed -> reports SUCCESS, so a
# non-UI PR neither runs the render nor blocks a required check.
@@ -130,8 +130,18 @@ jobs:
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Node 20
uses: ./.github/actions/setup-node
# The pinned Playwright image doesn't ship Node, so install Node before
# the pnpm action (pnpm/action-setup's self-installer needs a Node binary).
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.15.1
standalone: true
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -156,14 +166,12 @@ jobs:
- name: Build web SPA
# Build BEFORE pytest: Vite's emptyOutDir clobbers the static dir, so
# never run it alongside the live server. --legacy-peer-deps avoids
# re-resolving the known React 19 peer conflict under @emoji-mart/react.
# never run it alongside the live server.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Compare (PR) or regenerate (dispatch) the visual snapshots
id: snapshot
+59 -3
View File
@@ -15,11 +15,21 @@
# from finalize-release.yml's App-token publish; `workflow_dispatch` covers
# retries and catch-up (e.g. jumping the formula straight to the newest
# version after a missed cycle).
#
# brew resolves resources through pip's `--uploaded-prior-to=P1D` window, so a
# run within 24h of the PyPI upload cannot see the new sdist and used to go
# red every release day (v0.6.0, v0.7.0). Now: runs inside that window defer
# (green, with a warning), and the nightly `schedule` catch-up — which targets
# the latest published release and no-ops when the formula is already
# current — opens the tap PR once the window has passed.
name: Update Homebrew tap
on:
release:
types: [published]
schedule:
# Nightly catch-up for the P1D window (see header). No-ops when current.
- cron: "45 23 * * *"
workflow_dispatch:
inputs:
tag:
@@ -31,7 +41,7 @@ permissions:
contents: read
concurrency:
group: update-homebrew-${{ github.event.release.tag_name || inputs.tag }}
group: update-homebrew-${{ github.event.release.tag_name || inputs.tag || 'nightly' }}
cancel-in-progress: false
jobs:
@@ -46,12 +56,18 @@ jobs:
- name: Resolve tag and finality
id: r
env:
GH_TOKEN: ${{ github.token }}
EVENT_TAG: ${{ github.event.release.tag_name }}
PRERELEASE: ${{ github.event.release.prerelease }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$EVENT_TAG}"
if [ -z "$tag" ]; then
# Scheduled catch-up: target the latest published final release
# (empty when the repo has none yet — resolves to is_final=false).
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || echo "")"
fi
is_final=true
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
@@ -145,12 +161,29 @@ jobs:
path: tap
persist-credentials: false
- name: Skip when the formula is already at this version
id: current
working-directory: tap
env:
VERSION: ${{ steps.sdist.outputs.version }}
run: |
set -euo pipefail
# Nightly catch-up no-op: the stable url already points at this sdist.
if grep -q "omnigent-${VERSION}\.tar\.gz" Formula/omnigent.rb; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Formula already at ${VERSION} — nothing to do." | tee -a "$GITHUB_STEP_SUMMARY"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Homebrew
if: steps.current.outputs.skip != 'true'
uses: Homebrew/actions/setup-homebrew@18fcb8e3e06b4247c676c506750dc95ea7226479 # 2026-07-10
with:
token: ${{ github.token }}
- name: Rewrite the formula's stable url/sha256
if: steps.current.outputs.skip != 'true'
working-directory: tap
env:
SDIST_URL: ${{ steps.sdist.outputs.url }}
@@ -172,9 +205,12 @@ jobs:
git diff --stat
- name: Regenerate the pinned Python resources
id: regen
if: steps.current.outputs.skip != 'true'
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_FROM_API: "1"
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Make the checkout visible to brew as the real tap.
@@ -185,12 +221,31 @@ jobs:
# deps (certifi/cryptography/pydantic/rpds-py and their transitive
# cffi/pycparser) and the platform-conditional google-antigravity
# wheel stanzas.
brew update-python-resources \
if ! brew update-python-resources \
--exclude-packages=certifi,cryptography,pydantic,rpds-py,cffi,pycparser,google-antigravity \
omnigent-ai/tap/omnigent
omnigent-ai/tap/omnigent; then
# brew resolves through pip's --uploaded-prior-to=P1D window: a
# release <24h old is invisible and resolution ALWAYS fails. Defer
# to the nightly catch-up instead of going red; older releases are
# real failures.
published_at="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq .published_at 2>/dev/null || echo "")"
age_h=999
if [ -n "$published_at" ]; then
age_h="$(python3 -c 'import datetime, sys; d = datetime.datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")); print(int((datetime.datetime.now(datetime.timezone.utc) - d).total_seconds() // 3600))' "$published_at")"
fi
if [ "$age_h" -lt 24 ]; then
echo "deferred=true" >> "$GITHUB_OUTPUT"
echo "::warning::Resource resolution failed with ${TAG} only ${age_h}h old — inside pip's --uploaded-prior-to=P1D window. The nightly catch-up will open the tap PR."
echo "Deferred to the nightly catch-up (${TAG} is ${age_h}h old, inside the 24h PyPI window)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
exit 1
fi
echo "deferred=false" >> "$GITHUB_OUTPUT"
brew style omnigent-ai/tap/omnigent
- name: Assert the hand-maintained sections survived
if: steps.current.outputs.skip != 'true' && steps.regen.outputs.deferred != 'true'
working-directory: tap
run: |
set -euo pipefail
@@ -214,6 +269,7 @@ jobs:
done
- name: Open or update the tap bump PR
if: steps.current.outputs.skip != 'true' && steps.regen.outputs.deferred != 'true'
working-directory: tap
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -68,15 +68,16 @@ jobs:
with:
ref: release/vscode-v${{ inputs.version }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install, build, and package
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm ci
npm run build
npm run package
pnpm install --frozen-lockfile --filter omnigent-vscode
pnpm --filter omnigent-vscode run build
pnpm --filter omnigent-vscode run package
- name: Resolve tag and verify package.json version
id: meta
+6 -3
View File
@@ -62,6 +62,9 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Validate version
env:
VERSION: ${{ inputs.version }}
@@ -80,10 +83,10 @@ jobs:
working-directory: editors/vscode
env:
VERSION: ${{ inputs.version }}
# `npm pkg set` edits ONLY package.json (unlike `npm version`, which also
# rewrites package-lock.json). Keeps the release PR to package.json +
# `pnpm pkg set` edits ONLY package.json (unlike `pnpm version`, which
# also rewrites the lockfile). Keeps the release PR to package.json +
# CHANGELOG.md.
run: npm pkg set version="$VERSION"
run: pnpm pkg set version="$VERSION"
- name: Add the CHANGELOG section (placeholder)
working-directory: editors/vscode
@@ -0,0 +1,31 @@
name: Waiting on Author Test
# Offline unit test for waiting-on-author hygiene. Runs on PR head without
# secrets or network and only when the workflow logic changes.
on:
pull_request:
paths:
- .github/scripts/waiting_on_author.py
- .github/scripts/waiting_on_author_test.py
- .github/workflows/waiting-on-author.yml
- .github/workflows/waiting-on-author-test.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: waiting-on-author-test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run waiting-on-author unit test
run: python3 .github/scripts/waiting_on_author_test.py
+46
View File
@@ -0,0 +1,46 @@
name: Waiting on Author Hygiene
# Keeps the `waiting-on-author` PR label actionable: author activity clears it,
# and PRs that sit in that state for 7 days are closed. The workflow runs from
# trusted default-branch code and never checks out PR-authored files.
on:
pull_request_target:
types: [synchronize]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: waiting-on-author-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: false
jobs:
hygiene:
if: github.repository == 'omnigent-ai/omnigent'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check out .github
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Update waiting-on-author state
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/waiting_on_author.py
+11 -13
View File
@@ -24,14 +24,14 @@ concurrency:
cancel-in-progress: true
jobs:
# Security precondition gate: npm ci/test runs the PR's own install hooks and
# Security precondition gate: pnpm install/test runs the PR's own install hooks and
# test code, so untrusted PRs are held until the scan passes (security-gate.yml).
# Trusted authors and non-PR events pass through.
gate:
uses: ./.github/workflows/security-gate.yml
npm-test:
name: npm test
web-test:
name: web test
needs: gate
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
@@ -41,31 +41,29 @@ jobs:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
working-directory: web
# Pin the npm registry to the npmjs default.
# Pin the npm registry to the npmjs default; limit to the web package
# so the Electron package's large native devDependencies are not fetched.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: npm ci --legacy-peer-deps
run: pnpm install --frozen-lockfile --filter web
- name: Check formatting
working-directory: web
run: npm run format:check
run: pnpm --filter web run format:check
- name: Run tests with coverage
working-directory: web
run: npm run test:coverage
run: pnpm --filter web run test:coverage
# Distill the v8 json-summary into a single total.txt, mirroring the
# backend's coverage-report job. ui-code-coverage.yml (privileged
# workflow_run) consumes this artifact and posts the report-only status.
- name: Summarize coverage
if: always()
working-directory: web
run: |
cd web
mkdir -p ui-coverage-summary
if [[ ! -f coverage/coverage-summary.json ]]; then
echo "::warning::No coverage-summary.json; skipping UI coverage report."
+3
View File
@@ -2,6 +2,8 @@
build/
dist/
node_modules/
.pnpm-store/
.pnpm-debug.log*
reviews/
# Generated artifact; never committed.
@@ -57,6 +59,7 @@ dev/omnidev/target/
# Playwright test run output (screenshots, traces, videos).
test-results/
output/playwright/
# Visual-snapshot failure output (actual/expected/diff PNGs from the UI diff
# gate). Regenerated each run; only the baseline under
+8 -1
View File
@@ -36,10 +36,17 @@ repos:
types: [python]
files: ^tests/
- id: no-hardcoded-models
name: no new hardcoded LLM model ids
language: system
entry: .venv/bin/python dev/lint/lint_no_hardcoded_models.py
pass_filenames: false
files: ^((omnigent|scripts|examples|\.github|dev/lint)/.*\.(py|ya?ml|json|toml|sh)|dev/lint/hardcoded_model_allowlist\.txt)$
- id: web-prettier
name: web prettier
language: system
entry: npm --prefix web exec -- prettier --write
entry: bash -c 'test -x web/node_modules/.bin/prettier && web/node_modules/.bin/prettier --write "$@"' --
files: ^web/.*\.(css|html|js|jsx|json|md|mdx|ts|tsx|yaml|yml)$
# Exclude generated assets: web-ui build output, Xcode asset catalogs,
# and Apple Icon Composer `.icon` bundles (machine-formatted; prettier
+39
View File
@@ -9,6 +9,17 @@ Run the `pre-commit` hook before committing (`pre-commit run --all-files`, or
let it run on staged files via `git commit`). Fix any issues it reports so the
commit lands clean — CI runs the same checks.
## Local development shortcuts
Use `just` for common tasks; run `just --list` for grouped recipes.
- `just ensure` — install/check prerequisites
- `just run-ios` / `just run-android` — build/run mobile apps
- `just dev` / `just dev-mobile` — start the omnigent dev pod
- `just electron-dev` / `just electron-build` — Electron desktop shell
- `just lint` / `just lint-all` — run pre-commit
- `just normalize-locks` — rewrite lockfile registries to PyPI/npmjs.org
## Pull requests
When you open a pull request, fill in the repo's PR template at
@@ -30,6 +41,20 @@ Generate the description from the actual diff and this session's context — lea
with the motivation, then the change. Don't pass a `--body` that skips these
sections.
## Finishing a task
When you finish a task, print instructions to the user on how to test it: the
commands to run, the inputs to provide, or the steps to reproduce so they can
verify the result themselves. Don't leave the user guessing how to confirm the
work — tell them exactly what to do.
## Deprecating features
When deprecating a feature, note the version in which it is expected to be
removed so we can clean it up when that version ships. Call out the deprecation
version in code (e.g. a `@deprecated` tag or comment naming the target release)
and in the PR/commit description, so there's a clear marker to act on later.
## Code comments
Keep comments short and focused on the code, not on the change history.
@@ -41,3 +66,17 @@ Keep comments short and focused on the code, not on the change history.
*why* it exists, in terms a future reader needs. Don't reference PR numbers,
issue numbers, or ticket IDs (e.g. `#1646`, `fixes JIRA-123`); the scenario
should be clear without chasing external links.
## Framework-owned instructions
Keep runtime lifecycle and metadata instructions separate from portable agent
instructions:
- Agent-spec and per-request instructions are user-authored. Framework-owned
instructions are additive runtime behavior and are appended after them in
`omnigent/runtime/prompt.py`.
- Keep the canonical instruction text and lifecycle gate in the owning framework
module. Harness adapters should only transport the composed instructions; do
not duplicate policy across adapters or add lifecycle metadata to `AgentSpec`.
- If framework instructions grow beyond a small ordered list, introduce a
structured `FrameworkInstructions` value at the prompt-composition boundary.
+135
View File
@@ -5,11 +5,146 @@ generated at release time from each PR's `## Changelog` section, tagged by the
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
website under `/releases`.
## [v0.7.0] — 2026-07-27
- [Bug fix] Hermes thinking now appears in mirrored web conversations. (#1645)
- [Bug fix] Image and file attachments now survive session relaunches on remote host runners; attachments that fail to load show a visible marker instead of silently disappearing. (#2085)
- [UI / Feature] Voice dictation in the composer now works in Electron, Firefox, and Chromium via optional server-side transcription (`omnigent[dictation]`) — local models, live streaming partials, audio never leaves your server. (#2093)
- [UI / Bug fix / Test/CI] Hide Claude task completion control messages from conversation history while preserving them for resume context. (#2104)
- [Bug fix / Feature / Docs] Operators can mount pre-created PersistentVolumeClaims (NFS/SMB/SAN) into Kubernetes sandbox runners via `sandbox.kubernetes.pvc_mounts` (read-only by default) (#2435)
- [Bug fix / Test/CI] `/compact` no longer races when multiple compact requests hit the same session at once (#2585)
- [Bug fix / Test/CI] `sys_call_async` / `sys_cancel_async` now consistently use `handle_id` as the cancel identifier. (#2586)
- [Bug fix / Test/CI] Runner idle timeout no longer kills sessions waiting on async tools, timers, or approval prompts (#2588)
- [Bug fix] Misconfigured runner tool policies deny tool calls instead of silently allowing them (#2589)
- [UI / Feature] Slash-command menus now match any part of a command's name, so `/using-superpowers` finds `/superpowers:using-superpowers` (#2655)
- [Bug fix] Host-launched runners now reuse delegated credentials instead of repeating Databricks authentication during startup. (#2762)
- [Feature] Projects are now a first-class entity with a `/v1/projects` CRUD API (create, list, rename, delete) and per-session membership. (#2765)
- [Bug fix] Hermes forwarder introspects state.db columns to survive cross-version schema drift (#2774)
- [Feature] `omni usage` reports your LLM cost for today / the last 7 / 30 days, with a per-session per-model cost breakdown (#2787)
- [Feature / Chore] Native Claude sessions start faster by coalescing runner initialization into one handshake. (#2793)
- [UI / Bug fix / Feature / Docs / Test/CI] Claude-native launch and in-session pickers now share the selected host's live model catalog, including Claude Code's managed routes. (#2831)
- [Bug fix] Managed BoxLite sandboxes remain available after provisioning so agent launches can execute commands reliably. (#2846)
- [Feature] Server-side smart routing can now call an external `routes:select` router via `routing.provider: external`, with provider-agnostic auth (`api_key`) and model-name mapping (`model_prefix`) (#2864)
- [UI] Chat code blocks no longer load the syntax-highlighter engine until the first (#2886)
- [UI / Bug fix] The main chat "Working…" indicator now clears reliably when the session goes idle, instead of occasionally staying lit after a reply completes. (#2900)
- [Bug fix] The performance benchmark harness now records HTTP failures and continues the rest of the suite instead of aborting, and excludes fully-failed runs from the summary averages. (#2917)
- [Feature / Test/CI] Scheduled tasks can now be created without a workspace or a pinned host for non-code work (research, summaries, chat-only, MCP-only); an unset host runs on your live host at fire time, and an unset workspace defaults to the host's home directory. A pinned host is now authorized (existence + ownership) at create time rather than only at fire time. (#2946)
- [Chore / Test/CI] N/A — internal benchmark/dev tooling; no user-facing impact. (#2947)
- [Feature] Set `OMNIGENT_CONTAINER_RUNTIME=podman` to use Podman (or another supported runtime) globally instead of Docker, without editing every agent's YAML. (#2949)
- [Bug fix] Sending a message to a session whose Claude Code terminal crashed no longer (#2951)
- [Bug fix] The desktop app now always quits within a few seconds even if its background cleanup stalls or the OS re-quit is dropped. (#2972)
- [UI / Bug fix] Messages send immediately when a session's only remaining work is a background job, instead of being held in the queue until it finishes (#2974)
- [UI / Feature] Desktop update notifications now appear in a native corner toast that works (#2975)
- [Bug fix / Chore] Runner startup no longer waits several seconds for Git's optional untracked-file cache probe. (#2976)
- [Feature / Test/CI] `omnigent` benchmark harness gains `--network-delay-ms` and per-journey HTTP request counts (#2977)
- [UI / Bug fix] Pi sessions now show reasoning while it streams and after conversation history reloads. (#2979)
- [Feature] The runner log now records why the runner exited (crash traceback, signal, idle timeout, tunnel close, or parent death) (#2985)
- [UI / Feature] Set up a missing agent from the New Chat dialog with a guided, step-by-step checklist (#2987)
- [UI / Bug fix / Feature] HTTP headers can now be set and edited for HTTP MCP servers in the session agent info panel. (#2989)
- Capped unbounded DB list queries in the permission store and reduced session opens in `check_access`/`get_permission_level` from 23 to 1. (#2995)
- Deleting a conversation with many descendants now issues a single FTS DELETE instead of one per descendant. (#2999)
- [UI / Bug fix] Android auto theme and system-bar icons now stay readable with both device themes and explicit in-app theme overrides. (#3006)
- [UI / Feature] 3D model files (STL, 3MF, OBJ) now render an interactive preview in the file browser (#3007)
- [UI / Bug fix] Subagents panel Graph View now shows the same status dot colors as List View (#3009)
- [Feature / Test/CI] Scheduled-task runs now transition to a terminal state (`succeeded`/`failed`) as soon as the dispatched turn finishes, instead of staying `running` forever; run history is readable at `GET /v1/scheduled-tasks/{id}/runs`. (#3014)
- [Feature / Chore] When enabled, new sessions receive concise semantic titles in the background without adding work or latency to the active agent turn. (#3024)
- [Feature] Offload dictation speech-to-text to a remote worker with (#3025)
- [Bug fix / Docs / Test/CI] Codex-native subagents now appear in the Agents panel with their live conversations. (#3028)
- [Bug fix] Credential proxy no longer attaches injected credentials to TRACE/OPTIONS requests, and the egress proxy now honors Max-Forwards as a conformant intermediary. (#3029)
- [Docs] N/A — internal documentation cleanup. (#3031)
- [Feature] Import local Qwen, Kiro, Pi, and Kimi coding chats into Omnigent (#3032)
- [UI / Feature] Press ⌘⌥V (Ctrl+Alt+V) to toggle voice dictation from anywhere; while dictating, Enter keeps the text and Esc discards it (#3044)
- [UI / Feature] Added: "Auto · smart routing" harness option in the new-chat picker — lets the intelligent router pick both harness and model based on the task description (#3045)
- [Feature] Import existing OpenCode chats, including files and tool activity, with `omnigent import` (#3046)
- [Bug fix] Dictation streams now reliably release their worker slot when a browser disconnects abruptly. (#3048)
- [UI] New-session composer moves harness configuration into a gear-icon modal, with a cleaner agent picker (needs-setup and custom agents folded into flyouts) and Smart Routing offered as a model option. (#3050)
- [Bug fix / Feature] The Slack bot can now run against an Omnigent server deployed on Databricks Apps, (#3051)
- [Feature] Sessions can now be filed into first-class projects via `PATCH /v1/sessions/{id}` and listed with `GET /v1/sessions?project=<name>`, which dual-reads first-class membership and legacy project labels. (#3053)
- [Bug fix / Test/CI] Fixed SDK session telemetry always recording `harness: null` in server deployments not started via the CLI. (#3054)
- [Test/CI] N/A — test-only reliability change. (#3056)
- [Bug fix] Databricks OAuth CLI profiles no longer fail with a misleading "malformed profile" error; the message now explains the real fix (install `omnigent[databricks]` or refresh the OAuth session). (#3059)
- [Bug fix] An idle runner shutting down after inactivity no longer shows a scary "disconnected" error — just send a message to wake it back up. (#3060)
- [UI / Feature] The sidebar now uses first-class projects: create empty projects, rename and delete them, and file sessions into them — while existing label-based projects keep working. (#3061)
- [UI / Bug fix] The "Host is offline — click to reconnect" prompt now appears in the composer's host badge instead of a separate banner below the composer (#3062)
- [Test/CI] N/A (test-only change) (#3063)
- [Feature / Docs / Test/CI] New `databricks_cli` credential-proxy type lets sandboxed agents use the Databricks CLI without the real token entering the sandbox (#3080)
- [UI / Feature / Test/CI] Polly sessions running on Claude SDK can start Goal mode from the chat composer. (#3084)
- [UI / Bug fix] The Configure agent modal's footer no longer shows a gray background band behind Cancel/Save (#3089)
- [UI / Feature] The Sidebar is more compact and polished, with clearer status indicators and richer session details on hover. (#3092)
- [UI] Reordered the project-folder header buttons (new-session before the menu), (#3096)
- [Chore / Breaking] `omni server start` is removed; use `omni server --background` to launch the (#3105)
- [Bug fix] `omnigent server --host 0.0.0.0` now enables accounts (login) mode automatically instead of silently 401-ing every request (#3107)
- [Feature] Projects can store default session settings (host, workspace, harness, model, …) via a new `config` field on the projects API. (#3108)
- [Bug fix] Databricks-served Claude models no longer break non-streaming responses (prompt-policy and smart routing) when returning typed content blocks (#3109)
- [Feature] `omnidev omnigent <args…>` runs an omnigent command against the current checkout's pod via `uv run omnigent`, with the pod's isolated env applied (#3110)
- [UI / Feature] Configure a session's model, effort, and smart routing mid-chat from a new gear icon in the composer (#3111)
- [UI / Feature / Test/CI] Add the `/tasks` Scheduled Tasks page with sidebar navigation, task rows, empty states, suggestion chips, create-dialog entry points, and Playwright E2E coverage. (#3112)
- [Bug fix] Reading image files in a Claude Code native session no longer bloats conversation history and breaks resume/compaction on large sessions (#3113)
- [Bug fix / Test/CI] The iOS app no longer follows cross-origin redirects when probing a newly approved server for the Databricks workspace mount, so a consented host can't redirect the probe to a different origin. (#3115)
- [Bug fix] Forked native sessions (Claude Code, Codex, Pi, Qwen) again resume with their prior conversation history. (#3116)
- [UI / Feature / Test/CI] Workspace pane icons now explain themselves on hover, with a cleaner right-side session layout and compact Share action. (#3122)
- [UI / Bug fix / Feature] Add a dialog for creating recurring scheduled agent tasks. (#3123)
- [UI] Sidebar session hover flyouts and rows now align with the project rows — matching flyout style, title size, and right-edge padding. (#3124)
- [Bug fix] Resuming a session with large images stored in history no longer overflows the context window or breaks compaction, on both the SDK and native Claude Code paths (#3133)
- [Feature] `omnigent session import` loads a `session export` JSONL back into a server as a new session (#3141)
- [Feature] Telemetry now records the agent name for Polly and Debby sessions. (#3152)
- [Chore / Breaking] `omni integration slack start` is removed; use `omni integration slack --background` to launch the (#3153)
- [UI / Bug fix / Docs / Chore] Slack device login now requires a fresh password at the consent screen, closing a device-code phishing gap where an already-signed-in user could approve a login by reflex. (#3156)
- [Feature] `omnigent claude` keeps tool search enabled when launched with `CLAUDE_CODE_USE_GATEWAY=1`. (#3161)
- [Test/CI] Fix `test_session_stream_emits_heartbeat_on_idle` after `_session_labels_for_runner_spawn` was extracted into `omnigent.runner.native.orchestration`; patch the heartbeat cadence on `omnigent.runner.app` where it is defined and consumed. (#3163)
- [Bug fix] Crash reports are no longer lost when a process crashes more than once in the same second. (#3173)
- [Bug fix] Custom codex-native agents launch on the model declared in the agent spec (`executor.model`) instead of silently falling back to the provider default (#3175)
- [Bug fix] Single-file agent YAMLs that nest the executor under `type:`/`config:` (the bundle config.yaml shape) now fail at load time with the correct flat spelling, instead of silently running a harness inferred from the model prefix (#3178)
- [UI / Bug fix / Feature] Use native Codex goal mode from Polly's Goal control (#3181)
- [UI / Bug fix] Renaming a session now updates the name in the sidebar instantly instead of after a short delay. (#3185)
- [UI / Bug fix / Feature / Test/CI] Edit scheduled tasks and type exact run times, with a scrollable time picker and a consistent, fully-visible dialog. (#3186)
- [UI / Feature] Pinned sessions now persist server-side per user, so pins follow you across devices and browsers. (#3189)
- [Feature] New sessions now receive concise semantic titles automatically without additional configuration. (#3191)
- [Test/CI] `/rerun` PR comment re-runs failed CI on the current commit without dismissing approvals (#3195)
- [Feature] Native Codex sessions can now receive concise automatic background titles. (#3199)
- [Bug fix] Qwen3, inkling, and other non-OpenAI models now work in the Pi SDK executor harness (#3203)
- [Chore / Breaking] Slack-on-Databricks deploy: renamed `OMNIGENT_SLACK_WEBAUTH_BASE_URL` to `OMNIGENT_SLACK_DATABRICKS_APP_URL` (`--app-url`), removed the `WEBAUTH_PORT` / `DATABRICKS_WORKSPACE_HOST` overrides, and dropped deploy-time `uv lock` in favor of in-container `uv run`. (#3206)
- [UI / Bug fix] Sidebar session titles use the available space cleanly and reveal branch and action details only when needed. (#3208)
- [UI / Bug fix] Removed the redundant "Create new project" option from the sidebar project picker — create projects with the + icon next to Projects (#3210)
- [Feature] Smart routing now activates automatically when a server `llm:` block or an external `routing:` block is configured — no `OMNIGENT_SMART_ROUTING` env var needed (#3215)
- [UI / Feature / Test/CI] Scheduled task rows now show when each task will next run ("Next run in 15h") and a "Run now" action in the ⋯ menu to fire a task immediately, with refreshed row styling. (#3218)
- [UI / Feature] Projects now carry default session settings (host, working directory, agent, optional git worktree) that pre-fill the new-session composer. (#3221)
- [Bug fix] Fixed per-model cost attribution for native harnesses so a session's per-model (#3223)
- [UI / Bug fix] Codex task plans now stay in Tasks instead of being duplicated in chat. (#3249)
- [UI / Bug fix] Smart Routing no longer appears in the model dropdown for native terminal sessions (Claude Code, Codex, Pi), where it had no effect (#3259)
- [Bug fix] Sandboxed agents can now run tools managed by update-alternatives (awk, python3, editor, and similar) on Linux. (#3263)
- [Bug fix] Egress proxy now trusts corporate/MDM CA roots installed under the system `capath` directory, so TLS to hosts behind a corporate MITM works from a sandboxed agent. (#3264)
- [Bug fix] Large historical attachments no longer inflate replay and compaction context as inline base64 text. (#3267)
- [Docs] Contributors can now use `omnidev` as the documented worktree-safe local testing flow. (#3277)
- [Bug fix] Custom OpenAI Agents can use Unity AI Gateway Model Services with fully qualified model names when a Databricks provider or profile is configured. (#3288)
- [UI / Bug fix / Feature] Configure recoverable dangerous shell commands to ask for approval or deny execution, while always blocking catastrophic operations. (#3297)
- [Bug fix / Feature] Pi harness now routes kimi, inkling, GLM, qwen3, Gemini 3+, and Llama through the correct AI Gateway endpoints, fixing "Stream ended without finish_reason" errors and ensuring `system.ai.*` ids are used throughout. (#3307)
- [Feature] Add a top-level `justfile` with recipes for launching the iOS Simulator, running the Android debug build, starting the omnigent dev pod, and running pre-commit/lockfile normalization. (#3310)
- [UI / Bug fix] Sidebar header action buttons are now vertically centered with section titles. Session row hover is smoother, and active items no longer flash when hovered. (#3311)
- [UI / Bug fix] Aligns project folder icons and color with the rest of the sidebar. (#3317)
- [Bug fix] Unsupported Claude Code slash commands are now escaped and sent as regular user messages instead of leaving the native terminal in an undriveable state. (#3319)
- [Feature] `omnigent server` now tells users they can set `OMNIGENT_AUTH_ENABLED=0` to override automatic multi-user mode when binding to a non-loopback interface. (#3320)
- [Chore / Breaking] [Breaking] The deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment variable has been removed; use `OMNIGENT_AUTH_ENABLED` instead. (#3322)
- [UI / Bug fix] Fixed pinned sessions being lost when the web UI was updated before the server. (#3323)
- [UI / Feature] Automations list: tasks now render as cards and show a live-updating relative next-run time ("Next run in 3 hours"). (#3324)
- [UI] Settings → Appearance no longer has a separate Sidebar font size control, and a new "Reset to defaults" button restores every appearance preference after confirmation. (#3326)
- [UI] Starting a session inside a project now names the project in the new-session (#3327)
- [UI] The Files workspace tab now uses a stacked-files icon. (#3329)
- [UI / Feature] Automations: scheduled tasks can now pick a model and reasoning effort in the create/edit dialog (defaults to the agent's settings). (#3331)
- [UI / Bug fix] Fixed sessions pinned in the updated web UI being lost after the server was updated. (#3332)
- [UI / Feature] Native harness setup now checks the installed CLI version and prompts to upgrade if it is too old. Cursor's missing-binary case is normalized to the same structured `binary-missing` signal as the other CLI-backed native harnesses. (#3335)
- [Bug fix] Pi sessions now show a clear error when their Databricks login has expired, instead of silently accepting messages with no reply (#3336)
- [Test/CI] N/A (internal CI change). (#3338)
- [Bug fix] claude-sdk harness now surfaces harness-level failures (expired login, auth error) as structured errors instead of storing them as assistant messages. (#3342)
- [UI] Align the sidebar brand row with the rest of the navigation. (#3346)
- [UI / Bug fix] Short links like `#3090` in chat markdown tables no longer stack one character per line (#3350)
## [Unreleased]
### Features
- [UI / Feature] Added a Nord color theme (arctic frost-blue palette) to the Appearance settings palette picker.
- [Feature] Per-harness startup command/args overrides via a polymorphic `harness:` key in `config.yaml`. The `harness:` key now accepts a mapping with a `default` plus per-harness `command`/`args` overrides (e.g. `harness: {default: claude-sdk, codex: {command: /usr/local/bin/codex, args: [--config, approval_policy=on-request]}}`). The legacy scalar form (`harness: claude-sdk`) still works and auto-migrates to the mapping form on the next config write. Harness binary-path precedence: `OMNIGENT_<NAME>_PATH` env var > config `harness.<id>.command` > built-in default; `args` follow the same precedence with config `args` as the base and CLI pass-through args appended. The `OMNIGENT_<NAME>_PATH` env var (base id, `-native` suffix stripped) is the canonical per-binary override, standardizing the headless `HARNESS_<NAME>_PATH` and native `OMNIGENT_*_PATH` conventions into one namespaced name; the legacy `HARNESS_<NAME>_PATH` is still read as a deprecated fallback that logs a one-time warning + a CLI startup notice, and is slated for removal in v0.8.0. The pre-existing `omnigent claude --command` flag is deprecated (warns on use, pointing to `OMNIGENT_CLAUDE_PATH`/config) and will be removed in a future release; no other native command gained a `--command` flag — override via env or config.
## [v0.5.0] — 2026-07-10
+78 -10
View File
@@ -28,7 +28,9 @@ Install local prerequisites first:
- `bubblewrap` (`bwrap`), **Linux only**, used to OS-sandbox those native
Claude/Codex/Pi terminals (`apt install bubblewrap` on Debian/Ubuntu). macOS
uses the built-in `seatbelt` sandbox and needs nothing extra.
- Node.js 22 LTS or newer with `npm` when working on `web/`.
- Node.js 22 LTS or newer with `pnpm` (install via `corepack enable` or
`npm install -g pnpm`) when working on `web/`.
- A Rust toolchain for the recommended `omnidev` local development supervisor.
```bash
git clone https://github.com/omnigent-ai/omnigent.git
@@ -51,24 +53,73 @@ uv run pre-commit run --all-files
When touching `web/`:
```bash
cd web && npm install && npm run lint && npm run build
cd web && pnpm install && pnpm run lint && pnpm run build
```
## Running locally
To try your changes, start a local server, register your machine as a host,
and run the frontend dev server. Use three separate terminals:
Start with the smallest relevant automated test described in [Tests](#tests).
For full-stack manual testing, use `omnidev`.
### Recommended: worktree-safe testing with `omnidev`
`omnidev` runs the current checkout's server, host, and Vite frontend in one
terminal. Each checkout path, including each worktree, gets isolated state,
configuration, database, artifacts, logs, and automatically allocated ports,
so it can run alongside your normal Omnigent installation and other worktrees.
Install the supervisor once from an up-to-date checkout:
```bash
cargo install --path dev/omnidev --force
```
Then run it from anywhere inside the branch checkout or worktree you want to
test. A fresh worktree needs its own Python environment first:
```bash
cd /path/to/omnigent-worktree
uv sync --extra all --extra dev
omnidev
```
Open the exact `ui` URL displayed in the header; do not assume the Vite port is
`5173`. Python changes under `omnigent/` reload the server and host, while
frontend changes use Vite HMR.
Run CLI commands against the development pod through the passthrough so they
use that checkout and its isolated state instead of a globally installed
`omnigent`:
```bash
omnidev omnigent config show
omnidev omnigent agent list
```
Keep `omnidev` in the foreground and quit with `q` or `Ctrl-C` so it tears down
all three processes. An interactive terminal inside an existing Omnigent
session also works; use `git rev-parse --show-toplevel` to confirm that its
current checkout is the one you intend to test.
See [`dev/omnidev/README.md`](dev/omnidev/README.md) for log controls,
clean-state testing, backend-only and LAN modes, and other options.
### Manual three-terminal fallback
Use the manual flow when you need to run or debug each component separately.
Unlike `omnidev`, it does not isolate state or allocate ports. These commands
assume the default ports are free:
```bash
# Terminal 1: local server on :6767
omnigent server
uv run omnigent server
# Terminal 2: register your machine as a host
omnigent host --server http://localhost:6767
uv run omnigent host --server http://localhost:6767
# Terminal 3: frontend dev server
cd web
npm run dev
pnpm run dev
```
Open the Vite URL from the frontend dev server, usually
@@ -81,7 +132,7 @@ The host URL can also be passed positionally (`omnigent host
http://localhost:6767`). See the [README](README.md) for more on hosts,
harnesses, and credentials.
### Backend-only local development validation
### Disposable backend-only validation
Use this when you want to validate the Python backend and local API server from
a source checkout without building the web UI, configuring provider
@@ -169,7 +220,7 @@ Two cross-cutting suites sit on top of these:
Frontend changes follow the same expectation with a different toolchain:
- Add or update a **colocated Vitest test** — a `*.test.ts`/`*.test.tsx` file
next to the component or module you changed — and run it with `npm test`.
next to the component or module you changed — and run it with `pnpm test`.
- A change to **user-facing UI behaviour** also needs a Playwright test under
`tests/e2e_ui/`. This one is enforced mechanically by the `E2E UI Required`
check, so a UI PR won't merge without a covering test (or a maintainer
@@ -177,10 +228,27 @@ Frontend changes follow the same expectation with a different toolchain:
- Styling/formatting-only changes, copy tweaks with no flow change, and
refactors with no behaviour change are exempt, same as the backend.
## Developer Certificate of Origin
To contribute to this repository, you must sign off your commits to certify
that you have the right to contribute the code and that it complies with the
open source license. If you can certify the contents of the [DCO](DCO), add a
`Signed-off-by` line to each commit message:
```
Signed-off-by: Joe Smith <joe.smith@email.com>
```
Please use your real name — pseudonymous/anonymous contributions are not
accepted. If your `user.name` and `user.email` git configs are set, `git
commit -s` adds the sign-off automatically. The DCO check on every pull
request enforces this, so unsigned commits will block merging.
## Pull requests
- Branch from `main`, keep changes focused, and include tests or docs when relevant.
- Sign off your commits with `git commit -s` (Developer Certificate of Origin).
- Sign off your commits with `git commit -s` (see
[Developer Certificate of Origin](#developer-certificate-of-origin) above).
- Fill in the PR template. For **UI / frontend changes**, check the
"UI / frontend change" box and attach a **video or images** in the `Demo`
section showing the new behaviour, so reviewers can see it without checking
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+2 -2
View File
@@ -45,8 +45,8 @@ Copyright 2016 Google LLC.
opentelemetry-instrumentation-openai-agents-v2 - https://pypi.org/project/opentelemetry-instrumentation-openai-agents-v2/
Copyright The OpenTelemetry Authors.
cel-expr-python - https://github.com/cel-expr/cel-python/
Copyright The Cel Expr Python Authors.
cel-python - https://github.com/cloud-custodian/cel-python/
Copyright The cel-python Authors.
modal - https://pypi.org/project/modal/
Copyright Modal Labs 2022.
+22 -6
View File
@@ -122,10 +122,10 @@ uv tool install -q --python 3.12 git+https://github.com/omnigent-ai/omnigent.git
- **`uv`** (required). https://docs.astral.sh/uv/getting-started/installation/
The installer offers to set this up for you.
- **`git`** (required).
- **Node.js 22 LTS or newer** with **`npm`**, for the npm-installed coding
harnesses (Claude, Codex, OpenCode, Pi). `omnigent run` installs the
harness CLI you pick.
https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
- **Node.js 22 LTS or newer** with **`npm`** (for the coding-harness CLIs
installed by `omnigent run`) and **`pnpm`** (for the web UI). You can get
both from a single Node install; pnpm is available via
`corepack enable` or `npm install -g pnpm`.
- **Kiro CLI** (optional), for `omnigent kiro`: install with
`curl -fsSL https://cli.kiro.dev/install | bash`, then sign in with Kiro.
Kiro tool approvals stay answerable in the embedded Terminal; supported
@@ -294,7 +294,7 @@ see step 3.)
**Prefer the browser?** Start a server and register your machine as a host:
```bash
omnigent server start # start the local server and web UI in the background
omnigent server --background # start the local server and web UI in the background
omnigent host # (separate terminal) register this machine as a host
```
@@ -374,7 +374,7 @@ Omnigent supports **multi-user accounts**, controlled by one environment
variable:
```bash
OMNIGENT_AUTH_ENABLED=1 omnigent server start
OMNIGENT_AUTH_ENABLED=1 omnigent server --background
```
The **Docker deploy in [step 4](#4-deploy-a-server-and-use-it-from-your-phone)
@@ -414,6 +414,11 @@ and they're in. Signup is invite-only.
omnigent run --fork <session_id>
```
Shared sessions identify model-visible messages with `[account]:` labels by
default. Set `OMNIGENT_SHARED_MESSAGE_ATTRIBUTION_ENABLED=0` to hide those
labels. This does not change stored authors, UI avatars, or who may approve or
run privileged actions.
> [!TIP]
> Want your team to sign in with the logins they already have (**Google,
> GitHub, Okta, Microsoft**)? Set `OMNIGENT_OIDC_ISSUER` plus a client ID
@@ -508,6 +513,17 @@ Polly at [`examples/polly/`](https://github.com/omnigent-ai/omnigent/tree/main/e
---
## Telemetry
Omnigent collects anonymized usage data (telemetry) by default. This data
contains no sensitive or personally identifiable information. If you're using
Omnigent through a managed service or distribution, please consult your managed
service agreement to determine any data collection that may impact your use of
the service. To opt out, follow our instructions in
[Usage Telemetry](https://omnigent.ai/docs/deploy/telemetry).
---
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md) for how to set up your environment, run the checks, and open a pull request.
+18 -14
View File
@@ -94,8 +94,9 @@ What it does (all idempotent):
or run `uv lock` behind a proxy**; the workflow owns this now;
- commits `release: v0.6.0rc1`, tags, and pushes branch + tag with the
omnigent-ci App token, which fires the downstream automation:
`github-release.yml` (draft GH release, pre-release flagged),
`draft-release-notes.yml`, and `oss-publish-images.yml` (Docker);
`oss-publish-images.yml` (Docker; publishes the immutable version image tag),
`github-release.yml` (skips rc — no GitHub release is created for
pre-releases; rcs live on PyPI only), and `draft-release-notes.yml` (skips rc);
- on the **first** cut of a cycle (rc1), dispatches `bump-version.yml`
(post-release) — **review and merge the `main → 0.7.0.dev0` bump PR
promptly**, so `doc-sync` keeps staging to the right docs branch.
@@ -128,7 +129,8 @@ python -m venv /tmp/omni-rc && /tmp/omni-rc/bin/pip install \
/tmp/omni-rc/bin/omnigent --version # expect 0.6.0rc1
```
The rc's GitHub draft stays **unpublished** — rc drafts are never published.
No GitHub release is created for the rc — pre-releases live on PyPI only,
and a curated release page is reserved for the final cut.
Need another candidate? Repeat with `0.6.0rc2` (fixes land on `release/v0.6.0`
first, via cherry-pick PRs or direct pushes; CI runs on `release/v*` pushes).
@@ -186,9 +188,10 @@ can never be reused. So:
same inputs** — every step converges (branch exists → reused; version
stamped → no new commit; tag at the converged commit → no-op) or fails
loudly (tag elsewhere) rather than duplicating work.
- **Wrong commit tagged, nothing published yet:** delete the tag and draft
(`gh release delete vX.Y.Z`, `git push origin :refs/tags/vX.Y.Z`), then
re-dispatch `release.yml`.
- **Wrong commit tagged, nothing published yet:** delete the tag and, for a
final `vX.Y.Z` (which has a draft), the draft too —
`gh release delete vX.Y.Z`, `git push origin :refs/tags/vX.Y.Z` — then
re-dispatch `release.yml`. (rc tags have no draft to delete.)
- **rc is bad:** just cut the next rc — rcs are cheap and invisible to
default installs.
- **Prod publish partially succeeded** (e.g. two of three packages uploaded):
@@ -205,7 +208,7 @@ can never be reused. So:
To exercise the whole flow end to end without touching users, release a
deliberately **below-latest** rc on the dead `0.0` line. A below-latest rc is
inert everywhere that matters: the GitHub draft stays unpublished, Docker
inert everywhere that matters: no GitHub release is created for rc tags, Docker
publishes only the immutable version image tag (`:latest` / `:latest-rc` only
move for the highest version), the notes/site/homebrew workflows ignore rc
tags, `bump-main` skips itself (the version sorts below main's), and a
@@ -231,8 +234,8 @@ The examples below use `0.0.1rc2`; substitute the next free number.
```
2. **Execute**: re-run with `-f dry_run=false`. Expect `release/v0.0.0` + tag
`v0.0.1rc2` pushed, the tag firing the draft-release and image workflows,
and CI running on the branch push. If the CI gate rejects main's head
`v0.0.1rc2` pushed, the tag firing the image workflow (`github-release.yml`
runs but skips the rc — no draft), and CI running on the branch push. If the CI gate rejects main's head
(failing or still-pending checks), that's the gate working — wait, or
re-dispatch with `-f ref=<green sha>` / `-f skip_ci_check=true`.
Cancelled (superseded) runs only warn.
@@ -268,11 +271,11 @@ The examples below use `0.0.1rc2`; substitute the next free number.
Cleanup — delete everything the rehearsal minted on GitHub:
```bash
gh release delete v0.0.1rc2 --repo omnigent-ai/omnigent --cleanup-tag --yes
gh api -X DELETE 'repos/omnigent-ai/omnigent/git/refs/heads/release/v0.0.0'
```
Optionally delete the rehearsal image versions from GHCR. The PyPI side needs
No `gh release delete` is needed: pre-release tags no longer create a GitHub
release. Optionally delete the rehearsal image versions from GHCR. The PyPI side needs
no cleanup: the rc is invisible to default installs and only the version
number is spent — optionally yank it (*Manage → Releases → Yank*) for
tidiness.
@@ -300,8 +303,9 @@ git fetch origin && git checkout release/v0.6.0 && git pull
git tag v0.6.0rc1 && git push origin release/v0.6.0 v0.6.0rc1 # explicit tag, NOT --tags
```
Then continue from step 2 of the standard flow (secure-repo dispatches). If the
GH draft wasn't created, `gh release create vX.Y.Z --draft --verify-tag
--title vX.Y.Z` recreates it. To re-run the notes/site halves for an existing
Then continue from step 2 of the standard flow (secure-repo dispatches). For
a final `vX.Y.Z`, if the GH draft wasn't created, `gh release create vX.Y.Z
--draft --verify-tag --title vX.Y.Z` recreates it (rc tags get no draft by
design). To re-run the notes/site halves for an existing
tag, dispatch `draft-release-notes.yml` or `publish-changelog.yml` with the
`tag` input; for the tap, dispatch `update-homebrew.yml`.
+12
View File
@@ -59,6 +59,18 @@ sandbox:
server_url: https://omnigent.example.com # the in-box host dials this back
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
`provider` + `server_url` is a complete config: the image defaults to
the official prebaked host image and boxes run locally.
+2 -2
View File
@@ -68,8 +68,8 @@ browser ───────────────► Worker (src/index.js)
```bash
cd deploy/cloudflare
npm install
npx wrangler login
pnpm install
pnpm exec wrangler login
```
## Deploy
+12
View File
@@ -136,6 +136,18 @@ sandbox:
server_url: https://your-host # public URL sandboxes dial back to
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
`provider` + `server_url` is a complete config. `server_url` **must be reachable
from CoreWeave** — the host inside the sandbox opens an outbound WebSocket to it,
not `localhost`. For local testing, expose your server with a tunnel
+2 -4
View File
@@ -28,10 +28,8 @@ rm -rf omnigent/server/static/web-ui dist build omnigent.egg-info
if [[ "${SKIP_WEB_UI:-}" != "1" ]]; then
echo "==> Building web SPA into omnigent/server/static/web-ui/"
cd web
npm install
npm run build
cd "${REPO_ROOT}"
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
else
echo "==> SKIP_WEB_UI=1: skipping web build"
fi
+5
View File
@@ -153,6 +153,9 @@ try:
SqlAlchemyPermissionStore,
)
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
from omnigent.stores.scheduled_task_store.sqlalchemy_store import (
SqlAlchemyScheduledTaskStore,
)
DB_URI = f"postgresql+psycopg://{PGUSER}@{PGHOST}:{PGPORT}/{PGDATABASE}"
ARTIFACT_URI = f"dbfs:{VOLUME_PATH}"
@@ -180,6 +183,7 @@ try:
permission_store = SqlAlchemyPermissionStore(DB_URI)
policy_store = SqlAlchemyPolicyStore(DB_URI)
host_store = HostStore(DB_URI)
scheduled_task_store = SqlAlchemyScheduledTaskStore(DB_URI)
agent_cache = AgentCache(artifact_store=artifact_store, cache_dir=CACHE_DIR)
@@ -212,6 +216,7 @@ try:
permission_store=permission_store,
policy_store=policy_store,
host_store=host_store,
scheduled_task_store=scheduled_task_store,
auth_provider=auth_provider,
)
+12
View File
@@ -151,6 +151,18 @@ sandbox:
env: [OPENAI_API_KEY, ANTHROPIC_API_KEY, GIT_TOKEN]
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
## Credentials for the sandbox (LLM keys, git tokens)
Daytona has no provider-side named-secret store to attach at sandbox
+27 -8
View File
@@ -34,13 +34,19 @@ POSTGRES_PASSWORD=change-me-please
# instance.
#
# A) Built-in accounts (DEFAULT — no env needed for laptop testing).
# First boot auto-creates an admin user (named after the OS
# user, falling back to "admin"), prints the password to
# `docker compose logs omnigent`, and saves it to
# /data/admin-credentials on the persistent volume. Admin
# invites teammates via the web UI Members page.
# No credentials are auto-generated. First boot prints a
# "No admin yet" line pointing at the base URL; you create the
# first admin (username + password) via the web Create-admin
# form, or pre-seed it with OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD.
# Admin invites teammates via the web UI Members page.
# For any deploy behind a public domain you MUST set
# OMNIGENT_ACCOUNTS_BASE_URL — see below.
# Security note for public deployments: POST /auth/setup is
# intentionally unauthenticated while no password-bearing account
# exists, so an instance exposed before its operator reaches the
# form can be claimed by the first visitor. Pre-seed
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD, or keep the service
# private until setup completes.
#
# B) Native OIDC (for shops with an existing IdP).
# Set the OMNIGENT_OIDC_* vars below (at minimum
@@ -82,9 +88,14 @@ POSTGRES_PASSWORD=change-me-please
# omnigent:8000 container address.
# OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
#
# Optional: pre-seed the initial admin password instead of the
# auto-generated one. Useful for headless / CI deploys where
# the operator can't read `docker compose logs`.
# Optional: pre-seed the initial admin password so bootstrap creates
# the first admin directly, instead of waiting for someone to claim it
# through the web Create-admin form. Useful for headless / CI deploys
# where that form can't be reached interactively. Nothing is ever
# auto-generated: without this (and without an OIDC issuer) a fresh
# instance stays in the needs-setup state and prints the setup URL to
# stderr — no password appears in the logs. See the security note under
# section A above for public deployments.
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=
#
# Optional: session/invite/magic TTLs. Defaults shown.
@@ -151,6 +162,14 @@ POSTGRES_PASSWORD=change-me-please
# trusted enterprise directory — it makes any signed email claim the
# user's identity. Off by default.
# OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION=1
#
# Read the email identity from a different id_token claim. Some IdPs
# omit the email claim entirely — Microsoft Entra ID commonly issues
# only preferred_username (the UPN) — which fails login with
# "Could not determine user email". A custom claim carries no
# email_verified marker, so set OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION
# alongside it. Default: email.
# OMNIGENT_OIDC_EMAIL_CLAIM=preferred_username
# ── Server config file (admins, allowed domains, …) ──────
# Non-secret settings live in a YAML config file — the same one
+15 -7
View File
@@ -55,7 +55,7 @@
# Must satisfy pyproject requires-python (>=3.12); 3.11 fails dependency resolution.
ARG PYTHON_VERSION=3.12
ARG NODE_VERSION=20
ARG NODE_VERSION=22
# ── Web UI builder ──────────────────────────────────────
# Builds the web SPA so `docker build` works from a clean checkout —
@@ -70,12 +70,20 @@ ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-slim AS web-builder
ARG NPM_CONFIG_REGISTRY=
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
WORKDIR /web/web
# Manifests first so the install layer caches across pure source edits.
COPY web/package.json web/package-lock.json ./
RUN npm install --no-audit --no-fund
COPY web/ ./
RUN npm run build
WORKDIR /web
# Workspace manifests + lockfile first so the install layer caches across pure
# source edits. The electron package JSON is included so the root workspace is
# structurally complete, but we --filter web to avoid downloading Electron.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY web/package.json ./web/
COPY web/electron/package.json ./web/electron/
RUN npm install -g pnpm@11.15.1
RUN pnpm install --frozen-lockfile --filter web
COPY web/ ./web/
RUN pnpm --filter web run build
# ── Python builder (shared: server + host) ──────────────
# Installs the package (and its transitive native-extension deps) into
+15 -6
View File
@@ -13,7 +13,7 @@
# -f deploy/docker/Dockerfile.ubi .
ARG PYTHON_VERSION=3.12
ARG NODE_VERSION=20
ARG NODE_VERSION=22
# ── Web UI builder ──────────────────────────────────────
FROM registry.access.redhat.com/ubi9/nodejs-${NODE_VERSION} AS web-builder
@@ -21,11 +21,20 @@ ARG NPM_CONFIG_REGISTRY=
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
USER 0
WORKDIR /web/web
COPY web/package.json web/package-lock.json ./
RUN npm install --no-audit --no-fund
COPY web/ ./
RUN npm run build
WORKDIR /web
# Workspace manifests + lockfile first so the install layer caches across pure
# source edits. The electron package JSON is included so the root workspace is
# structurally complete, but we --filter web to avoid downloading Electron.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY web/package.json ./web/
COPY web/electron/package.json ./web/electron/
RUN npm install -g pnpm@11.15.1
RUN pnpm install --frozen-lockfile --filter web
COPY web/ ./web/
RUN pnpm --filter web run build
# ── Python builder (shared: server + host) ──────────────
FROM registry.access.redhat.com/ubi9/python-312 AS builder
+20 -13
View File
@@ -43,40 +43,47 @@ docker compose down -v
Built-in accounts auth: no IdP to register, no proxy to host.
This is the default — `docker compose up -d` brings it up with no
extra env wiring. First boot creates an admin user (named after the
operator's OS user, falling back to `admin` in headless containers)
with a random password that lands in the container logs and on the
persistent volume at `/data/admin-credentials`.
extra env wiring. No credentials are auto-generated. On first boot,
when no admin exists yet and none was pre-seeded, the server creates
nothing and prints:
```
→ No admin yet. Open <base_url> to create the first admin account (choose a username + password).
```
You then open the web UI's **Create admin** form (it appears while no
admin exists) and pick your own username + password.
For any deploy reachable through a public domain, also set the
external URL so invite links resolve correctly:
external URL so the printed link and invite links resolve correctly:
```bash
# Add to .env (bootstrap.sh already minted the cookie secret for you):
OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
docker compose up -d
docker compose logs omnigent | grep -A4 "Created initial admin"
docker compose logs omnigent # shows the "No admin yet" line with your base URL
```
Copy the random `password` from the log line into the web UI's
login form, then:
Once you've created the admin and signed in:
- Click your username in the top-right → **Members****Invite member**.
- Share the single-use URL with the teammate; they pick their own
username and password when they redeem it.
- Sign-out lives in the same account menu.
Headless deploy (CI, Cloud Run, etc.) where you can't read the
logs? Pre-seed the password:
Headless deploy (CI, Cloud Run, etc.) where you can't reach the
Create-admin form? Pre-seed the admin password so first boot creates
the admin directly:
```bash
OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<your-strong-password>
```
The persistent password file is at `/data/admin-credentials` on
the `artifact-data` volume — survives `docker compose restart`,
deleted by `docker compose down -v`.
`OMNIGENT_ADMIN_CREDENTIALS_PATH` (set to `/data/admin-credentials`
in `docker-compose.yaml`) anchors the persistent state directory on
the `artifact-data` volume — it survives `docker compose restart` and
is deleted by `docker compose down -v`.
## Multi-user mode (OIDC)
+5 -4
View File
@@ -94,8 +94,9 @@ echo
echo "✓ deploy/docker/.env is ready. Next:"
echo " docker compose up -d && docker compose logs omnigent"
echo
echo " Accounts mode is the default — the first-boot admin password"
echo " lands in the logs and in /data/admin-credentials on the"
echo " persistent volume. For any public-domain deploy also set:"
echo " Accounts mode is the default — no credentials are auto-generated."
echo " First boot prints a 'No admin yet' line; open that URL and create"
echo " the first admin (username + password) via the web form. For any"
echo " public-domain deploy also set:"
echo " OMNIGENT_ACCOUNTS_BASE_URL=<your public URL>"
echo " in .env so invite links resolve to the right host."
echo " in .env so that link and invite links resolve to the right host."
+18 -6
View File
@@ -8,9 +8,11 @@
# open http://localhost:8000 # web UI; start a local runner per the prompt
#
# Auth modes (OMNIGENT_AUTH_PROVIDER):
# - accounts (DEFAULT) — built-in accounts, no IdP needed. First
# boot prints the admin password to `docker compose logs` and
# saves it to /data/admin-credentials. Set
# - accounts (DEFAULT) — built-in accounts, no IdP needed. No
# credentials are auto-generated; first boot prints a "No admin
# yet" line and you create the first admin (username + password)
# via the web Create-admin form, or pre-seed it with
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD. Set
# OMNIGENT_ACCOUNTS_BASE_URL for any deploy reachable behind
# a public domain (defaults to http://<HOST>:<PORT> otherwise).
# - oidc — bring your own IdP. Set OMNIGENT_OIDC_* vars — see
@@ -60,9 +62,15 @@ services:
ARTIFACT_DIR: /data/artifacts
HOST: 0.0.0.0
PORT: "8000"
# Pin the admin-credentials path to the persistent volume so
# the file survives container restarts. Empty/unset would
# write to /root/.omnigent/ inside the ephemeral container.
# Anchor the server's data dir on the persistent volume so
# file-backed operator config survives container restarts:
# the admin roster (/data/admins) and allowed-domains file
# (/data/allowed_domains), plus artifacts mounted elsewhere
# in the same volume. Account rows and password hashes live in
# PostgreSQL (the postgres-data volume), not here. The server
# resolves its data dir from this path's parent (/data);
# empty/unset would fall back to /root/.omnigent/ inside the
# ephemeral container.
OMNIGENT_ADMIN_CREDENTIALS_PATH: /data/admin-credentials
# ── Auth ─────────────────────────────────────────
@@ -101,6 +109,10 @@ services:
# without API Access Management) that omit the claim for
# directory-provisioned users. Off unless set; see .env.example.
OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION: "${OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION:-}"
# Read the email identity from a different id_token claim — for
# IdPs (e.g. Microsoft Entra ID) that issue preferred_username
# instead of email. Empty means the default (email); see .env.example.
OMNIGENT_OIDC_EMAIL_CLAIM: "${OMNIGENT_OIDC_EMAIL_CLAIM:-}"
# Opt-in OIDC invites (admin pre-authorizes one off-domain email).
# Off unless set. The admin list (/data/admins) and the optional
# allowed-domains file (/data/allowed_domains) need no env var —
+76 -1
View File
@@ -254,6 +254,25 @@ def _select_artifact_store(resolved_config: _ResolvedConfig) -> ArtifactStore:
return LocalArtifactStore(str(resolved_config.artifact_dir))
def _build_local_llm_routing_client(
server_llm: Any, # type: ignore[explicit-any] # LLMConfig | None
) -> Any | None: # type: ignore[explicit-any] # LLMRoutingClient | None
if server_llm is None:
return None
from omnigent.runtime.policies.builder import (
_build_policy_llm_client,
_resolve_server_llm_connection,
)
conn = _resolve_server_llm_connection(server_llm)
policy_client = _build_policy_llm_client(server_llm, conn)
if policy_client is None:
return None
from omnigent.server.smart_routing import LLMRoutingClient
return LLMRoutingClient(policy_client)
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
"""Resolve config if needed, wire the stores, and build the app.
@@ -289,6 +308,10 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
from omnigent.stores.permission_store.sqlalchemy_store import (
SqlAlchemyPermissionStore,
)
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
from omnigent.stores.scheduled_task_store.sqlalchemy_store import (
SqlAlchemyScheduledTaskStore,
)
telemetry.init()
@@ -298,6 +321,8 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
comment_store = SqlAlchemyCommentStore(database_url)
permission_store = SqlAlchemyPermissionStore(database_url)
host_store = HostStore(database_url)
policy_store = SqlAlchemyPolicyStore(database_url)
scheduled_task_store = SqlAlchemyScheduledTaskStore(database_url)
# Fail startup loud on a malformed `sandbox:` section (an operator
# typo should not surface as a runtime 502 on the first managed
# session); the startup catch-all below logs it.
@@ -309,14 +334,62 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
cache_dir=artifact_dir / ".cache",
)
from omnigent.spec import parse_default_policies, parse_server_llm
server_llm = parse_server_llm(cfg.get("llm"))
routing_cfg = cfg.get("routing")
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
from omnigent.server.smart_routing import ExternalRoutingClient, _bearer_auth
base_url = (routing_cfg.get("base_url") or "").strip()
router_name = (routing_cfg.get("router_name") or "").strip()
api_key_raw = (routing_cfg.get("api_key") or "").strip()
profile = (routing_cfg.get("profile") or "").strip()
raw_prefixes = routing_cfg.get("model_prefix")
if isinstance(raw_prefixes, str):
raw_prefixes = [raw_prefixes]
model_prefixes = (
[p.strip() for p in raw_prefixes if isinstance(p, str) and p.strip()]
if isinstance(raw_prefixes, list)
else []
)
if base_url and router_name:
auth = None
databricks_profile: str | None = None
if api_key_raw:
from omnigent.spec import expand_env_vars
auth = _bearer_auth(expand_env_vars({"api_key": api_key_raw})["api_key"])
elif profile:
databricks_profile = profile
routing_client = ExternalRoutingClient(
base_url=base_url,
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
)
else:
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
caps = RuntimeCaps(
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
)
init_runtime(
agent_cache=agent_cache,
caps=RuntimeCaps(),
caps=caps,
agent_store=agent_store,
file_store=file_store,
conversation_store=conversation_store,
artifact_store=artifact_store,
comment_store=comment_store,
policy_store=policy_store,
)
# Build the auth provider from the live env (header/oidc/accounts).
@@ -343,7 +416,9 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
agent_cache=agent_cache,
comment_store=comment_store,
permission_store=permission_store,
policy_store=policy_store,
host_store=host_store,
scheduled_task_store=scheduled_task_store,
auth_provider=auth_provider,
account_store=account_store,
# Non-secret auth settings from the config file (admins are the
+12
View File
@@ -138,6 +138,18 @@ sandbox:
server_url: https://your-host # public URL sandboxes dial back to
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
`server_url` must be reachable *from E2B's cloud* — a public HTTPS URL,
not `localhost`. Sessions created with `host_type: "managed"` (the API
call or the Web UI's New Sandbox option) then run on a fresh E2B sandbox;
+14 -5
View File
@@ -35,14 +35,23 @@ Then:
1. **Memory**`fly.toml` pins a **1 GB** machine (`[[vm]] memory = "1gb"`).
The server idles around ~275 MB RSS, so Fly's 256 MB default OOM-loops.
Keep it at 1 GB (or `fly scale memory 1024 -a <your-app>` if you changed it).
2. **Admin password** prints once in the first-boot logs:
2. **Create the first admin.** No credentials are auto-generated. First boot
prints a "No admin yet" line pointing at your `*.fly.dev` URL:
```bash
fly logs -a <your-app>
```
Look for `Created initial admin account ... password: <generated>` (also
written to `/data/admin-credentials` on the volume).
3. Open `https://<your-app>.fly.dev`, log in as `admin`. The cookie secret and
base URL (`FLY_APP_NAME` -> `<app>.fly.dev`) are handled automatically.
Open `https://<your-app>.fly.dev` and use the web Create-admin form to pick
your own username + password. For a headless deploy, pre-seed
`OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` (`fly secrets set …`) before first
boot to create the admin directly instead.
3. Log in with the admin you just created. The cookie secret and base URL
(`FLY_APP_NAME` -> `<app>.fly.dev`) are handled automatically.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Deploy (Fly web-UI Launch)
+10 -4
View File
@@ -35,15 +35,21 @@ files plus two secrets.
| `DATABASE_URL` | variable | `sqlite:////data/artifacts/chat.db` |
| `OMNIGENT_ACCOUNTS_COOKIE_SECRET` | secret | `openssl rand -hex 32` (pin it: ephemeral disk would otherwise drop sessions on restart) |
4. The Space builds + boots. Admin password is in the Space **Logs** on first
boot. The base URL is auto-detected from `SPACE_HOST`, so it needs no manual
set.
4. The Space builds + boots. No admin credential is auto-generated: first boot
prints a "No admin yet" line to the Space **Logs**, and the Space serves a
web Create-admin form where you pick your own username + password. The base
URL is auto-detected from `SPACE_HOST`, so it needs no manual set. To create
the admin directly instead, add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` as a
Space secret before first boot.
5. **Log in via the direct URL** `https://<user>-<space>.hf.space` in its own
tab — not HF's embedded preview. The session cookie is `SameSite=Lax`, which
browsers won't send inside HF's cross-origin iframe, so logging in from the
embedded view loops back to `/login`. The direct URL is top-level
(same-site), so login sticks. Make the Space **Public** so the direct URL
isn't gated.
isn't gated — but note the Create-admin form is unauthenticated until the
first admin is claimed, so a public Space can be claimed by the first
visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` (step 4) or claim
the admin immediately after it goes public.
## Want persistence / multi-user later?
+12
View File
@@ -191,6 +191,18 @@ sandbox:
server_url: https://your-host # public URL sandboxes dial back to
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
`server_url` must be reachable *from Islo's cloud* — a public HTTPS URL,
not `localhost`. The server itself needs `ISLO_API_KEY` (and optional
`ISLO_BASE_URL`) in its environment. Sessions created with
@@ -128,15 +128,53 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match
| Key | Meaning |
|---|---|
| `server_url` | URL the runner Pod's host dials back to (in-cluster service DNS by default). |
| `host_config` | Optional, top-level under `sandbox:` (provider-agnostic, not inside `kubernetes:`): verbatim in-sandbox `~/.omnigent/config.yaml` content installed before `omnigent host` starts — e.g. a `providers:` block routing the `pi` harness through a self-hosted gateway (LiteLLM/vLLM). Server-managed: entries injected by a previous launch are replaced or removed on the next launch/resume; config created inside the sandbox survives. Keep secrets out via `api_key_ref: env:VAR`, resolved inside the runner Pod against the `secret_name` Secret. Validated at server startup. |
| `namespace` | Runner-Pod namespace (defaults to `omnigent-sandboxes`). |
| `secret_name` | Harness-creds Secret projected into every Pod via `envFrom`. |
| `service_account` | ServiceAccount the runner Pods run as (powerless). |
| `image` | Optional runner image override (defaults to the official multi-arch amd64/arm64 host image). |
| `env` | Optional list of SERVER env-var names to inject as literal Pod env (prefer `secret_name` for credentials). |
| `node_selector` | Optional extra node labels, merged with a default `kubernetes.io/arch: amd64` — set that key to `arm64` to schedule runners on arm64 nodes. (arm64 note: the CEL policy module is unavailable there — `cel-expr-python` ships no aarch64 wheel — and degrades gracefully.) |
| `node_selector` | Optional extra node labels, merged with a default `kubernetes.io/arch: amd64` — set that key to `arm64` to schedule runners on arm64 nodes. |
| `resources` | Optional `requests` / `limits` (`cpu` / `memory`) override. |
| `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). |
| `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). |
| `pvc_mounts` | Optional pre-created PersistentVolumeClaims mounted into every runner Pod — see [Persistent storage mounts](#persistent-storage-mounts-pvc_mounts). |
## Persistent storage mounts (`pvc_mounts`)
Runner Pods are ephemeral by design — the workspace lives on an `emptyDir` and
dies with the Pod. To expose durable data (datasets, model caches, shared
output directories) mount pre-created PersistentVolumeClaims:
1. Create the PV/PVC **in the runner namespace** (`omnigent-sandboxes`) out of
band — via your GitOps repo, with whatever backend your cluster provides
(NFS/SMB CSI drivers, SAN, cloud disks). Omnigent only references the claim;
it never creates volumes, so the server RBAC stays unchanged.
2. List the claims under `sandbox.kubernetes.pvc_mounts` (see
`sandbox-config.yaml`). Mount paths may not overlap `/home/omnigent`, the
OS directories, or their ancestors (e.g. `/home`, `/var`) — the server
rejects such config at startup.
Caveats:
- **Multiple runners share writable claims concurrently** — use a
`ReadWriteMany`-capable backend (NFS/SMB/CephFS) for anything writable, and
prefer `read_only: true` (the default) everywhere else: a writable shared
mount lets one session's agent read and modify what another session wrote,
and anything written there outlives the Pod and its launch token.
- Runner Pods run as uid/gid 1000660000 with `fsGroup`. NFS `root_squash` and
SMB ownership mapping must permit that identity (export to the uid, or use
CSI mount options like `uid=`/`gid=` for SMB); `fsGroupChangePolicy:
OnRootMismatch` avoids re-chowning large exports on every start.
- `ReadWriteOnce` claims pin all runners to one node — combine with
`node_selector` deliberately, or the second Pod sits `Pending`.
- A mount visible in the Pod is not automatically visible to a harness's own
OS-level sandbox (OmniBox path grants are separate).
To verify `host_config` end to end against a live cluster, run
`python tests/e2e/integrations/deploy/kubernetes/e2e_managed_host_config.py
--server <url>` — it creates a managed session and asserts the injected
config inside the runner Pod.
## Troubleshooting
@@ -19,6 +19,16 @@ data:
# Service listens on port 80) is simplest; use your ingress URL if runner
# Pods must reach the server through it.
server_url: http://omnigent.omnigent.svc.cluster.local
# ── optional, provider-agnostic ──
# host_config: # verbatim in-sandbox ~/.omnigent/config.yaml content,
# providers: # merged in before `omnigent host` starts — e.g. route
# litellm: # the `pi` harness through a self-hosted gateway.
# kind: gateway # Keep secrets out: api_key_ref: env: resolves inside
# default: [pi] # the runner Pod against the secret_name Secret below.
# openai:
# base_url: http://litellm.litellm.svc.cluster.local/v1
# api_key_ref: env:LITELLM_API_KEY
# wire_api: chat
kubernetes:
# Runner-Pod namespace (secret_name / service_account resolve here).
namespace: omnigent-sandboxes
@@ -35,5 +45,9 @@ data:
# resources: # runner Pod sizing (defaults: 0.5-2 cpu / 1-4Gi)
# requests: {cpu: "500m", memory: "1Gi"}
# limits: {cpu: "2", memory: "4Gi"}
# pvc_mounts: # pre-created PVCs (in the runner namespace) mounted into every runner Pod
# - claim_name: omnigent-datasets
# mount_path: /mnt/datasets
# read_only: true # default true; set false only for claims meant as shared scratch
# in_cluster: true # config source: true=in-cluster SA only, false=kubeconfig only, omit=try both
# kubeconfig: /path/to/config # out-of-cluster kubeconfig (env: OMNIGENT_KUBERNETES_KUBECONFIG)
+24 -9
View File
@@ -50,23 +50,26 @@ the secret and redeploy.
The first boot runs DB migrations over the network (~1 minute on Neon).
**Get the admin password:** the first boot prints it to the app log:
**Create the first admin.** No credentials are auto-generated. First boot
prints a "No admin yet" line pointing at your `*.modal.run` URL:
```bash
modal app logs omnigent
```
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
Open that URL and use the web Create-admin form to pick your own username +
password, then invite teammates from **Members** in the web UI.
Log in as the admin and invite teammates from **Members** in the web UI.
> To set a known admin password instead, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
> To create the admin directly instead of claiming it through the web form,
> add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
> `omnigent-deploy` secret before the first deploy.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
### Modal-specific caveats
- **2 MiB WebSocket message cap.** Modal's ingress limits WebSocket
@@ -302,6 +305,18 @@ sandbox:
secrets: [omnigent-llm] # Modal secrets to inject
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
### LLM credentials for managed sandboxes
A fresh sandbox has no API keys. Park your provider credentials in a
+17 -5
View File
@@ -14,7 +14,7 @@ This guide covers the Omnigent-specific OpenShell setup:
- configure CLI-launched or server-managed sandboxes.
```bash
pip install 'omnigent[openshell]'
uv pip install 'omnigent[openshell]'
```
Omnigent uses OpenShell two ways:
@@ -58,9 +58,9 @@ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh |
> **The gateway host must be amd64 Linux.** OpenShell's supervisor
> (Landlock/seccomp/netns) does not run reliably under emulation — on an arm64
> host (e.g. Apple Silicon via colima) the sandbox never reaches READY. The
> official host image now publishes multi-arch (amd64 + arm64), but its arm64
> variant omits `cel-expr-python` (no linux-arm64 wheel — CEL policies degrade to
> unavailable there), so the amd64 variant is the one to run with OpenShell. On an
> official host image now publishes multi-arch (amd64 + arm64). CEL policies are
> available on all architectures (cel-python is pure Python), so the amd64 variant
> is the one to run with OpenShell for supervisor reasons. On an
> Apple-Silicon laptop, point the gateway at a remote **amd64 Linux** box (and the
> server at that gateway) rather than the local Docker VM.
@@ -188,6 +188,18 @@ sandbox:
server_url: https://your-host # public URL sandboxes dial back to
```
A top-level `sandbox.host_config:` (provider-agnostic) holds verbatim
in-sandbox `~/.omnigent/config.yaml` content — e.g. a `providers:`
block routing a harness through a self-hosted gateway — installed into
the sandbox before `omnigent host` starts. The block is server-managed:
entries injected by a previous launch are replaced or removed on the
next launch/resume, while config created inside the sandbox survives.
Keep secrets out via
`api_key_ref: env:VAR` (resolved in the sandbox against the injected
env). See the [sandbox-runners config
table](../kubernetes/overlays/sandbox-runners/README.md#configuration-sandbox-configyaml)
for the shape.
`provider` + `server_url` is a complete config. Sessions created with
`host_type: "managed"` (the API call or the Web UI's New Sandbox option) then run
on a fresh OpenShell sandbox; the create returns immediately and provisioning
@@ -392,6 +404,6 @@ upload, foreground streaming, attach, terminate, env passthrough, error handling
and the managed-config parsing:
```bash
pip install -e '.[openshell,dev]'
uv pip install -e '.[openshell,dev]'
pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
```
+12 -8
View File
@@ -47,14 +47,12 @@ steps below are validated end-to-end:
reference value simply hadn't propagated yet — **redeploy** and it resolves.
(To confirm, the app service should have a `DATABASE_URL` variable
referencing the Postgres service, e.g. `${{Postgres.DATABASE_URL}}`.)
3. **Get the admin password** from the first-boot **Deploy logs** (printed once;
idempotent — later boots don't reprint):
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
It's also written to `/data/admin-credentials`.
4. Open the URL, log in as `admin`, invite teammates from **Members**.
3. **Create the first admin.** No credentials are auto-generated. The
first-boot **Deploy logs** print a "No admin yet" line pointing at your
`*.up.railway.app` URL (printed once; idempotent — later boots don't
reprint). Open that URL and use the web Create-admin form to pick your own
username + password.
4. Log in with the admin you just created, invite teammates from **Members**.
> **`HOST` is handled automatically.** Railway injects `HOST=[::]`, which a
> socket bind can't use and which Railway's IPv4 edge can't reach; the
@@ -69,6 +67,12 @@ steps below are validated end-to-end:
> pin a known admin password, set `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`
> before first boot.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Use your own IdP instead (OIDC)
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
+18 -13
View File
@@ -23,8 +23,9 @@ The `render.yaml` blueprint at the repo root defines:
- **omnigent-db** (`basic-256mb` managed Postgres) — `DATABASE_URL` is injected
into the service automatically
- **artifact-data** (10 GB persistent disk) — mounted at `/data` so server
config, first-boot credentials, cookie secrets, and agent artifacts survive
redeploys. Artifacts live under `/data/artifacts`.
config, the auto-minted cookie secret, and agent artifacts survive redeploys.
Artifacts live under `/data/artifacts`. (Account rows and password hashes
live in the managed Postgres, not on the disk.)
## Quickstart (built-in accounts — the default)
@@ -34,18 +35,22 @@ mints its own cookie secret and auto-detects its public URL from Render.
1. Click the Deploy to Render button above → **Apply**. Wait ~35 min for the
image pull + health check.
2. **Get the admin password:** open the service → **Logs** and find the
first-boot block:
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
(also written to `/data/admin-credentials` on the disk; printed once).
3. Open your `https://<service>.onrender.com` URL, log in as the admin, and
invite teammates from **Members** in the web UI.
2. **Create the first admin.** No credentials are auto-generated. Open your
`https://<service>.onrender.com` URL — a fresh instance shows a
Create-admin form where you pick your own username + password. (First-boot
**Logs** also print a "No admin yet" line with that URL.)
3. Log in as the admin you just created, and invite teammates from **Members**
in the web UI.
> To set a known admin password instead of the generated one, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` in the dashboard before first boot.
> To create the admin directly instead of claiming it through the web form
> (e.g. a headless deploy), add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` in the
> dashboard before first boot.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Use your own IdP instead (OIDC)
+357
View File
@@ -0,0 +1,357 @@
# Delegated Auth — Device Authorization Grant (RFC 8628)
> **IMPLEMENTED.**
>
> A generic, client-agnostic delegated-login mechanism. Slack is the first
> consumer (`integrations/slack/`), but the server side carries no
> Slack-specific concepts — the requesting application names itself with the
> RFC 8628 `client_id` (a public string like `"slack"`; display/audit only).
> It is a public OAuth client by default (no client secret), with an
> **optional** shared secret (`OMNIGENT_DEVICE_CLIENT_SECRET`) that gates the
> client-facing endpoints when set — see the phishing mitigations below.
>
> Server: `omnigent/server/routes/device_auth.py` (endpoints + the
> `mint_delegated_token` / `DELEGATED_SCOPE` it owns),
> `omnigent/server/device_grant_store.py`, `SqlDeviceGrant` +
> `device_grants` migration (`d1e2f3a4b5c6`), and scope + revocation
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
> flow and never mounts these routes).
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
> per-`(server,user)` pool). Login is folded into the `/omnigent` setup
> modal; `/omnigent logout` revokes + clears.
>
> **Auth-mode selection (Slack).** The bot probes the server's mode
> (`oauth.probe_auth_mode` → `GET /v1/me`, mirroring the CLI) and picks
> the flow: **accounts → this device grant**; **oidc → the server's
> cli-login ticket flow** (`/auth/cli-login` + `/auth/cli-poll`), where
> the user signs in at the IdP and the bot stores the server's session
> JWT (no device grant, no refresh token). Both surface through one
> `oauth.PendingLogin` shape so the setup/auth-manager code is
> flow-agnostic. **Header/proxy mode is unsupported** — the server mints
> no token and mounts no device-grant/cli-login router in that mode
> (`app.py`: device auth is `oidc`/`accounts` only), so `start_login`
> raises a clear error rather than firing a request the server would 404.
>
> Tests: `tests/server/test_device_auth.py`, and the Slack
> `test_oauth.py` / `test_tokens.py` / `test_client_auth.py` /
> `test_auth_manager.py`.
## Problem
The Slack integration (`integrations/slack/`) is a standalone Socket-Mode
process that calls each user's Omnigent server over HTTP + SSE
(`OmnigentClient` / `OmnigentClientPool`). Each Slack user's turns must reach
the Omnigent server **as that user's own authenticated identity** — so the
server can scope permissions and audit who did what — **without the Slack
process ever handling the user's Omnigent credentials**. An unauthenticated
client can only reach auth-disabled servers, and would present one shared
anonymous identity the server can't distinguish per user.
## Topology and trust
```
omnigent server <-> slack socket server <-> slack.com <-> user
(Auth + Resource (OAuth client / (transport) (browser =
Server) "device") Resource Owner)
```
Slack relays all messages between the user and the socket server, so **no
Omnigent credential may pass through Slack**. The user authenticates directly
against the Omnigent server in their own browser, out of band. This is exactly
the shape of the **OAuth 2.0 Device Authorization Grant (RFC 8628)**: a device
that cannot host a browser obtains a code, the user approves out-of-band, and
the device polls for a token.
Role mapping:
| RFC 8628 role | Here |
|--------------------------|-------------------------------------------------|
| Authorization Server | Omnigent server (`/oauth/device/*`, `/oauth/token`) |
| Resource Server | Omnigent server (existing `/v1/**` APIs) |
| Client / "device" | Slack socket server |
| Resource Owner | The Slack user, authenticating in their browser |
| Out-of-band channel | Slack (delivers the verification link only) |
## Shared substrate (reused, not rebuilt)
The device grant builds on existing server primitives:
- **Atomic single-use token redemption** — `SqlAlchemyAccountStore.redeem_token`
uses `UPDATE … WHERE redeemed_at IS NULL` + rowcount so at most one caller
wins under concurrency (`accounts_store.py`). The grant store follows the
same pattern.
- **Session JWT minting** — `mint_session_token(user_id, secret, ttl, provider)`
(`oidc.py`), HS256 with `sub`/`iat`/`exp`/`provider`.
- **Bearer validation** — `UnifiedAuthProvider._check_cookie` accepts
`Authorization: Bearer <jwt>` and validates the same claim shape
(`auth.py`). Delegated access tokens validate through this path unchanged.
- **Browser consent under accounts mode** — the `accounts` provider
establishes the browser identity via its session cookie; the consent page
runs behind it. (This is why the grant mounts in accounts mode only — see
the mount restriction below.)
- **Open-redirect hardening** — `_sanitize_return_to` (`routes/auth.py`) guards
the post-login bounce back to the consent page.
## Design decisions (agreed)
1. **Public by default, optional client secret.** The baseline boundary is
the secret `device_code` the client holds, the ephemeral verification
link, and authenticated in-browser consent; initiation is per-IP
rate-limited and nothing is granted until a real user approves. On top of
that, setting `OMNIGENT_DEVICE_CLIENT_SECRET` on the server gates the
**client-facing** endpoints (authorize / token / revoke) behind a shared
secret header (`X-Omnigent-Client-Secret`, constant-time compared), so
only an authorized client can drive the flow. The **browser** endpoints
(consent GET / approve / deny) are never gated by it — the user's browser
doesn't hold the secret; their trust is the session cookie + Origin check.
Unset ⇒ endpoints stay public (backward compatible). Shipping the secret to
the Slack client is safe because its target is a **fixed operator config**
(`OMNIGENT_SERVER_URL`), not a user-supplied URL, so the secret only ever
travels to the trusted server.
2. **Refresh tokens** — short-lived access tokens (≤ 1 h) plus a rotating,
revocable refresh token, with a 30-day absolute grant lifetime. The Slack
server refreshes silently; a stolen access token expires quickly and a grant
can be killed centrally or ages out on its own.
## Flow
```
1. A Slack user opens the `/omnigent` setup modal against an
accounts-mode server; the modal detects auth is required and starts
the device flow (there is no separate login command).
2. Slack server ─ POST /oauth/device/authorize ─────────────▶ Omnigent
body: { client_id } # public app name, e.g. "slack"
Omnigent ─────────────────────────────────────────────────▶ Slack server
{ device_code, # secret, HELD BY SLACK SERVER ONLY
user_code, # short, human-readable
verification_uri, # e.g. https://srv/oauth/device
verification_uri_complete, # verification_uri?user_code=XYZ
expires_in: 600, interval: 5 }
3. Slack server shows the verification link (verification_uri_complete,
code prefilled for one-click) in the setup modal (initiator only),
plus the user_code so the user can confirm the match. The
device_code is NOT included — it never leaves the server pair.
4. User clicks → Omnigent consent page (verification_uri).
The page REQUIRES a login started for THIS flow: if the browser's
session predates the grant (session iat < grant.created_at), it
bounces through the login page with ?reauth=1 — which forces a fresh
password entry even for an already-signed-in user — and returns here.
Once re-authenticated, the page shows: "<client_id> is requesting
permission to act as YOU (alice@example.com) on this Omnigent server.
[Approve] [Deny]" plus a warning to approve only a self-started login.
The forced re-auth means a grant can't be approved by one reflexive
click on a link the user didn't personally start (see threat #2).
5. User approves → the grant is bound to the authenticated identity
(alice@…). client_id is recorded for display/audit only, never as
an authorization key.
6. Slack server polls ─ POST /oauth/token ──────────────────▶ Omnigent
grant_type=urn:ietf:params:oauth:grant-type:device_code
{ device_code }
Responses: 400 authorization_pending | 429 slow_down |
400 expired_token | 400 access_denied |
400 invalid_grant |
200 { access_token, refresh_token, expires_in, token_type }
7. Slack server stores (team_id, slack_user_id, server_url)
→ { access_token, refresh_token } ENCRYPTED AT REST,
and attaches Authorization: Bearer <access_token> on every
request for that user thereafter.
8. On 401 / near-expiry: POST /oauth/token grant_type=refresh_token →
new access + rotated refresh. On refresh failure (revoked/expired):
drop tokens and re-prompt login in the setup modal.
```
The Slack `(team_id, slack_user_id)` → identity mapping lives entirely on
the Slack side (step 7). The server-side grant is client-agnostic: it
knows only the RFC 8628 `client_id` and the Omnigent identity that
approved it.
## Server-side changes
### Router `omnigent/server/routes/device_auth.py`
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
cli-ticket flow and never mounts these routes; header mode has no
server-mintable identity — see `create_device_auth_router`, which raises if
constructed for any other source). The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the router
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
- `POST /oauth/device/authorize`**public** (rate-limited). Generates a
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
short `user_code`, `expires_in`, `interval`. Persists a `pending` grant
carrying only the public `client_id`. Returns the RFC 8628 authorize
response. Opportunistically purges expired grants (no scheduler).
- `GET /oauth/device` — the consent page (`verification_uri`). Requires a
browser identity via the active provider; if unauthenticated, bounce through
the provider's normal login and return here (`_sanitize_return_to`). Prefills
`user_code` from `verification_uri_complete`.
- `POST /oauth/device/approve` / `POST /oauth/device/deny` — authenticated
browser actions, CSRF-gated by `_require_browser_origin` (rejects a missing
Origin). `approve` binds the grant to the authenticated `user_id` (`sub`),
stamps `approved_at` (the absolute-lifetime clock), and flips status to
`approved`; `deny` flips to `denied`.
- `POST /oauth/token`:
- `grant_type=…:device_code` — look up by hashed `device_code`; return
`authorization_pending` / `slow_down` (interval enforcement) /
`expired_token` / `access_denied` / `invalid_grant`, or on approval mint an
**access token** (`mint_delegated_token`, TTL ≤ 1 h) + **refresh token** and
return them. Single-use: an atomic `approved → redeemed` transition means
the device_code cannot be exchanged twice.
- `grant_type=refresh_token` — validate the presented refresh token against
the stored hash, **rotate** it (issue new, invalidate old), mint a new
access token. Refuses rotation past the 30-day absolute lifetime
(`expired_token`). **Reuse detection**: presenting an already-rotated
refresh token revokes the whole grant (token-theft signal).
- `POST /oauth/revoke` — revoke a grant: null the refresh token, mark revoked
(the `grant_id` then reads as revoked in the denylist check). Accepts a
`refresh_token`, or falls back to the `grant_id` on the caller's own bearer
so a client holding only its access token can still log out. Idempotent.
Backs `/omnigent logout`.
### New store `omnigent/server/device_grant_store.py`
Modeled on `SqlAlchemyAccountStore` — workspace-scoped, secrets stored hashed,
atomic single-use redemption, `purge_expired`. New table `device_grants`:
| column | notes |
|----------------------|----------------------------------------------------|
| `id` (grant id) | PK with `workspace_id` |
| `device_code_hash` | HMAC/SHA-256 of the device_code; never store raw |
| `user_code` | short code shown/typed by the user |
| `client_id` | RFC 8628 client id — the requesting application (e.g. `slack`); display + audit |
| `status` | `pending` / `approved` / `denied` / `redeemed` / `revoked` |
| `user_id` | bound Omnigent identity, set at approval |
| `refresh_token_hash` / `prev_refresh_token_hash` | current + prior digest (rotation + reuse detection) |
| `created_at` / `expires_at` / `approved_at` / `last_polled_at` | TTL, absolute-lifetime clock, `slow_down` timing |
### Token claims and validation (`auth.py`, `device_auth.py`)
Delegated access tokens (minted by `mint_delegated_token`) keep the existing
HS256 shape (so `_check_cookie` accepts them) plus four delegated-only claims:
- `act` — provenance, RFC 8693-style: `{ "client_id": "slack" }`, naming the
application that obtained the grant so every delegated action is attributable
to it.
- `scope` — set to `DELEGATED_SCOPE` (`"sessions"`). The auth layer's
fail-closed allowlist `delegated_path_allowed` restricts a token carrying
this scope to `/health`, `/v1/agents`, `/v1/hosts`, `/v1/sessions`,
`/v1/runners`, `/oauth/token`, `/oauth/revoke` (exact or `prefix/…`);
everything else — including admin / user-management (`/auth/users*`, invites,
setup) — is rejected.
- `grant_id` — checked against the revoked-grant denylist (`is_revoked`, wired
via `set_grant_revocation_check`) on **every** request for a delegated token,
so revoking the grant kills the token immediately. Delegated tokens carrying
a `grant_id` skip the credential cache (they return before the cache write),
keeping the per-request revocation check honest without making ordinary
(non-delegated) sessions stateful. Fail-closed: an unknown `grant_id` reads
as revoked.
- `jti` — unique token id for audit/log correlation (not a revocation key;
revocation is grant-scoped, not per-token).
## Slack-side changes
- **`oauth.py`** — device-authorize → post ephemeral link → poll token
endpoint (respecting `interval` / `slow_down`) → store tokens.
- **`omnigent.py`** — attach `Authorization: Bearer` per
`(server_url, slack_user_id)`; on 401, refresh once and retry; on refresh
failure, surface a re-login prompt. `OmnigentClientPool` keys clients by
`(server_url, slack_user_id)` instead of `server_url` alone.
- **`store.py`** — new `oauth_tokens` table `(team_id, user_id, server_url)`
access/refresh **encrypted at rest** (key from env / secret manager, never in
the DB). `/omnigent logout``POST /oauth/revoke` + local delete.
- **`setup.py`** — validation uses the user's token, so auth-enabled servers
are supported.
- **`config.py`** — holds the local encryption key for token storage.
## Security analysis
| # | Threat | Mitigation |
|---|--------|-----------|
| 1 | `device_code` leak → token theft | Never transits Slack or the user — only `verification_uri_complete` (a `user_code`) does. Stored hashed; single-use. |
| 2 | Link misdelivery / phishing another user | Link shown to the initiator only (in their own setup modal). **Consent requires a login started FOR this flow: the consent page rejects a session whose `iat` predates the grant and bounces through the login page with `reauth=1`, forcing a fresh password entry even for an already-signed-in user.** So an attacker-initiated flow can't be approved by a single reflexive click — the victim must deliberately re-enter their password against a screen naming the exact Omnigent identity and requesting `client_id`. The gate is enforced on both the consent GET and the approve POST. |
| 3 | Anyone can initiate/poll (public client) | Cheap `pending` state grants nothing until an authenticated user approves. `POST /oauth/device/authorize` is rate-limited per client IP (10/60s → 429 `slow_down`); short (10 min) `device_code` expiry; `slow_down` enforced server-side on aggressive polling; expired grants purged opportunistically. |
| 4 | Slack SQLite exfiltration → mass impersonation | Tokens **encrypted at rest**; access tokens short-lived (≤ 1 h); refresh tokens revocable. Bounded, centrally killable window. |
| 5 | Compromised Slack server acts as all users (inherent to delegation) | Reduced scope (no admin), short TTL + refresh rotation, per-grant revocation, **absolute grant lifetime (30 d) enforced on refresh** so even an un-revoked grant dies, and an `act`-claim audit trail. |
| 6 | Confused deputy — user A's token used for user B | On the Slack side, token lookup is strictly keyed by acting `slack_user_id`; the thread `owner_user_id` gate drops non-owner follow-ups (`service.py`). |
| 7 | Stale/leaked delegated token can't be revoked | Per-grant `grant_id` revocation denylist (`is_revoked`, checked every request) makes delegated-token revocation immediate — closes today's stateless-JWT gap for these higher-value tokens. |
| 8 | Refresh-token theft | Rotation on every use + **reuse detection**: the just-superseded token's digest is retained in `prev_refresh_token_hash`; presenting it (a replay) is recognised and revokes the whole grant, killing the attacker's freshly-rotated token too. |
| 9 | Transport interception | Require HTTPS for `verification_uri` and all token/bearer traffic; refuse the flow over plaintext except localhost dev. |
| 10 | Open redirect on the consent login bounce | Reuse `_sanitize_return_to` (OIDC, `routes/auth.py`) / `sanitizeReturnTo` (accounts SPA). Verified both providers reject absolute / `//` targets. |
| 11 | CSRF on approve/deny if `SameSite=none` is ever enabled | `_require_browser_origin` rejects a **missing** `Origin` on approve/deny (stricter than the shared `require_trusted_origin`, which fail-opens for non-browser clients). These routes are browser-only, so the CSRF defense no longer depends on the cookie's `SameSite`. |
### Device-code phishing — accepted risk, mitigated in depth
The canonical RFC 8628 risk: a stranger initiates a flow and tricks a victim
Omnigent user into approving the verification link, binding the grant to the
*victim's* identity while the attacker (holding the `device_code`) polls for the
token.
When no client secret is configured the endpoints are **public**, so
initiation is open — the defense is layered, not a gate:
- **Forced re-authentication at consent.** Consent requires a login started for
THIS flow: the consent page (and the approve POST) reject a session whose
`iat` predates the grant's `created_at` and bounce through the login page with
`reauth=1`, which forces a fresh password entry even for an already-signed-in
user. This defeats the reflex-approve variant of the attack — a victim handed a
one-click link (even one with the code prefilled) still can't bind the grant
without deliberately re-entering their password against a screen naming the
exact identity and client. (`device_auth.py` `_session_iat` + the
`reauth=1` bounce; `LoginPage.tsx` suppresses its already-signed-in
auto-return under `reauth=1`.) The prefilled one-click link is therefore
retained for convenience — the re-auth step, not code handling, is the gate.
- The consent page prominently **warns** the user to approve only a login they
personally started and to match the code shown by the application.
- The delegated scope excludes admin / user-management endpoints.
- The grant has a 30-day absolute lifetime and is revocable; a leaked/phished
grant self-expires even if never revoked.
- Initiation is rate-limited per IP; nothing is granted until a real user
authenticates and approves in their own browser.
- **Startup warning.** When the grant is mounted on a multi-user (accounts)
server with `OMNIGENT_DEVICE_CLIENT_SECRET` unset, the server logs a loud
warning at startup that the authorize endpoint is public — nudging the
operator to opt into the secret rather than leaving initiation open unknowingly
(`app.py`, at the device-router mount).
Setting `OMNIGENT_DEVICE_CLIENT_SECRET` closes initiation entirely to
unauthorized callers: without the matching `X-Omnigent-Client-Secret` header,
authorize / token / revoke return `401 invalid_client` before anything is
created, so only the operator's own client (which holds the secret) can even
start a flow. This is now shippable to the Slack client because its server
target is a fixed operator config, not a user-supplied URL — the secret only
ever travels to the trusted server. The consent-page warning, short TTL, and
absolute lifetime remain the defenses when the secret is left unset.
### Deliberate deviation from the current model
Ordinary Omnigent session JWTs are stateless and unrevocable today (revocation =
cookie deletion + expiry). Delegated tokens are higher-value — one server acts
for many users — so this design makes **delegated** tokens revocable (persisted
grant + per-`grant_id` revocation check) while leaving normal sessions
stateless. This added invariant is the main thing for reviewers to scrutinize.
## Out of scope / follow-ups
- Admin UI for listing and revoking active Slack delegations.
- Multi-replica rate limiting: the authorize throttle is in-process; a
horizontally-scaled server would want a shared store (the grant table's
single-use/expiry semantics already bound abuse in the meantime).
- Applying the same delegated grant to other non-browser clients (the CLI could
use it too, superseding the in-memory `_cli_tickets` store).
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
+664
View File
@@ -0,0 +1,664 @@
# PRD: Projects
- Status: Draft
- Author: Serena Ruan
- Related: [`designs/SESSION_PROJECTS_SIDEBAR.md`](./SESSION_PROJECTS_SIDEBAR.md) (v1, label-based sidebar grouping), issue [#863](https://github.com/omnigent-ai/omnigent/issues/863), PR [#869](https://github.com/omnigent-ai/omnigent/pull/869)
## 1. Overview
A **Project** is a user-defined container that groups related sessions and
carries owner-private configuration (memory, context, a default working
directory). It gives users a durable "workspace" above the single session, so
recurring work isn't re-configured each time or scattered across a flat list.
This PRD scopes the **full vision**. A **v1** already exists (§4): projects as a
reserved `omni_project` label, grouped in the sidebar — this covers grouping
only. Everything else (empty projects, rename/reorder, memory/context/defaults)
requires promoting Project from a *label* to a *first-class entity*.
Two decisions shape the whole design and are worth stating up front:
- **A project is owner-private metadata.** Sharing stays **per-session** exactly
as it is today; there is **no project-level ACL** (§9).
- **Memory & context are owner-private** and never cross to a shared session's
recipient. A shared session appears **ungrouped** to the recipient (§9).
## 2. Summary — what we support & UX impact
Priority order (highest first), per product direction:
| # | Capability | Support? | Mechanism | UX impact |
|---|---|---|---|---|
| 1 | **Create empty project** | ✅ | first-class `projects` table (needed for empty) | Always-visible **Projects** section in the sidebar; "New project" creates one with zero sessions |
| 2 | **Move session in / create session in project** | ✅ | `project_id` FK on session | Session-row kebab "Add to / Move / Remove from project"; "New session here" from a project header, pre-filling its defaults |
| 3 | **Rename projects** | ✅ | `PATCH /projects/{id}` on `name` (members untouched) | Inline rename from the project header |
| 4a | **Default working dir / host / harness** | ✅ (soft hints) | default columns on project | "New session here" pre-fills the new-chat dialog; unsatisfiable hints (host offline / not owned) are silently dropped |
| 4b | **Project memory** (owner-private) | ✅ | DB-backed, scoped to project, host-agnostic | Agent accumulates learnings across the project's sessions; never exposed to shared-session recipients |
| 4c | **Project context** (owner-private) | ✅ | curated docs/instructions attached to project | Owner-curated inputs seed each session started in the project |
| 5 | **Project-level sharing / ACL** | ❌ intentional | — | Sharing stays **per-session** (existing ACL). A shared session appears **ungrouped** in the recipient's "Shared with me" — no project, no memory/context |
| opt | **Reorder projects** | 🔵 optional | client-only (localStorage, no DB column) | Drag-to-reorder in the sidebar; nice-to-have, not required for launch — see §7.2 |
**Two-line model:**
- Project = **owner-private organizer** carrying defaults + memory + context.
- Sharing = **per-session only**; a shared session crosses the ACL boundary, its
project membership and memory/context do **not**.
## 3. Where we are today (grounding)
The exploration of the current codebase established the following, which every
requirement below builds on:
- **Session = `Conversation`** (`omnigent/entities/conversation.py`). Key fields:
`id`, `title`, `runner_id`, `host_id`, `workspace` (absolute path, **immutable
after creation**), `git_branch`, `model_override`, `reasoning_effort`,
`harness_override`, `cost_control_mode_override`, `labels`, `session_state`,
`archived`.
- **Session creation** goes through `POST /v1/sessions`
(`SessionCreateRequest`, `omnigent/server/schemas.py`) with `agent_id`,
`host_type` (`external` | `managed`), `host_id`, `workspace`, optional `git`
worktree spec, and per-session overrides. UX lives in
`web/src/shell/NewChatDialog.tsx`.
- **Working directory & host**: `workspace` is a path on a `host` (a machine
running `omnigent host`, or a server-managed sandbox). Bound at creation,
immutable thereafter. Git worktrees are opt-in at create time.
- **Memory/context today**: only `session_state` (per-session key/value for
policy callables) and `session_usage`. There is **no cross-session or
project-level memory** and no shared context store.
- **Permissions**: per-`(user_id, conversation_id)` rows with levels
read(1)/edit(2)/manage(3)/owner(4) (`omnigent/db/db_models.py`,
`omnigent/entities/permission.py`). `__public__` sentinel grants public read.
No org/team model. `workspace_id` is a multi-tenancy partition key, not a
user-facing grouping.
- **Projects v1 already exists**: reserved label `omni_project` in
`conversation_labels`; endpoints `GET /v1/sessions/projects` and
`?project=<name>` filtering; `useProjects()` / `useMoveToProject()` hooks;
sidebar grouping. Projects are **implicit** — a project exists iff a
non-archived session references it, and vanishes when its last member leaves.
**There is no project row, so a project cannot be empty, renamed, or carry its
own config.**
## 4. Key architectural decision: implicit label vs. first-class entity
This is the foundational decision. Every requirement below except plain grouping
requires promoting Project from a label to a first-class entity.
| Capability | Implicit label (v1 today) | First-class entity (this PRD) |
|---|---|---|
| Group sessions | ✅ | ✅ |
| Empty project | ❌ (project = its members) | ✅ |
| Rename (cheap, safe) | ❌ (rewrite every member) | ✅ (rename the row) |
| Project defaults (dir/host/model) | ❌ nowhere to store | ✅ (columns on project) |
| Project memory/context | ❌ nowhere to attach | ✅ (FK to project) |
| DB migration required | ❌ none | ✅ new table + backfill |
**Recommendation:** ship v1 (labels) for grouping now, then **promote to a
first-class `projects` entity** as the foundation for the rest.
Migration: create `projects` rows from the distinct `omni_project` label values
per owner, point sessions at `project_id`, retire the label. This is a one-time
backfill, not user-visible.
Proposed model, referenced throughout:
- New `projects` table: `id`, `workspace_id`, `name`, `owner`, `created_at`,
`updated_at`, plus the config columns from §8. (No `position` column —
reorder is deferred and client-only, §7.2.)
- Session→project membership = nullable `project_id` FK on conversation
metadata. `null` = unfiled. FK (over label-of-id) is cleaner for renames and
referential integrity.
- CRUD endpoints: `POST /v1/projects`, `GET /v1/projects`,
`PATCH /v1/projects/{id}`, `DELETE /v1/projects/{id}`.
## 5. Priority 1 — Create empty project
### 5.1 What
Create a project with **zero sessions**, then fill it later.
### 5.2 Why this needs a first-class entity
Under the implicit-label model a project *is* its set of member sessions — an
empty project has no rows and therefore does not exist. Supporting empty
projects **requires the `projects` table** from §4.
### 5.3 UX
- The sidebar always shows a **Projects** section, even with zero projects, so
the create-empty-then-fill flow is discoverable (not a hidden label action).
- "New project" creates a named, empty project (`POST /v1/projects`).
- **Deletion:** deleting a project with members prompts — (a) delete the project
and unfile its sessions (sessions kept) or (b) block until empty. **Proposed:
(a)** with confirmation. Never cascade-delete sessions. This replaces v1's
"archive all members to make the project vanish" behavior.
### 5.4 Why ship empty-project creation first
It's the right first slice — not just because it's P1, but because it's the
**minimal change that forces the first-class entity**, which everything else
builds on for free. An empty project is definitionally impossible under the v1
label model (a label-project *is* its members), so "create empty project" *is*
the `projects` table + the `project_id` membership column. Once those exist,
move/rename/reorder/defaults are small additions on the same schema.
Phase 1 is therefore exactly two schema changes: a new `projects` table, and a
`project_id` column on the conversation metadata row. Config/memory/context
columns (§8) are **deferred to Phase 2/3** — start with a lean table so Phase 1
stays small and reversible.
### 5.5 Proposed Phase-1 schema
Follows the repo's conventions from the most recent table (`scheduled_tasks`,
migration `z6…`, `omnigent/db/db_models.py`): `workspace_id` leads the PK, **no
DB foreign keys** (schema Rule R032 — relationships enforced in the app), owner
scoping via a `*_user_id` column, epoch-seconds timestamps.
```python
class SqlProject(OmnigentBase):
"""A user-defined, owner-private container that groups sessions."""
__tablename__ = "projects"
workspace_id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, nullable=False,
server_default="0", default=current_workspace_id,
)
# "proj_"-prefixed string id (not Uuid16) so it reads as a sibling of
# conversation ids and lives naturally in the metadata String column.
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False)
# Owner stamped on the row (like scheduled_tasks), NOT derived from a
# permission table the way session ownership is. Correct here precisely
# because projects have no ACL and are owner-private (§9) — see the
# "Where ownership lives" note below. None in single-user/OSS mode.
owner_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
# No `position` column: ordering is deferred and, when added, will be a
# client-only concern (localStorage), not server state — see §7.2.
__table_args__ = (
# "list my projects": prefix scan on (workspace_id, owner). Server
# returns a stable order (e.g. created_at / name); the client may
# re-order locally.
Index("ix_projects_owner", "workspace_id", "owner_user_id", "id"),
# Per-owner name uniqueness (§7.1); app validates too.
Index("uq_projects_owner_name", "workspace_id", "owner_user_id", "name", unique=True),
)
```
Membership is one nullable column on the existing metadata table
(`SqlConversationMetadata`, `omnigent_conversation_metadata`):
```python
# Relates to projects.id; no DB FK (Rule R032). NULL = unfiled.
project_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# In __table_args__ — "list sessions in project X" + counts (GROUP BY project_id):
Index("ix_conversation_metadata_project_id", "workspace_id", "project_id", "id"),
```
**Column decisions worth sign-off:**
| Choice | Proposed | Rationale / alternative |
|---|---|---|
| `id` type | `String(64)`, `proj_`-prefixed | Reads as a sibling of `conv_…` ids; lives in the metadata String column. Newest tables use `Uuid16` — diverge here for readability + column symmetry. |
| Membership location | `project_id` on `omnigent_conversation_metadata` | Metadata already holds host/workspace/runner; `list_conversations` can filter it inline. |
| Name uniqueness | per-`(workspace, owner)` unique index | Matches §7.1; case-sensitivity still open (Q3). |
| Ownership | `owner_user_id` **column on the row** | See "Where ownership lives" below — differs from sessions on purpose. |
| Ordering | **no `position` column** | Reorder is deferred and client-only (§7.2); no server state until proven needed. |
| Deferred columns | default host/workspace/harness/model, memory/context refs | Added in Phase 2/3, not now. |
**Where ownership lives (why a column, not the permission table).** The repo has
two ownership conventions, and which is correct depends on whether the entity is
shareable:
- **Sessions** have *no* owner column — ownership is derived from
`session_permissions` as the `LEVEL_OWNER` grantee (`get_session_owner`,
`list_projects(owned_by=...)`). This is required *because sessions are shared*:
ownership is just the top row among many `(user, level)` grants.
- **`scheduled_tasks`** — a personal, non-shareable artifact with no ACL — instead
stamps `owner_user_id` directly on the row (`db_models.py:1298`), indexed
`(workspace_id, owner_user_id, id)`.
Projects follow `scheduled_tasks`, not sessions, **because §9 gives them no
project-level ACL** — they're owner-private, single-owner, never granted to
anyone else. With no `project_permissions` table to derive from, `owner_user_id`
on the row is the correct and consistent choice. (The v1 label-based
`list_projects` derives ownership from `session_permissions` only because a label
has no row of its own to stamp — the first-class table removes that constraint.)
If §9 is ever reversed and projects become shareable, we would drop this column
and derive ownership from a `project_permissions` ACL, mirroring sessions.
**Migration (mirrors `z6…`):** `op.create_table("projects", …)` with the two
indexes; `op.add_column("omnigent_conversation_metadata", project_id)` + its
index. **No backfill needed** for empty-project support — existing sessions stay
`project_id = NULL` (unfiled). The `omni_project`-label → `project_id` backfill
is a **separate, later** step (only when migrating v1 label-projects), kept out
of this migration so Phase 1 stays clean and reversible.
## 6. Priority 2 — Move session in / create session in project
### 6.1 What
Move existing sessions into (or out of) a project, and start new sessions
already filed in a project.
### 6.2 UX — how a session joins a project
We deliberately **do not** add a project picker to the generic new-chat dialog.
That dialog is already dense (agent, host, workspace, git, model, effort,
permission mode); a picker would complicate the most-used "just start a chat"
flow. Sessions join projects from the project surface instead:
| Path | Behavior |
|---|---|
| **New session within a project** (primary) | From a project's header, "New session here" opens the new-chat dialog with the project pre-set and its defaults (host/workspace/harness — §8) pre-filled. This is where "create in project" happens. |
| **After creation** (session-row kebab) | "Add to / Change / Remove from project" submenu, `PATCH /v1/sessions/{id}` — for reorganizing existing sessions. |
- "Move session in/out" = update `project_id`. Moving to `null` = unfiled.
- Membership is the **owner's** private metadata (see §9): a session's project
is scoped to its owner and does not travel to other viewers.
### 6.3 Sidebar & navigation
- Collapsible project sections, per-project counts (server-authoritative via
`GET /v1/sessions/projects`, not the loaded page).
- Section precedence, highest wins: **Archived > Pinned > Project > Chats**
(from v1 design §7). Confirm this survives the first-class-entity move.
- Filtering: `GET /v1/sessions?project=<id>`, incl. unfiled.
- Pagination correctness: counts from server `GROUP BY`; expand-time fetch of a
project's full member list (v1 design §8).
## 7. Priority 3 — Rename projects
- First-class entity makes this a single `PATCH /v1/projects/{id}` on `name`
members are unaffected (they reference `project_id`, not the name string).
- Under v1 labels, rename means rewriting the label on every member (racy,
O(members), breaks on unloaded pages) — another reason to go first-class.
- Validation: trim; reject empty/whitespace-only; max length (propose 100);
**case-sensitive**, unique **per owner** (`workspace_id` + `owner` scope).
Confirm case-sensitivity.
### 7.2 Optional — Reorder projects (client-only, not required)
- **Optional, not a launch requirement, and not a DB concern.** We deliberately
do **not** add a `position` column. The server returns projects in a stable
order (e.g. by `created_at` or name); manual ordering is a nice-to-have we can
add later without a schema change.
- When we do add it, reorder should be a **client-only** preference (persisted in
localStorage, like the existing collapsed-section state) rather than server
state. Since projects are owner-private (§9), there's no shared-ordering
concern to force it into the DB — an owner's arrangement is theirs alone, and a
local preference is enough. Promoting it to a server column is a reversible
future step if cross-device ordering is ever wanted.
## 8. Priority 4 — Project config: default working dir, host, memory & context
Break into the three sub-capabilities. All are **owner-private** (§9).
### 8.1 Default working directory & host
- **What:** a project stores a default `host_id` + `workspace` (and optionally
default `git` base-branch / worktree policy, harness, model, reasoning
effort). New sessions created "in" the project pre-fill these.
- **How it works with the host model:** `workspace` is a path *on a specific
host*, and hosts are user-owned machines/sandboxes that go online/offline.
Implications to design for:
- The project's default host may be **offline** at session-create time →
fall back to prompting for host, keep the workspace path as a hint.
- For **managed** hosts, the "workspace" is a git repo URL that provisions a
fresh sandbox per session — the project default is the repo URL + branch
policy, not a live path.
- Working directory remains **immutable per session** (existing constraint);
the project only seeds the *initial* value.
- **Proposed:** store project defaults as *hints*, never hard requirements —
the new-chat dialog pre-fills them and always lets the user override, and
silently drops any hint that isn't currently satisfiable (host offline).
### 8.2 Project-level memory (owner-private)
- **What:** a persistent memory store scoped to the project, readable/writable
across all its sessions, so learnings accumulate across sessions instead of
being lost per-session.
- **Grounding:** no cross-session memory exists today; `session_state` is
per-conversation and policy-oriented. This is net-new.
- **Owner-private:** memory belongs to the project owner and is **never** exposed
through a session shared individually with someone else (§9.4). This avoids
the leak where one shared session would expose learnings aggregated from *all*
the project's sessions — including ones never shared with that person.
- **Design questions to resolve:**
- **Storage:** DB rows (project-scoped key/value or documents) vs. files in
the project's default workspace (like agent memory files). DB is
host-independent; files are natural for coding agents but tied to one
workspace/host. **Proposed:** DB-backed project memory (host-agnostic,
survives host churn), optionally surfaced to the agent as a virtual
file/tool.
- **How the agent reads/writes it:** injected into system context at session
start? Exposed as a tool (read/append/update)? Both?
- **Compaction interaction:** must survive conversation compaction — project
memory lives outside the conversation so it's inherently durable.
### 8.3 Project-level context (owner-private)
- **What:** reference material attached to the project (pinned docs, links,
instructions, files) that seeds every session's context.
- **Relationship to memory:** *context* = user-curated inputs (I give the
project these docs/instructions); *memory* = agent-accumulated learnings (the
agent records what it discovered). Keep them as distinct surfaces even if
stored similarly. Both are owner-private.
- **Design questions:** size/token budget when injecting into new sessions;
per-file include/exclude; whether context is copied into the session at start
(snapshot) or referenced live (updates propagate). **Proposed:** referenced
live with a snapshot-at-turn behavior, matching how instruction files work.
## 9. Decision: no project-level sharing (per-session ACL only)
**Decision: we do not add a project-level ACL.** Sharing stays per-session,
exactly as it is today. A project is the **owner's private organizer**; its
membership, memory, and context are the owner's private metadata and do not
travel to other viewers.
### 9.1 Why this is coherent
Two principles point the same way:
- Memory/context are **owner-private** (§8) — a project's learnings span multiple
sessions, so exposing them through a single shared session would leak content
from sessions never shared with that person.
- **Shared sessions ignore project** — when a session is shared, the *session*
crosses the ACL boundary; its *project membership does not*.
The reconciling rule:
> A session's project membership is scoped to its **owner**. When a session is
> shared, the session crosses the ACL boundary; its project membership,
> memory, and context stay with the owner.
### 9.2 How it looks
```mermaid
flowchart TD
subgraph Alice["Alice (owner)"]
PX["Project X<br/>(private: defaults + memory + context)"]
PX --> A["Session A"]
PX --> B["Session B"]
PX --> C["Session C"]
end
B -->|session ACL grant| BOB
subgraph Bob["Bob's view"]
SWM["Shared with me<br/>• Session B (ungrouped)"]
BOB --> SWM
end
PX -.->|project + memory/context<br/>never cross| Bob
```
- Alice files sessions A/B/C under Project X — only **Alice** sees them grouped.
- Alice shares session B with Bob via the existing session ACL.
- Bob sees session B in **"Shared with me"**, **ungrouped** — no project, no
memory, no context. Nothing leaks.
### 9.3 What this buys us
- No new `project_permissions` table, no inherited/computed grants, no
move-in/move-out ACL recomputation.
- Requirements 14 (create, move, rename, defaults) — and the optional reorder —
need no ACL at all.
- The existing per-`(user, conversation)` ACL does all sharing, unchanged.
### 9.4 Consequence to enforce
- Project memory/context access is gated by **ownership of the project**, never
by a session-level grant. A session shared individually must not unlock the
project's memory/context.
### 9.5 Owner-scoped membership (Model A)
Project membership is a single `project_id` owned by the session owner. A
recipient of a shared session **cannot** file it into one of *their own*
projects — shared sessions appear as a flat list in "Shared with me". Letting a
viewer organize *shared* sessions into their own projects (per-viewer membership,
"Model B") is a possible future extension and still needs **no** ACL, since each
user's memberships would be their own private metadata.
## 10. Priority 5 — What we do NOT support (intentional)
- **Project-level sharing / ACL** — sharing stays per-session (§9).
- **Nested projects / sub-projects.** One level only.
- **A session in multiple projects.** Membership is single, owner-scoped.
- **Per-viewer filing of shared sessions** ("Model B", §9.5) — deferred.
- **Automatic/AI-driven grouping** — grouping stays user-defined (per #863).
- **Org/team-owned projects** — no team entity exists; out of scope until one
does.
- **Public (`__public__`) projects** — out of scope (follows from §9: projects
aren't shared at all).
## 11. Phasing (proposed)
- **Phase 0 — Grouping (v1, shipped on main):** label-based projects
(`omni_project`), sidebar sections, new-chat picker, kebab move. Per
`SESSION_PROJECTS_SIDEBAR.md`.
- **Phase 1 — First-class entity:** `projects` table + CRUD, empty projects
(P1), move/create-in-project (P2), rename (P3), delete semantics. Reorder is
**optional** and, if added, client-only (§7.2). No label backfill here — the
server dual-reads (§13), so first-class projects work alongside existing
label-projects without migrating them. Shipped as three PRs: **1a** the project
container (table + store + CRUD), **1b** session membership (the `project_id`
column, conversation-store dual-read, and the session-move HTTP surfaces), and
the **web UI** (sidebar create/rename/delete/move against the first-class
entity, dual-reading label-projects) — see §12.
- **Phase 2 — Project defaults (P4a):** default host/workspace/harness/model
seeded into the new-chat dialog when starting a session from within a project;
graceful degradation when host offline / not owned.
- **Phase 3 — Memory & context (P4b/P4c):** owner-private project memory store +
curated context; agent read/write; injection into sessions.
- **Phase 4 — Label consolidation (deferred, optional):** one-off backfill of
`omni_project` label-projects into `projects` rows, then (only if ever)
retiring the label path. Not required for the feature — dual-read makes it
opportunistic cleanup, not a milestone (§12, §13).
## 12. Implementation status / TODO
Tracks what has actually landed vs. what remains. Updated as work ships.
### Done (Phase 1a — project container: entity + store + CRUD)
Shipped: the project **container** — create, list, rename, and delete empty
projects. Session→project membership landed separately in Phase 1b (below).
-**`projects` table** — `SqlProject` (`db_models.py`): `id` (Uuid16),
`name`, `owner_user_id`, `created_at`, `updated_at`. Owner-scoped index; a
UNIQUE index on `(workspace_id, owner_user_id, name)` enforces per-owner name
uniqueness at the DB layer for non-NULL owners (the store's `_name_taken`
check guards NULL-owner / single-user rows, which SQL treats as distinct).
(No `config` column in Phase 1a — deferred so we didn't ship an unused
column; added in Phase 2 via migration `b3c4d5e6f7a8`, see the TODO below.)
-**Migration** `b1c2d3e4f5a6` — creates the `projects` table only;
additive, no backfill. Chained after `d4f2a1b6c8e9`.
-**Entity**`Project` (`entities/project.py`).
-**Store**`ProjectStore` + `SqlAlchemyProjectStore` (create/get/list/
update/delete, owner-scoped; `IntegrityError``ALREADY_EXISTS` as the DB
backstop for the uniqueness race).
-**API**`POST/GET/PATCH/DELETE /v1/projects` (`routes/projects.py`),
request/response schemas, wired into `create_app` + CLI; `openapi.json`
regenerated. Every handler is owner-scoped (projects are owner-private).
- ✅ Tests: store CRUD + owner isolation + name uniqueness (incl. DB backstop);
route CRUD (single- + multi-user header auth); entity.
### Done (Phase 1b — session membership over HTTP)
Links sessions to projects and exposes it, completing Phase 1.
-**Membership column + migration** — nullable `project_id` (Uuid16) on
`SqlConversationMetadata` (no DB FK, Rule R032; `NULL` = unfiled) via
`c2d3e4f5a6b7`, an add-column migration chained after `b1c2d3e4f5a6`, plus
`ix_conversation_metadata_project_id` and `Conversation.project_id`.
-**Conversation-store ops**`set_conversation_project()` (file / move /
unfile by id) and a **name-based dual-read** `list_conversations(project=…)`
filter: a session is "in project `<name>`" if it has EITHER the first-class
membership (`metadata.project_id` → the caller's project of that name) OR the
legacy `omni_project` label with that value; `""` = unfiled. Cross-DB-safe;
the prefetch is bounded to the caller's permission-scoped ids so the `IN` /
`NOT IN` list can't grow past their own sessions. This is the §13 dual-read
made real, so there is no coexisting pair of filters to reconcile later.
-**Session routes**`PATCH /v1/sessions/{id}` files/unfiles by id
(owner-only; target-project ownership validated → 404 otherwise) and
`GET /v1/sessions?project=<name>` lists a project's sessions (owner-scoped);
`project_id` surfaced on `SessionResponse` / `SessionListItem`; `openapi.json`
regenerated.
- ✅ Tests: store membership ops + name-based dual-read (incl. unfiled and the
cross-DB split-DB path); route move / unfile / list, single- and multi-user
ownership boundaries.
### Done (Web UI — first-class projects in the sidebar)
Wires the web app to the first-class entity, keeping the label path working via
dual-read so no migration is forced. Folders are keyed by **name** (the union
key that merges a first-class project and a like-named label-project into one
folder), carrying the first-class `id` when one exists.
-**List dual-read**`GET /v1/sessions/projects` now returns the UNION of
first-class projects (`project_store.list`, incl. empty, with `id`) and legacy
label-projects (`id=None`), merged by name and sorted. Response shape changed
from `list[str]` to `list[{id, name}]`; owner-scoped as before.
-**First-class CRUD client**`web/src/lib/projectsApi.ts`
(`list/create/rename/delete` against `/v1/projects`); hooks `useCreateProject`,
`useRenameProject`, and a reworked `useDeleteProject`. `useProjects` now
returns `{id, name}[]`.
-**Create empty project** — a "New project" control (`NewProjectButton`) in
the always-visible Projects section (My-sessions tab), even with zero
projects (§5.3). `POST /v1/projects`.
-**Membership by `project_id`** — filing/moving a session (composer picker,
row kebab, drag-drop) PATCHes `project_id`, resolving the picked name to an id
and **creating the first-class row on demand** for a label-only folder. `""`
unfiles. Sidebar groups a folder's members by first-class id OR the legacy
label, and a row's "current project" dual-reads both (so pinned first-class
members keep their project flyout).
-**Rename**`PATCH /v1/projects/{id}` (O(1)); a label-only folder is
promoted on demand (create + re-file members). **Delete** — archives and
unfiles every member (clears `project_id` + `omni_project` label), then
deletes the container; sessions are never deleted (§5.3, §7).
- ✅ Tests: `projectsApi` unit tests; reworked hook tests (resolve→file,
create-on-demand, archive+unfile+delete); sidebar/composer suites updated;
server union test. Empty folders read "No sessions".
### Done (Benchmark — #3094)
-**Latency journeys + corpus seeder.** Added `list_projects` (sidebar project
list, dual-read union) and `list_project_sessions` (`?project=` folder fetch)
latency journeys to `dev/benchmarks/omnigent`, mirroring the `list_sessions`
hot read path. The corpus seeder now also seeds first-class `projects` rows
and files a configurable fraction of sessions into them (`--projects`,
`--filed-fraction`) so the journeys measure a realistic sidebar instead of an
empty project set, and the PR benchmark regression check runs on
`dev/benchmarks/**` changes. CRUD writes remain unbenchmarked (infrequent
single-row ops).
### Done (Phase 2 — project defaults, P4a)
Complete, shipped across several PRs: default host/workspace/agent + an opt-in
worktree stored on the project and seeded into the new-chat composer.
-**Backend `config` column.** Added a nullable `config` column to
`projects` (migration `b3c4d5e6f7a8`, additive with a clean downgrade) and
plumbed it through the store/entity/API. `config` is an **opaque JSON
object**, not a typed schema: the backend persists it whole and never
filters on it, so the key vocabulary (host/workspace/harness/model/
reasoning-effort/git base-branch, …) is owned by the client and can grow
without a schema change. Values are *hints* (§8.1). Store `update()`
distinguishes `config=None` (leave unchanged) from `config={}` (clear), so
a rename never wipes stored defaults. Exposed on `ProjectObject` /
`CreateProjectRequest` / `UpdateProjectRequest` (openapi regenerated).
-**Settings editor.** A "Project settings" dialog
(`web/src/shell/ProjectSettingsDialog.tsx`, reached from the project-folder
kebab menu) writes a project's `config`: host, working directory, default
agent, and an opt-in "Random worktree" toggle. The `config` shape the client
owns is `{ host_id?, workspace?, agent_id?, use_worktree? }`. Fields are
optional (an unset one stores no key); an all-default dialog clears to `{}`.
Worktrees are **opt-in** — the toggle defaults OFF and only an explicit ON is
stored as `use_worktree: true` (matching "worktrees are opt-in at create
time"). The host/agent pickers and filesystem browser reuse the composer's
components.
-**Seed defaults into the new-chat dialog.** The composer reads the stored
`config` and pre-fills host / working directory / agent, always overridable,
silently dropping any hint that isn't currently satisfiable (e.g. the default
host is offline, or the configured agent is no longer registered). An unset
field falls through to the composer's generic defaults (last host, recent
workspace, last-used agent). The prefill machine waits while the projects
list (name → id) or the config query is still loading, so a generic default
can't win the race. An opt-in worktree (`use_worktree: true`) generates a
fresh `worktree-<hex>` branch once the workspace is in place and confirmed a
git repo — including for empty projects, where the workspace comes from the
config or the home-fallback.
-**Backend `config` hardening (from the #3108 review).** Both landed in
`stores/project_store/sqlalchemy_store.py`: (a) `_encode_config` bounds the
serialized `config` at 64 KiB (`_CONFIG_MAX_SERIALIZED_LEN`) and raises
`INVALID_INPUT` past it — the value is persisted verbatim and reflected back,
so an unbounded blob is a mild storage/response-size amplifier; (b)
`_decode_config` coerces a non-dict blob (future writer / manual DB edit) back
to `{}` rather than returning it raw, so callers can always treat config as a
mapping.
-**Replaced the inference-based prefill (PR #2133).** That merged PR
prefilled the composer by *inferring* defaults from the project's newest
session (host/agent/repo + a fresh worktree branch) — an explicit non-goal
workaround for the absence of stored project defaults. Now that the dialog
reads the stored `config`, the inference path is retired: `projectPrefill.ts`
is collapsed to config-only seeding, and `useNewestProjectSession` (plus its
`["project-newest-session"]` cache invalidations) is removed. Stored config
is the single source of truth for a project's defaults; a project with no
config prefills nothing project-specific (just the generic defaults).
### TODO
**Postponed.** Phases 12 (first-class projects + defaults) are shipped. Phases 3
and 4 below are **not scheduled**, each on a different trigger: Phase 3 waits for
customer evidence that project memory/context is wanted; Phase 4 waits until
telemetry shows most clients have migrated to a version that writes `project_id`
(so retiring the label path is safe). Both are additive and independent, so either
can start whenever its trigger lands.
- ⏸️ **Phase 3 — memory & context (P4b/P4c)** — new `project_memory` /
`project_context` tables + agent read/write + injection (§8.2/§8.3). Postponed:
the largest remaining chunk and the one with open design questions (§14 Q5/Q6);
wait for customer evidence that cross-session project memory/context is wanted
before committing to a storage + agent-surface design.
- ⏸️ **Phase 4 — label consolidation (postponed; not required for the feature).**
Because the server dual-reads (a session is "in project X" if it has *either* a
`project_id` *or* the `omni_project` label — see §13), the first-class feature
works end-to-end without touching the label path, so this whole phase is
deferred and may never be needed. Postponed until telemetry shows most clients
have migrated to a version that writes `project_id` — retiring the label path is
only safe once old label-writing clients are gone. Two steps, both optional:
- **Label → `project_id` backfill** — a **separate one-off command/migration**
converting existing `omni_project` label-projects (real production data) into
`projects` rows. Gives a clean single source of truth, but dual-read means it
is **not mandatory** — only needed if/when we decide to retire the label path.
- **Retire the label path** — remove the `omni_project` reads/writes and the
`?project=<name>` filter. This includes the one UI reader still on the label
path: the Settings archived-only project picker
(`fetchAllArchivedProjectNames`) derives its options from the `omni_project`
label. Last step, if ever; do only after the backfill has run and telemetry
shows no client still writes labels. Keeping the label path is cheap, so
treat retirement as opportunistic cleanup, not a milestone.
## 13. Backwards compatibility (mixed server / client versions)
Because deployments can run an old client against a new server (or vice versa)
during a rollout, the design keeps every change additive:
- **DB / old server code, new schema.** Migration `b1c2d3e4f5a6` only *adds* a
nullable column and a new table. An older server binary reading the migrated
DB simply ignores `project_id` and the `projects` table — no column it selects
disappears, no NOT NULL constraint is added. Rollback is a clean
`downgrade()` (drops the column + table) since no existing data was rewritten.
- **New server, old client.** The `/v1/projects` routes and the `project_id`
filter are *new* endpoints/params; an old web client that doesn't call them
behaves exactly as before. `project_id` is an added field on the session
snapshot — old clients ignore unknown JSON fields.
- **Old server, new client.** A new client may call `/v1/projects` or pass
`?project_id=`; against a server without the router mounted these 404 / are
ignored. The client must degrade gracefully (treat "no projects endpoint" as
"feature off") — a UI requirement to note, not a data risk.
- **v1 label coexistence.** The label path (`omni_project`, `?project=<name>`)
is untouched and runs alongside the new `project_id` path, so a client still
on the label API keeps working through the transition. Neither path writes the
other's storage, so a mixed fleet can't corrupt state — at worst a session is
grouped by label on one client and unfiled by `project_id` on another until
the backfill runs.
- **Server dual-reads to stay client-compatible.** Rather than forcing a client
cutover, the server treats a session as "in project X" if it has *either* a
`project_id` *or* the `omni_project` label. This keeps old web clients (which
still write labels) working indefinitely with no forced upgrade, and means the
label path can stay in place — retiring it is an optional, deferred cleanup
(§12), not a milestone. The backfill (§12) migrates existing label data into
rows for a clean single source of truth, but because of dual-read it is not a
correctness prerequisite. Only if the label path is ever removed must clients
first have shipped the `project_id` path.
## 14. Open questions
1. **§4** Confirm FK (`project_id`) over label-of-id for membership.
(Proposed: FK.)
2. **§6.2 / §8.1** When starting a session from within a project, auto-fill
host/workspace/harness from project defaults (overridable)? (Proposed: yes.)
3. **§7.1** Case-sensitive, per-owner-unique project names? Max length 100?
4. **§8.1** Store project defaults as soft hints (drop when unsatisfiable) vs.
hard requirements? (Proposed: soft hints.)
5. **§8.2** Project memory storage: DB vs. workspace files? (Proposed: DB.)
6. **§8.2/8.3** How is memory/context surfaced to the agent — system-context
injection, a tool, or both?
7. **§9.5** Do we ever want per-viewer filing of shared sessions (Model B), or
is a flat "Shared with me" list sufficient long-term?
+60 -4
View File
@@ -106,6 +106,7 @@ swap-on-access:
| `https_basic` | `Authorization: Basic b64(user:<real>)` | swap-on-access (optional `env:`) | Generic Basic auth; `username` defaults to `x-access-token`. |
| `git_https` | `Authorization: Basic b64(user:<real>)` | swap-on-access | Preset for git-over-HTTPS; nothing in the sandbox. |
| `gh_basic` | Basic for git host, `token` for api host | swap-on-access for git; `GH_TOKEN`/`GITHUB_TOKEN` env for api | Preset for GitHub CLI + git; defaults to `github.com` + `api.github.com`. |
| `databricks_cli` | `Authorization: Bearer <real>` per workspace host | placeholder `.databrickscfg` file (one `oa_cred_*` per profile) | Preset for the Databricks CLI; takes `profiles` (+ optional `default`). See below. |
Common fields: `target`/`targets` (host + optional path glob — only the
host binds the credential; path scoping is delegated to `egress_rules`),
@@ -152,6 +153,54 @@ parser) that rejects unknown keys, enforces exactly one source key, and
checks POSIX env-var names — then converts to the `CredentialSourceSpec`
dataclass the runtime consumes.
### `databricks_cli` — profile-keyed, refreshing, file-materialized
The Databricks CLI is a fifth type that differs from the four host-keyed
primitives above:
- **Profile-keyed, not host-keyed.** It takes `profiles: [name, ...]`
(and optional `default`) instead of `target`/`targets`/`source`. The
workspace host behind each profile is only known once the parent
resolves it, so profiles are carried on `DatabricksProxySpec` rather
than in the host-keyed `entries` list. Only the listed profiles are
proxied; every other profile is invisible to the sandbox.
- **File materialization, not env injection.** The CLI gates on a local
credential (like `gh`) and `DATABRICKS_HOST`/`DATABRICKS_TOKEN` carry
only one workspace, so per-profile selection needs a config file. The
parent writes a placeholder-only `.databrickscfg` into the sandbox
scratch dir — one `[profile]` section per profile with the real `host`
and a synthetic `token = oa_cred_*` — and points `DATABRICKS_CONFIG_FILE`
at it (and `DATABRICKS_CONFIG_PROFILE` when `default` is set). The CLI
emits `Authorization: Bearer oa_cred_*`, which the proxy swaps per host.
- **Refreshing secret.** Databricks profiles are usually OAuth
(`auth_type = databricks-cli`) with a ~1h token, so the rewrite rule
holds a `DatabricksProfileTokenProvider` (via the SDK) instead of a
static secret. The proxy calls `rule.resolve_secret()` on each swap; the
provider re-mints via `Config.authenticate()` at most once per throttle
window (the SDK caches in-memory and only re-shells near expiry), so a
long session survives token expiry. The provider requires the
`databricks` extra and fails loud if it is missing.
- **Egress is operator-listed.** Reaching a workspace requires its host in
`egress_rules` (`* <host>/**`), the same as every other credential-proxy
type. The proxy does not widen egress on its own — an earlier draft
auto-added resolved hosts, but that was dropped to keep egress behavior
consistent across types (the operator declares every reachable host).
- **Linux only.** The `databricks` CLI is a Go binary and Go on macOS
ignores `SSL_CERT_FILE`, so `databricks_cli` is rejected on
`darwin_seatbelt` (same rationale as `gh_basic`); use `linux_bwrap`.
```yaml
os_env:
sandbox:
type: linux_bwrap
egress_rules:
- "* pypi.org/**"
credential_proxy:
- type: databricks_cli
profiles: [dbc-adb7b1a3-9097, oss]
default: dbc-adb7b1a3-9097
```
## Internal model
`omnigent/inner/datamodel.py`:
@@ -162,8 +211,11 @@ dataclass the runtime consumes.
type compiles down to: `host`, `scheme` (`basic`/`bearer`/`token`),
`source`, `username | None`, `inject_env: list[str]` (empty for
swap-on-access; populated only by the opt-in `env` shim).
- `CredentialProxySpec` — list of entries; attached to
- `CredentialProxySpec` — list of entries plus an optional
`databricks: DatabricksProxySpec`; attached to
`OSEnvSandboxSpec.credential_proxy`.
- `DatabricksProxySpec` / `DatabricksProfileBinding` — the profile list
(+ `default`, `config_env`) for the `databricks_cli` type.
The parser (`omnigent/spec/parser.py`, `_parse_credential_proxy`)
validates each raw entry with a pydantic boundary model
@@ -186,9 +238,13 @@ backend allow-list, and the `gh_basic`-on-macOS guard.
- `helper_env_updates` — synthetic values for each `inject_env` var
(empty for swap-on-access entries),
- `rewrites: list[CredentialRewriteRule]` — `(host, scheme,
real_secret, synthetic | None, username)` for the proxy. `synthetic`
is `None` for swap-on-access entries; it is minted (and the matching
placeholder injected) only when the entry sets `env`.
real_secret | secret_provider, synthetic | None, username)` for the
proxy. `synthetic` is `None` for swap-on-access entries; it is minted
(and the matching placeholder injected) only when the entry sets `env`
(or, for `databricks_cli`, per proxied profile). A rule carries either
a static `real_secret` or a refreshing `secret_provider` — the proxy
calls `rule.resolve_secret()` — plus, for `databricks_cli`,
`sandbox_files` (the placeholder `.databrickscfg`).
The real secret lives **only** in the parent process and the proxy's
in-memory rewrite table. It is never serialized into the
@@ -0,0 +1,487 @@
# Native Harness Plugin Interface (Modular Registry Proposal)
Status: draft
Supersedes nothing; extends `designs/harness-plugin-interface.md`.
## Problem
Omnigent supports **headless / SDK harnesses** as community plugins today (see
`designs/harness-plugin-interface.md`). A package like `omnigent-foo` declares a
`HarnessContribution` entry point, fills `harness_modules` / `aliases` /
`install_specs`, and core wires it in generically — because an SDK harness plugs
in as *pure data*: one import-path string per harness, dispatched through
`omnigent.runtime.harnesses._HARNESS_MODULES` and `runner/routing.py`.
**Native (terminal / TUI) harnesses are not pluggable.** A native harness wraps
a real vendor CLI (Claude Code, Codex, Cursor, Pi, Goose, …) in a tmux/PTY or
local-server session, tails its transcript, mirrors output back into Omnigent,
and mediates auth / permissions / resume / interrupt. Adding one today means
editing core in ~10 places. The registry *rejects* any community contribution
that sets `native_harnesses` or `native_agents`:
```python
# omnigent/harness_plugins.py:716
if contribution.native_harnesses or contribution.native_agents:
return (
f"community harness plugin {entry_point_name!r} registers native terminal "
"metadata, but community native terminal harnesses are not supported yet"
)
```
`designs/harness-plugin-interface.md` § "Native TUI Harnesses" already names the
blockers: *"the runner, chat-resume, CLI-command, interrupt/stop, and built-in
agent seeding paths are not pluggable."* This proposal turns that list into a
concrete plan.
## What is already pluggable
The **data model** is done. `NativeCodingAgent` is a frozen dataclass of stable
wire metadata, contributions carry a tuple of them, and everything downstream
reads them through registry accessors:
- `omnigent/harness_plugins.py``NativeCodingAgent`, `HarnessContribution`
(fields `native_harnesses`, `native_agents`), `native_agents()`,
`native_harnesses()`.
- `omnigent/native_coding_agents.py` — indexes the registry rows by
`agent_name` / `harness` / `wrapper_label` / `terminal_name`.
- `omnigent/_wrapper_labels.py` — the canonical wrapper-label string constants.
- `omnigent/harness_aliases.py` — canonicalization (`native-pi``pi-native`).
Nothing in this proposal changes the *shape* of `NativeCodingAgent`; it adds a
behavior side-channel and rewrites the dispatch that currently ignores it.
## What is NOT pluggable — the coupling inventory
Every blocker is **imperative per-harness dispatch** that branches on
`harness_name == "<x>-native"` or `native_agent.key == "<x>"` and does an inline
`import omnigent.<x>_native`. Grouped by hub:
### 1. The runner — `omnigent/runner/app.py` (~10.1k lines) + `omnigent/runner/native/orchestration.py` (~6.5k) — the epicenter
Phase 0 (#3148) moved the native *builders and mirrors* out of `app.py` into
`omnigent/runner/native/orchestration.py` (re-exported through
`omnigent/runner/native/__init__.py`), shrinking `app.py` from ~20.1k to
~10.1k lines. The imperative per-harness *dispatch* still lives in `app.py`;
it now calls the imported builders instead of locally-defined ones. The
coupling left to untangle:
- **Spawn-env dispatch** (`app.py`, 11 arms): `if harness_name ==
"<x>-native" and spawn_env is None: ... build_<x>_native_spawn_env`.
- **Launch dispatch** (`app.py`, 11 arms) → `_auto_create_<x>_terminal(...)`.
- **`_auto_create_<x>_terminal`** functions (11 of them) — now in
`runner/native/orchestration.py`; each imports its own `<x>_native_bridge` /
`<x>_native_forwarder` / `<x>_native_permissions` and wires the transcript
forwarder + permission/usage/compaction mirrors, alongside the
`_supervise_*_bridges` mirrors (`_supervise_cursor_native_bridges`,
`_supervise_goose_native_bridges`, `_supervise_hermes_native_bridges`,
`_supervise_qwen_native_bridges`). Still the dominant blocker — the split
gave it a home but the `if key ==` dispatch that reaches it is unchanged.
- **Interrupt / stop dispatch** (`app.py`) → `_handle_<x>_native_interrupt` /
`_handle_<x>_native_stop` closures (kept in `app.py`, not extracted).
- **Terminal-route dispatch** (`app.py`): `terminal_name == "<x>"` →
`_auto_create_<x>_terminal`.
- Plus the 11 `*_NATIVE_TERMINAL_ROLE` imports and the cost-popup bridge-dir
dispatch (both in `app.py`).
### 2. Native launch — `omnigent/cli.py` (~14.5k lines)
Each native TUI is a hand-written `@cli.command` (`claude`, `codex`, `opencode`,
`pi`, `cursor`, `kiro`, `goose`, `hermes`, `antigravity`, `qwen`, `kimi`), each
importing `from omnigent.<x>_native import run_<x>_native` and calling
`_reject_native_on_windows("<x>")` with a literal name. No registry indirection
generates these.
### 3. Resume / resume-redirect
- `omnigent/resume_dispatch.py:216` (`_dispatch_wrapper`) — the canonical
11-branch `if native_agent.key == "<x>":` chain, each `import
run_<x>_native`. Used by `omnigent resume`.
- `omnigent/chat.py:1057` (`_redirect_native_resume_if_needed`) — a parallel,
partially-covered (6 of 11) resume-redirect keyed on `native_agent.key`, with
hand-written `_run_<x>_native_resume_redirect` helpers.
### 4. Built-in `*-native-ui` agent seeding — `omnigent/server/app.py`
`_ensure_default_agents` calls 11 hardcoded `_ensure_default_<x>_agent(...)`,
each paired with a `_build_<x>_native_bundle()` that imports
`_materialize_<x>_agent_spec`. `omnigent/db/utils.py:builtin_agent_id` and
`omnigent/session_import/local.py` depend on the fixed built-in names.
### 5. Enumerations parallel to the registry (should *derive* from it)
- `omnigent/spec/_omnigent_compat.py:88` — `OMNIGENT_HARNESSES` /
`OMNIGENT_HARNESS_ALIASES` frozensets re-list all native ids + `native-*`
aliases.
- `omnigent/onboarding/harness_readiness.py` — per-family frozensets gating
readiness/auth.
- `omnigent/onboarding/harness_install.py:219` — `_HARNESS_NAME_TO_KEY`.
- `omnigent/model_override.py` / `omnigent/model_catalog.py` — `*_FAMILY` /
`_CURSOR_HARNESSES` frozensets.
- `omnigent/server/routes/sessions.py` — `_FORK_HISTORY_NATIVE_HARNESSES`,
`_CURSOR_FORK_HISTORY_HARNESSES`, per-harness wrapper-label/model constants,
and fork/switch gating.
- `omnigent/runner/resource_registry.py` — 11 `*_NATIVE_TERMINAL_ROLE`
constants + the native-role status set.
- `omnigent/runtime/harnesses/__init__.py:36` — a **dead** `_HARNESS_MODULES`
literal listing every `<x>-native` module (overwritten at `:152`). Delete.
### 6. The web mirror — `web/src/lib/`
`nativeCodingAgents.ts` duplicates all 11 rows + aliases; `forkHarness.ts`,
`AgentCard.tsx` (icon switch), and `sessionStop.ts` / `sessionCapabilities.ts` /
`codexPlanMode.ts` hardcode wrapper-label literals. Truly community-contributable
native harnesses need the web driven by `GET /v1/harnesses`, not literals.
## Design: a `NativeHarnessProvider` behavior seam
Mirror how SDK harnesses supply *one import path* (`harness_modules[id]`). A
native harness supplies a small set of import paths for the lifecycle hooks the
dispatch hubs currently hardcode. `NativeCodingAgent` stays a pure-data
identity row; behavior lives in a sibling provider resolved lazily (respecting
the plugin import rules — `get_contribution()` must stay import-light).
```python
# omnigent/harness_plugins.py (new)
@dataclass(frozen=True)
class NativeHarnessProvider:
"""Import paths for a native harness's lifecycle hooks.
Every value is a dotted path resolved lazily at dispatch time, so
get_contribution() never imports the runner/CLI/provider stack.
"""
key: str # matches NativeCodingAgent.key
run_native: str # "...:run_<x>_native" (CLI + resume launch)
auto_create_terminal: str # "...:auto_create_<x>_terminal" (runner)
spawn_env_builder: str | None = None # "...:build_<x>_native_spawn_env"
interrupt_handler: str | None = None # "...:handle_<x>_native_interrupt"
stop_handler: str | None = None # "...:handle_<x>_native_stop"
materialize_agent_spec: str | None = None # "...:_materialize_<x>_agent_spec"
bridge_dir: str | None = None # "...:bridge_dir_for_session" (cost popup)
```
Add to `HarnessContribution`:
```python
native_providers: tuple[NativeHarnessProvider, ...] = ()
```
And accessors in `omnigent/harness_plugins.py`:
```python
def native_providers() -> tuple[NativeHarnessProvider, ...]: ...
def native_provider_for_key(key: str) -> NativeHarnessProvider | None: ...
```
A tiny resolver (new `omnigent/native_dispatch.py`) turns a dotted path into a
callable with `importlib`, caching per path, so each hub calls
`resolve(provider.run_native)(server=..., session_id=..., args=...)` instead of
an `if/elif` arm. `run_native` must accept a uniform `(*, server, session_id,
extra_args: tuple[str, ...])` signature — the per-harness `run_<x>_native`
functions are near-uniform already, so this is mostly a keyword-arg
normalization, not a rewrite.
### Signature normalization
The one real API change: today `run_claude_native(claude_args=...)`,
`run_pi_native(pi_args=...)` each name their pass-through arg differently. The
provider seam requires a single spelling (`extra_args`). Keep the existing
functions, add thin `**kwargs`-tolerant wrappers, or rename the parameter with a
back-compat alias for one release (per CLAUDE.md deprecation policy, note the
target release).
### Rewriting each hub
| Hub | Today | After |
|---|---|---|
| `resume_dispatch.py` `_dispatch_wrapper` | 11 `if key ==` arms | `resolve(provider.run_native)(...)` |
| `cli.py` native subcommands | 11 `@cli.command` funcs | loop over `native_agents()`, register one Click command each; `_reject_native_on_windows` reads the row |
| `runner/app.py` launch + terminal-route | 11 arms → `_auto_create_<x>_terminal` | `resolve(provider.auto_create_terminal)(...)` |
| `runner/app.py` spawn-env | 11 arms | `resolve(provider.spawn_env_builder)(...)` when set |
| `runner/app.py` interrupt/stop | 11 arms each | `resolve(provider.interrupt_handler / stop_handler)(...)` |
| `chat.py` resume-redirect | 6 arms | fold into the same provider `run_native`; delete the per-harness redirect helpers |
| `server/app.py` seeding | 11 `_ensure_default_<x>_agent` | loop over `native_agents()`, materialize via `provider.materialize_agent_spec` |
| enumerations (§5) | frozensets/dicts | derive from `native_agents()` / capability flags |
### Capability-driven behavior (replace the ad-hoc frozensets)
Several §5 sets encode *behavior*, not identity — e.g.
`_FORK_HISTORY_NATIVE_HARNESSES` ("rebuilds fork transcript") and
`_CURSOR_FORK_HISTORY_HARNESSES` ("replays history as a text preamble"). These
should become fields on `HarnessCapabilities` (which already exists and is
asserted in `tests/test_harness_capabilities.py`) — e.g. a `fork_history:
Literal["none","rebuild","preamble"]` axis — so the server reads the capability
instead of membership in a hand-maintained set. This also feeds `/v1/harnesses`
so the web can stop hardcoding `forkHarness.ts`.
### Validator flip
Once the hubs resolve through the registry, replace the hard reject in
`_validate_community_contribution` with positive validation:
- every `native_agent.key` has a matching `native_provider.key`;
- provider import paths start with `COMMUNITY_MODULE_PREFIX` (same rule as
`harness_modules`);
- native-agent identity values don't collide with an existing contribution
(the `_native_agent_identity_values` check already exists — keep it);
- `run_native` and `auto_create_terminal` are non-empty.
## Phasing
This is a **substantial refactor, not a small extension**. The realistic path
is an internal refactor first (built-in native harnesses keep living in core but
route through the generic seam), then a thin follow-up that opens it to
community packages.
### Phase 0 — Prep: split the oversized dispatch files
The refactor is concentrated in files that are already too large to edit safely.
The goal is **< 10k lines per file**. Before adding the seam, carve the
native-specific code into cohesive modules so the provider rewrite touches small
files with clear boundaries. This is behavior-preserving and independently
reviewable/mergeable. Each extraction is a mechanical move + import fix, verified
by the existing test suite and `pre-commit run --all-files`. No behavior change;
no `if key ==` arm removed yet.
Done:
- **`cli.py`** ✅ (#3047) — native subcommand bodies moved into
`omnigent/cli_native.py` (they already delegate to `run_<x>_native`); `cli.py`
registers them. `cli.py` is now 9.6k lines; `cli_native.py` 1.3k.
- **`server/routes/sessions.py`** ✅ (#3097) — split into a facade
(`sessions.py`, now 7.8k) that star-imports an impl package
(`omnigent/server/routes/_sessions/`: `common.py`, `helpers.py`,
`orchestration.py`). `create_sessions_router` stays in the facade.
- **`runner/app.py`** ✅ (#3148) — the native builders and bridge mirrors
(`_auto_create_*_terminal`, `_supervise_*_bridges`, the transcript-forwarder
task registry, cost-popup repop tasks) moved into
`omnigent/runner/native/orchestration.py` (~6.5k lines), re-exported through
`omnigent/runner/native/__init__.py`; `app.py` imports them. `app.py` dropped
from ~20.1k to ~10.1k lines. Landed as a single `orchestration.py` rather than
the proposed `terminals.py` / `supervise.py` / `interrupt.py` three-way split —
a further sub-split can happen when the seam lands if the module stays hot.
The `if key ==` / `if harness_name ==` dispatch arms and the interrupt/stop
handler closures stayed in `app.py` (they are the entry points Phase 1
rewrites), so `app.py` is still marginally over the 10k target.
- **`tests/runner/test_app_sessions_native.py`** ✅ (#3149) — the ~19.0k-line
monolith was split into nine concern-scoped modules
(`test_app_sessions_native_{events_lifecycle,events_options,supervision,
terminal_routing,terminals_autocreate,terminals_runtime,wake_forwarders,
workflow_init,workflow_messages}.py`) plus a shared `tests/runner/conftest.py`
(~0.7k) holding the scaffolding. Each new file is under 3k lines.
Deferred (under the 10k target already; fold into Phase 1 when the seam lands):
- **`chat.py`** (4.2k) → move the `_run_<x>_native_resume_redirect` helpers into
`resume_dispatch.py` (they duplicate its dispatch anyway) as the first step of
collapsing the two resume paths into one.
### Current state (verified 2026-07-24, at `main` `59e6b70e`)
Grounding the plan in the actual tree, not just the coupling inventory above:
- **Data model is ready.** `NativeCodingAgent` (`harness_plugins.py:49`) is 11
frozen rows; `HarnessContribution` (`:70`) has `native_harnesses` /
`native_agents` but **no** `native_providers` field yet;
`native_coding_agents.py` already indexes rows by agent_name / harness /
wrapper_label / terminal_name. `HarnessCapabilities`
(`harness_capabilities.py:79`) exists with an optional-field extension
pattern (`steering`, `live_queue`, `images`, `compaction`) but **no**
`fork_history` axis.
- **`run_<x>_native` is already near-uniform.** All 11 are `(*, server,
session_id, <x>_args, resume_picker=..., ...)`. The divergence is only the
pass-through arg *name* plus four harnesses carrying extra kwargs: claude
(`command`, `use_claude_config`), codex (`command`, `model`, `prompt`),
antigravity (`command`, `model`, `permission_mode`), opencode (`model`). So
signature normalization is a keyword-rename with a threaded `**extra`, not a
rewrite — lower risk than "Signature uniformity" under Risks suggested.
- **Coverage is uneven across hubs** (a correctness smell the seam fixes):
`resume_dispatch._dispatch_wrapper` covers 10, `chat.py`
`_redirect_native_resume_if_needed` only 6 (missing opencode/goose/hermes/
antigravity/qwen), runner interrupt handlers 9, stop handlers 7. Routing
everything through one resolver *normalizes* coverage.
- **The dead `_HARNESS_MODULES` literal still exists** (`runtime/harnesses/
__init__.py:36`, overwritten at `:152`) — not yet deleted.
- **`harness_catalog()` (`harness_plugins.py:899`) does not emit native-agent
rows** — only `{id, label, capabilities?, setup_steps?}` per harness, no
`agent_name` / `wrapper_label` / icon. The web is still 100% literals.
Roughly **60+ hardcoded duplication points across ~12 Python + 6 TS files**
plus the five dispatch hubs remain.
### Phase 1 — Internal provider seam (core-only)
Built-ins keep living in core but route through the generic seam. The test bar
for every PR here is **"every native harness behaves identically before/after"**
— lean on the split native test suite (#3149) and the native e2e skills. The
validator keeps rejecting community native metadata throughout Phase 1.
| PR | Scope | Key files | Depends on | Risk | Est. |
|---|---|---|---|---|---|
| **1.1 Provider model + resolver** | Add `NativeHarnessProvider` (import-path strings), the `native_providers` field + accessors, and `omnigent/native_dispatch.py` (lazy `importlib` resolver, cached per path). Populate 11 built-in providers pointing at existing `omnigent.<x>_native` functions. Purely additive — no hub rewired yet. | `harness_plugins.py`, new `native_dispatch.py` | — | Low | 12d |
| **1.2 Signature normalization** | Give `run_<x>_native` a uniform `extra_args` spelling with a back-compat `<x>_args` alias (one-release deprecation per CLAUDE.md — name the target release). Decide the `**extra` protocol for the four special-kwarg harnesses (claude/codex/antigravity/opencode). | 11 `omnigent/<x>_native.py`, `native_dispatch.py` | 1.1 | LowMed (mechanical ×11) | 23d |
| **1.3 Resume hubs** | Collapse `resume_dispatch._dispatch_wrapper` (10 arms) and the 6 `chat.py` `_run_<x>_native_resume_redirect` helpers into one `resolve(provider.run_native)(...)` path. Deletes the redirect helpers and normalizes the 10-vs-6 coverage gap. | `resume_dispatch.py`, `chat.py` | 1.1, 1.2 | Med | 2d |
| **1.4 CLI subcommands** | Replace the 11 hand-written `@cli.command` funcs in `cli_native.py` with a loop over `native_agents()`, registering one Click command each; make `_reject_native_on_windows` a registry-driven guard. Wrinkle: per-command options (`--model`, `--command`) must come off provider/row metadata. | `cli_native.py`, `cli.py` | 1.1, 1.2 | Med | 23d |
| **1.5a Runner spawn-env** | Collapse the spawn-env dispatch — **two** near-identical 11-arm blocks (`app.py` ~2720 and ~6236) — behind a uniform `build_spawn_env(session_id, *, server_client, labels)` provider hook. Absorbs the three shapes (bare / bridge-id-from-labels / hermes policy-hook write) behind that one signature. Self-contained; removes the duplication. The bounded first measurement of the runner. | `runner/app.py`, 11 `<x>_native_bridge.py` | 1.1, 1.2 | Med | 23d |
| **1.5b Runner launch** | The epicenter. The launch arms (`app.py` ~28413316 + the ~6095 elif chain) do **not** share a signature — `_auto_create_<x>_terminal` has 11 divergent signatures (3 common params; claude carries 9 extras). Unify by passing a `NativeLaunchContext` dataclass to a uniform `provider.auto_create_terminal(ctx)` adapter, with explicit `pre_launch` hooks for the non-uniform arms (claude transfer-inbound + rebuild-on-switch, codex needs-terminal check, antigravity host-spawn + transfer, opencode cold-boot terminal-ensure on the turn path ~6329). **Preserve the `_supervise_*_bridges` forward-cursor / double-post invariants exactly.** | `runner/app.py`, `runner/native/orchestration.py` | 1.5a | **High** | 46d |
| **1.5c Runner terminal-route** | Collapse the `terminal/attach` `ensure_native_terminal` dispatch (`app.py` ~74627908). 9/11 are a uniform check→create→return; codex + antigravity need an ownership-check + response-wrap hook (`_is_runner_owned_*`, `_codex_ensure_response_with_policy_notice`). Reuses 1.5b's context object. | `runner/app.py` | 1.5b | Med | 2d |
| **1.6 Runner interrupt/stop** | Route interrupt/stop through the provider; fill the 9-interrupt / 7-stop coverage gaps so every native has both paths. **Not additive as first scoped:** every handler is a closure over app-scope state (`server_client`, `resource_registry`, `_publish_event`, the `_AUTO_FORWARDER_TASKS` / `_session_*` module dicts), so extracting to module-level provider functions requires threading those in as a dependency-injection context, not a plain move. | `runner/app.py`, `runner/native/orchestration.py` | 1.5b | MedHigh | 34d |
| **1.7 Seeding loop** | Replace the 26 `_ensure_default_<x>_agent` / `_build_<x>_native_bundle` touchpoints in `server/app.py` with a loop materializing via `provider.materialize_agent_spec`. **`builtin_agent_id` output must stay byte-identical** so redeploy doesn't orphan seeded agents — pin this with a test. | `server/app.py`, `db/utils.py` | 1.1 | Med | 23d |
| **1.8 Derive enumerations** | Add a `fork_history: Literal["none","rebuild","preamble"]` axis to `HarnessCapabilities`; derive the §5 frozensets/dicts from `native_agents()` / capabilities (8 files, ~35 sets); delete the dead `_HARNESS_MODULES` literal. Also add optional `shell_tool_name` / `shell_tool_prompt` fields so the harness bench's tool-call probe can be driven off capabilities instead of its hardcoded `_NATIVE_TOOL_PROVOCATION` table (see "Harness bench compatibility" below). | `harness_capabilities.py`, `_omnigent_compat.py`, `harness_readiness.py`, `harness_install.py`, `model_override.py`, `model_catalog.py`, `_sessions/common.py`, `resource_registry.py`, `runtime/harnesses/__init__.py`, `tests/harness_bench/native_tui_driver.py`, `tests/test_harness_capabilities.py` | 1.1 | Med | 23d |
After 1.1 + 1.2 land, PRs 1.3, 1.4, 1.7, 1.8 touch mostly disjoint hubs and can
proceed in parallel. The runner sub-stack (1.5a → 1.5b → 1.5c → 1.6) is serial —
each reuses the prior's context object — and is the critical path.
**Phase 1 subtotal: ~2029 engineer-days** (revised up from ~1725 after the
runner exploration split 1.5 into 1.5a/b/c and re-scoped 1.6; see below).
### Phase 2 — Open to community packages
Only starts once Phase 1 has every built-in running *through* the seam.
| PR | Scope | Key files | Depends on | Risk | Est. |
|---|---|---|---|---|---|
| **2.1 Validator flip** | Replace the hard reject in `_validate_community_contribution` with positive validation: every `native_agent.key` has a matching `native_provider.key`; provider import paths start with `COMMUNITY_MODULE_PREFIX`; identity values don't collide (`_native_agent_identity_values` already checks this); `run_native` + `auto_create_terminal` are non-empty. | `harness_plugins.py` | 1.1 | LowMed | 1d |
| **2.2 `/v1/harnesses` native rows** | Extend `harness_catalog()` to emit native-agent rows + capabilities (`agent_name`, `wrapper_label`, `fork_history`, icon/label field), so the web has a server source of truth. | `harness_plugins.py`, `server/routes/harnesses.py` | 1.8 | Low | 2d |
| **2.3 Web off the endpoint** | Delete the `nativeCodingAgents.ts` literals + `HARNESS_ALIASES`, the `forkHarness.ts` sets (`NATIVE_REBUILD_HARNESSES` / `PREAMBLE_FORK_HARNESSES` now come from `fork_history`), the `AgentCard` icon switch, and the wrapper-label literals in `sessionStop.ts` / `sessionCapabilities.ts` / `codexPlanMode.ts` — all driven by `/v1/harnesses`. Needs a **demo (screenshots/recording)** per CLAUDE.md; likely splits into 2.3a fork/capabilities data-plumb and 2.3b icon/label rendering. | `web/src/lib/*`, `web/src/components/AgentCard.tsx` | 2.2 | MedHigh (largest FE) | 46d |
| **2.4 Docs + example plugin** | Extend `designs/harness-plugin-interface.md` § "Native TUI Harnesses" with the native checklist, and ship an example native plugin (`examples/` or a sibling `omnigent-foo-native`) proving the contract end to end. **Acceptance criterion: the example plugin is benchable** — `python -m tests.harness_bench --harness <plugin> --live` runs green (selection + native-tui driver + provisioning), which is the honest end-to-end proof the contract holds. | `designs/harness-plugin-interface.md`, `examples/` | 2.1, 2.2 | LowMed | 23d |
**Phase 2 subtotal: ~912 engineer-days.**
### Harness bench compatibility
Does the harness bench (`tests/harness_bench/`, per
`designs/harness-capabilities-bench-seam.md`) work with a community-contributed
native plugin after this refactor? **Mostly yes, with no separate bench-migration
PR — the bench's selection/driver layer is already registry-driven.** Verified
against the current tree:
- **Selection is already registry-driven.** `manifest.py` seeds
`OFFICIAL_PROFILES` from the 4 P0 SDK probes, then loops over
`harness_capabilities()` adding every harness whose `integration_mode is
NATIVE_TUI` (`_native_tui_harnesses()`), auto-generating a `BenchProfile` per
native via `_native_profile()`. Community harnesses also resolve through
`_registry_profile()` (`harness_modules()` / `harness_capabilities()`), and the
CLI accepts a `module:attr` `BenchProfile` reference. So a plugin declaring
`native_agents` + a `NATIVE_TUI` capability **enumerates and gets a profile with
zero bench edits**.
- **The driver is already generic.** `transport.py`'s `driver_registry()` keys on
transport/integration-mode; a native profile auto-selects `NativeTuiDriver`,
which spawns a real server + runner and drives the vendor TUI through the
session API. No `if harness == "x"` in the driver path.
- **Gap 1 — provisioning needs registry-driven seeding, which is PR 1.7.** The
native driver provisions a session against a pre-seeded `<harness>-ui` agent
(`native_tui_driver.py` → `_agent_id(vendor.agent_name)`). Today that agent
exists only because `server/app.py` hardcodes its seeding, so a community
plugin's agent isn't on the server and the run fails at provisioning. **PR 1.7
(registry-driven seeding loop) closes this for free** — no separate bench work.
- **Gap 2 — tool-call probe metadata, folded into PR 1.8.** The tool/MCP probe
reads a hardcoded `_NATIVE_TOOL_PROVOCATION` table (the per-vendor "run this
shell tool" prompt); a plugin can't supply it, so those probes *skip*
(NOT_APPLICABLE — non-fatal; basic-turn / streaming / interrupt / reasoning
probes still run). The `shell_tool_name` / `shell_tool_prompt` capability fields
added in **1.8** let the bench read this off the registry instead.
Net: **no new phase or standalone bench-migration PR.** Full provisioning falls
out of 1.7; tool-call probes become plugin-drivable via a small 1.8 field; and
2.4's example plugin carries a `--live` bench run as its acceptance criterion.
### Effort summary
- **Phase 1** (internal seam): ~2029 engineer-days across 10 PRs (1.11.4,
1.5a/b/c, 1.6, 1.7, 1.8).
- **Phase 2** (community + web): ~912 engineer-days across 4 PRs.
- **Total: ~2941 engineer-days** across ~14 PRs (2.3 may split further).
Folding in review cycles, CI, and runner e2e validation, that is realistically
**~2.53.5 calendar months** done alongside other work. The critical path is
1.1 → 1.2 → 1.5a → 1.5b → 1.5c → 1.6, then 2.2 → 2.3 (web); the risk center is
the **runner sub-stack (1.5b in particular)**, where the divergent
`_auto_create_*` signatures and the `_supervise_*_bridges` invariants live.
### Calibration (updated 2026-07-27, after 1.11.3 landed + runner exploration)
Grounding the estimates in built evidence rather than the original guesses:
- **Additive/mechanical PRs come in under estimate.** 1.1 (provider model +
resolver) and 1.3 (resume-hub collapse, net 261 lines) each landed in ~½ day
of code vs. the 12d / 2d budgeted. 1.1 was cheap partly because the resolver
was ~90% pre-built (`load_object` already did dotted-path → callable). Expect
1.4, 2.1, 2.4 to likewise come in low.
- **The real cost is test-shape churn, not the seam.** 1.3's core rewrite was
trivial; the time went into the tests that pin the exact call shape (kwarg
renames, a parametrized dispatch table, catching an over-reach where launch-
path expectations were flipped before their hub was migrated). This scales
with how many tests pin a hub — and the runner has the most (the ~19k-line
split native suite, #3149).
- **The runner is bigger than the original single "1.5" line implied.** Reading
the code (not guessing) showed spawn-env, launch, and terminal-route each need
their own PR, `_auto_create_*` has 11 divergent signatures (so the seam needs
a `NativeLaunchContext`, not a uniform call), and interrupt/stop (1.6) closes
over app-scope state (needs DI, not a move). Hence 1.5 → 1.5a/b/c and 1.6
re-scoped upward.
- **Net:** trim the additive PRs, **hold the runner sub-stack** until 1.5a is
measured. The back-loaded risk profile is confirmed, not softened, by the
three fast early PRs — 1.11.3 being fast is evidence the foundation is sound,
not that 1.5b will be. A 2-week compression of remaining Phase 1 is plausible
only if the runner sub-stack goes cleanly; that is the one unmeasured unknown.
### Implementation progress
Append-only ledger — one line per PR as it opens, updated to `landed` on merge.
The plan tables above stay the stable target; this tracks what has actually
shipped. **~14 PRs total** (Phase 1: 1.11.4, 1.5a/b/c, 1.6, 1.7, 1.8;
Phase 2: 2.12.4).
| PR | Status | Link |
|---|---|---|
| 1.1 Provider model + resolver | landed | #3239 |
| 1.2 Signature normalization | landed | #3244 |
| 1.3 Resume hubs | landed | #3314 |
| 1.5a Runner spawn-env | landed | #3495 |
| 1.5b-i Runner launch (scaffolding + 8 uniform arms) | in review | (this PR) |
## Risks and open questions
- **Runner extraction is the risk center.** The `_supervise_*_bridges` mirrors
hold subtle forward-cursor / restart / double-post invariants (see the
`_AUTO_FORWARDER_TASKS` transcript-forwarder registry, now in
`runner/native/orchestration.py`). Phase 0's move (#3148) preserved these
behaviorally — verified by the split native test suite (#3149) — so the
remaining risk shifts to Phase 1, where the dispatch that reaches these
mirrors gets rewritten. Lean on the existing native e2e skills
(`claude-native-ui:build-omnigent`, `pi-native-e2e-dev`, etc.).
- **Signature uniformity — confirmed non-uniform (runner exploration).** The
`run_<x>_native` *launchers* normalized cleanly (1.2). The *runner* builders
did not: `_auto_create_<x>_terminal` has 11 divergent signatures (3 common
params; claude carries 9 extras — `bundle_dir`, `skills_filter`,
`auth_token_factory`, `resolve_launch_config`, …), and the launch arms carry
irreducible per-harness pre-call work (claude transfer/rebuild, codex/
antigravity needs+transfer checks, opencode turn-path cold-boot). The seam
therefore passes a `NativeLaunchContext` dataclass to a uniform
`provider.auto_create_terminal(ctx)` adapter with explicit `pre_launch` hooks —
not a single uniform positional call. Interrupt/stop handlers close over
app-scope state and need a DI context, not a plain extraction. This is why 1.5
became 1.5a/b/c and 1.6 was re-scoped upward.
- **Windows.** `_reject_native_on_windows` must keep firing for contributed
natives — make it a registry-driven guard, not per-command.
- **Import hygiene.** Providers hold *strings*; the resolver is the only place
that imports harness modules, and only at dispatch time — preserving the
plugin import rules from `harness-plugin-interface.md`.
- **Capability axis scope.** Which of the §5 sets are genuinely
behavior-capabilities (belong on `HarnessCapabilities`) vs. pure identity
(derive from rows) needs a per-set decision; fork-history is the clearest
capability candidate.
## Bottom line
The data model is ready, Phase 0 (the file splits) has landed, and 1.11.3 are
in review. The remaining work is untangling native orchestration from five
`runner/app.py` chains and four other hubs into a `NativeHarnessProvider`
behavior seam, then flipping the validator — sequenced as ~14 PRs (Phase 1:
1.11.4, 1.5a/b/c, 1.6, 1.7, 1.8; Phase 2: 2.12.4), ~2941 engineer-days total.
The additive foundation (1.1) landed cheap and the additive/mechanical PRs are
coming in under estimate; the estimate now lives almost entirely in the serial
runner sub-stack (1.5a → 1.5b → 1.5c → 1.6), where the code — read, not guessed —
shows divergent `_auto_create_*` signatures (needing a `NativeLaunchContext`),
closure-bound interrupt/stop handlers (needing DI), and the `_supervise_*_bridges`
invariants. Measure 1.5a (bounded spawn-env) before committing a runner
timeline.
+275
View File
@@ -0,0 +1,275 @@
# Server-side streaming dictation
## Problem
The composer mic button (`web/src/components/ComposerMicButton.tsx`) relies on
the browser Web Speech API. That API is only backed by a real recognizer in
official Chrome/Safari builds (Google/Apple cloud speech); it is unavailable
in Electron, Firefox, Chromium, and most self-hosted contexts. Today the
button renders nothing (or "Dictation unavailable") in those environments —
`web/electron/README.md` documents the gap and prescribes the fix: capture
audio in the client and transcribe it on the Omnigent server.
This design adds that path: a streaming speech-to-text WebSocket on the
server, backed by a local [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)
model (CPU, no cloud, no per-request cost), with the mic button falling back
to it whenever Web Speech is unavailable.
## Goals
- Dictation works in Electron, Firefox/Chromium, and the iOS/Android wrappers
(mic permissions are already wired in all three).
- Audio never leaves the operator's infrastructure.
- Live partial transcripts stream into the composer while the user speaks
(the Web Speech path today only inserts final utterances).
- Zero new required dependencies: the STT engine ships as an optional extra
(`omnigent[dictation]`), imported lazily, mirroring the `s3`/`modal`/
`daytona` extras' posture. Servers without the extra (or without models)
report `available: false` and the web UI silently keeps its current
behavior.
## Non-goals
- Voice *conversations* (TTS replies, wake words, hands-free turn taking).
- Replacing the Web Speech path where it works today.
- Terminal REPL dictation (possible follow-up; shares the engine).
- Speaker diarization, translation, non-English models beyond whatever
sherpa-onnx model the operator installs.
## Server
### Engine — `omnigent/server/dictation.py`
A small engine layer isolates the recognizer behind a protocol so tests
(and alternate backends, e.g. Whisper or an OpenAI-compatible
transcription API) don't need the native dependency:
```python
class DictationStreamHandle(Protocol):
def feed_pcm16(self, data: bytes) -> DictationUpdate: ... # decode a chunk
def finish(self) -> str: ... # flush tail, final text
def close(self) -> None: ... # release (client vanished)
@dataclass(frozen=True)
class DictationUpdate:
partial: str # current in-progress utterance, display-ready (revisable)
finalized: str | None # utterance completed by endpointing, if any
```
Emitted text is **display-ready** — an engine that needs punctuation/casing
applies it internally before returning, so the route and protocol stay
engine-agnostic. Most modern models (Whisper, Parakeet) punctuate
themselves; sherpa is the exception (see below).
**Engine registry.** Engines are registered by name and selected via
`OMNIGENT_DICTATION_ENGINE`:
```python
register_engine("sherpa", lambda: SherpaDictationEngine(...), available=_sherpa_available)
register_engine("fake", FakeDictationEngine)
```
Adding an engine (Whisper, Parakeet, a hosted API) is one `register_engine`
call with a factory and an optional availability probe — no edits to
`get_engine` or `engine_availability`. Third-party engines register
themselves on import. The default (unset env var) is `sherpa`.
`SherpaDictationEngine` implements the protocol with a process-wide
`OnlineRecognizer` (streaming transducer: `encoder/decoder/joiner + tokens`)
shared across connections — the ~650 MB weights load once — plus one
recognizer *stream* per WebSocket. Endpointing folds completed utterances
into `finalized` and resets the stream, exactly the loop proven in pi-voice.
An optional `OnlinePunctuation` model re-punctuates partials/finals
(lowercase + strip punctuation before re-adding, throttled) so the live
preview reads like a sentence. This punctuation is **internal** to the
sherpa engine — the raw transducer emits lowercase, unpunctuated text, so
the streams beautify before returning; it is not part of the protocol.
Decode calls are CPU-bound → they run via `asyncio.to_thread`, serialized by
a per-engine `threading.Lock` (sherpa recognizer streams are not documented
thread-safe), with a module-level semaphore capping concurrent dictation
connections (default 2, `OMNIGENT_DICTATION_MAX_STREAMS`).
### Configuration
| Env var | Default | Meaning |
|---|---|---|
| `OMNIGENT_DICTATION_MODEL_DIR` | `~/.omnigent/models/dictation/asr` | dir containing `encoder*.onnx`, `decoder*.onnx`, `joiner*.onnx`, `tokens.txt` |
| `OMNIGENT_DICTATION_PUNCT_DIR` | `~/.omnigent/models/dictation/punct` | optional online-punctuation model dir (`model*.onnx` + `bpe.vocab`) |
| `OMNIGENT_DICTATION_MAX_STREAMS` | `2` | concurrent dictation WebSockets |
| `OMNIGENT_DICTATION_ENGINE` | unset (`sherpa`) | engine to use by registered name (`sherpa`, `remote`, `fake`) |
| `OMNIGENT_DICTATION_REMOTE_URL` | unset | worker stream URL for the `remote` engine, e.g. `ws://venus:8100/v1/dictation/stream` |
`scripts/fetch-dictation-models.sh` downloads a known-good pair (streaming
Nemotron 0.6 B int8 + English online punctuation, both Apache-2.0 upstream)
into the default locations. Availability is computed lazily and cached:
extra installed **and** ASR model dir populated.
**Hardware sizing.** Any sherpa-onnx streaming transducer directory works —
point `OMNIGENT_DICTATION_MODEL_DIR` at it. Streaming dictation needs ≥1×
realtime decode; measured with this engine loop (int8, 4 threads, 100 ms
chunks):
| Model | Apple M-series | Intel N95 (4 E-cores, loaded box) | RAM |
|---|---|---|---|
| Nemotron 0.6 B (fetch-script default) | ~9× realtime | 0.60.7×**too slow** | ~1.0 GB |
| `streaming-zipformer-en-2023-06-26` | — | 1.42.3× realtime | ~190 MB |
| `streaming-zipformer-en-20M` | — | 3.64.9× realtime | ~130 MB |
On N100/N95-class mini-PC servers, use the mid-size zipformer (accuracy held
up in spot checks; the 20 M model audibly degrades) and consider
`OMNIGENT_DICTATION_MAX_STREAMS=1`.
**Other languages.** The engine is language-agnostic — dictation speaks
whatever language the installed model was trained on. The
[sherpa-onnx streaming-model catalog](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/online-transducer/index.html)
includes Chinese, Chinese/English bilingual
(`sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20`), French
(`sherpa-onnx-streaming-zipformer-fr-2023-04-14`), Korean, and more; point
`OMNIGENT_DICTATION_MODEL_DIR` at any of them. Two caveats: the fetch
script's punctuation model is English-only, so leave
`OMNIGENT_DICTATION_PUNCT_DIR` unpopulated for other languages (raw
recognizer output is emitted as-is), and the mic button's `lang` prop only
affects the Web Speech path — the server path's language is decided by the
operator's model choice.
### Remote worker
Where a mini-PC server can't run the model an operator wants at realtime, the
`remote` engine relays each take to a **dictation worker** on a beefier LAN
box. The worker is just `create_dictation_router` served on its own — it
speaks the exact same wire protocol the browser does (PCM frames up,
transcript events down), so no new protocol or code path was needed. The
browser never talks to the worker; the main server authenticates the user on
its own route, then relays over a `websockets` client.
Run the worker wherever the models live (it is **unauthenticated** — bind it
to a trusted LAN/VPN only):
```
pip install omnigent[dictation] && scripts/fetch-dictation-models.sh
python -m omnigent.server.dictation_worker --host 0.0.0.0 --port 8100
```
Then select the `remote` engine on the main server via env vars — no CLI
integration is required:
```
OMNIGENT_DICTATION_ENGINE=remote \
OMNIGENT_DICTATION_REMOTE_URL=ws://<worker-host>:8100/v1/dictation/stream \
omnigent server ...
```
`RemoteDictationEngine` registers by name like every other engine (no changes
to the route, protocol, or selection logic). `_RemoteStream` bridges the
worker's async push events into the synchronous handle interface via a daemon
reader thread, and `close()` releases the worker's capacity slot promptly.
Fallback is per take: if the worker is unreachable and local models are
installed, a lazily-built local sherpa engine serves the take instead (its
weights cost no RAM until the worker actually goes down); each new take
retries the worker first.
Client timeouts (`web/src/lib/dictation.ts`) are widened to exceed the
worker's cold-load budget (`_REMOTE_READY_TIMEOUT_S` / `_REMOTE_STOP_TIMEOUT_S`
in `dictation.py`) so a relayed take doesn't time out on the browser side just
as the worker finishes loading its model.
### Routes — `omnigent/server/routes/dictation.py`
`create_dictation_router(*, auth_provider=None, engine_provider=None)`,
registered in `create_app` under `/v1` like every other router. Dictation is
not session-scoped (the new-chat composer has no session yet), so auth is
identity-level only: authenticated user required when an auth provider is
configured, open in single-user/dev mode — the same posture as
`GET /v1/harnesses`.
Availability rides the existing boot-time capability probe —
`dictation_available` on **`GET /v1/info`** — rather than a dedicated
endpoint; the UI needs one boolean, once per page load.
- **`WS /v1/dictation/stream`** — wire protocol (documented in the module
docstring, mirroring `terminal_attach.py`):
- **Client → server, binary frames**: raw 16 kHz mono s16le PCM.
- **Client → server, text frames**: JSON control messages.
`{"type": "stop"}` requests a flush; unknown shapes are ignored for
forward compatibility.
- **Server → client, text frames**: JSON events.
- `{"type": "ready"}` — sent once after accept; the client may start
streaming audio.
- `{"type": "partial", "text": ...}` — revisable in-progress utterance,
throttled to ~6 Hz.
- `{"type": "final", "text": ...}` — an utterance completed by
endpointing; the client appends it and clears the partial region.
- `{"type": "stopped", "text": ...}` — response to `stop`: the flushed
tail utterance (possibly empty). The server closes after sending it.
- `{"type": "error", "message": ...}` — fatal; server closes.
The route holds no session state; a connection is one dictation take.
## Web
### Capture — `web/src/lib/dictation.ts`
`DictationSession` owns the full client pipeline:
`getUserMedia({audio})``AudioContext``AudioWorkletNode` (the worklet,
inlined as a Blob module, downsamples from the context rate to 16 kHz and
converts Float32 → Int16, posting 100 ms chunks) → binary WS frames via
`resolveWebSocketUrl("/v1/dictation/stream")` (the same host seam the
terminal-attach and session-updates sockets ride, so embed hosts and the
Vite dev proxy keep working). Callbacks: `onPartial`, `onFinal`, `onError`;
`stop()` sends `{"type":"stop"}`, resolves with the flushed tail, and
releases the mic tracks and audio context.
Availability comes from the existing `/v1/info` capability context
(`useServerInfo().dictation_available`) — no extra request.
### Mic button — `ComposerMicButton.tsx`
Mode selection: **Web Speech when the browser has a working one, server
dictation otherwise** — no behavior change for Chrome/Safari users;
Electron, Firefox, and Chromium gain a working button. "Working" cannot be
detected statically: Electron and plain Chromium expose the
`SpeechRecognition` constructor but its cloud backend rejects them at
runtime with a `network` error. So Web Speech stays primary whenever the
constructor exists, and a take that dies with `network` falls back to the
server **for that take** (retried immediately, so the user's click still
lands); the next take tries Web Speech again, so a transient blip in real
Chrome never permanently downgrades the page. With no constructor at all
(Firefox), takes go to the server directly.
New optional prop `onInterim?: (text: string) => void`. In server mode the
button emits `onInterim` for partial frames and the existing
`onTranscript` for finals. Both composers (`ChatPage`, `NewChatDialog`)
share a small hook, `useDictationInsert(setValue)`, that appends finals and
maintains a replaceable trailing interim region in the textarea value, so
text forms live while speaking. When `onInterim` is absent (Web Speech
mode), behavior is exactly today's.
## Testing
- **Server (pytest, `tests/server/routes/test_dictation.py`)**: drive the
real route with `TestClient.websocket_connect` and a fake engine injected
through `engine_provider` — no sherpa dependency in CI. Cases:
`/v1/info` availability (with and without an engine), ready→partial→final
→stopped flow, stop-flush, auth rejection with a no-identity provider,
stream-cap rejection.
- **Engine unit tests** skip unless sherpa-onnx and models are present
(developer machines), keeping CI hermetic.
- **Web (Vitest, `ComposerMicButton.test.tsx` + `dictation.test.ts`)**:
mode selection, partial/final callback flow against a mocked WebSocket and
mocked AudioWorklet capture.
- **e2e (Playwright, `tests/e2e_ui/`)**: a fake engine selected via env
(`OMNIGENT_DICTATION_ENGINE=fake`, emits a scripted transcript) lets the
full browser→WS→server→composer loop run headless without a mic:
the test grants fake mic permissions, clicks the mic button, and asserts
the scripted text lands in the composer.
## Rollout / compatibility
- No schema changes, no migrations, no new required deps.
- Servers without the extra: `/v1/info` reports `dictation_available: false`;
the web UI behaves exactly as today.
- Old web clients against new servers: unaffected (new route + one new
`/v1/info` field only).
- New web clients against old servers: `/v1/info` lacks the field → treated
as unavailable → today's behavior.
+160 -27
View File
@@ -44,7 +44,8 @@ Key flags (`--help` for all): `--journeys A,B`, `--database-uri URI` (seeded
corpus / Postgres; default: throwaway empty SQLite), `--iterations N` (per
latency run), `--requests N` / `--concurrency N` (throughput), `--runs N`,
`--warmup N`, `--output FILE`, `--min-rps` / `--max-p50-ms` / `--max-p99-ms`
(CI thresholds).
(CI thresholds), `--network-delay-ms MS` (simulated client↔server latency,
see *Network* below).
## Journeys
@@ -59,6 +60,8 @@ latency run), `--requests N` / `--concurrency N` (throughput), `--runs N`,
| `search_sessions` | `GET /v1/sessions?search_query=` — unindexed `LIKE` | total item count |
| `fork_session` | `POST /v1/sessions/{id}/fork` — fork (deep-copy items); forks deleted in teardown, untimed | items/session |
| `add_comment` | `POST /v1/sessions/{id}/comments` — create a review comment | write path |
| `list_projects` | `GET /v1/sessions/projects` — sidebar project list (dual-read union) | project count |
| `list_project_sessions` | `GET /v1/sessions?project=` — a project folder's sessions (dual-read filter) | sessions/project |
Read journeys target a **pre-seeded** session when the DB has a corpus; against
an empty DB they self-seed a small fallback session over HTTP (the
@@ -81,23 +84,21 @@ drift negligible (~2 ms/turn).
| Journey | Operation timed |
| --- | --- |
| `session_cold_start` | Spawn a **fresh runner process**, wait for its tunnel, bind a session, and drive the first turn to `idle` — the full new-conversation cold path |
| `session_cold_start` | Create a new host-bound session and time its fresh runner launch through the first token — the full new-conversation cold path |
| `session_cold_restart` | With an existing session's runner stopped before the sample, post a user message and time the automatic runner relaunch to first token |
| `warm_turn` | Drive a turn on an already-warm session — steady-state dispatch overhead |
| `time_to_first_token` | Post a turn; time to the first streamed `output_text` delta |
| `interrupt` | Interrupt a running (gated) turn; time to cancellation |
| `read_runner_file` | `GET .../environments/default/filesystem/{path}` — server → runner filesystem read proxy |
**`session_cold_start` spawns a real runner.** The env spawns one runner at
boot, but the warm journeys reuse it — so `session_cold_start` instead spawns a
*fresh* runner subprocess per iteration and waits for its reverse tunnel to
register before binding and driving the turn. That captures the runner process
start + tunnel handshake that a real new conversation always pays (and that a
host-launched session pays on its first message), not just the sub-second
executor-construction + first-turn overhead. Each iteration terminates its
runner afterward, so at most one extra runner is ever live. Each spawned runner
mints its own binding token and derives its `runner_id` from it (so tunnel,
mint, and session binding all agree on one id) and registers over loopback,
exactly like the boot runner — a fully independent runner.
The two cold journeys use a real `omni host` daemon. `session_cold_start`
creates a new host-bound session per sample, while `session_cold_restart`
creates one session up front and sends `stop_session` before each sample. That
control event preserves the conversation but stops its runner; the timed user
message then follows the production auto-relaunch path. In both cases the host
spawns a fresh runner with its own binding token and reverse tunnel, so the
latency includes process startup, tunnel registration, and first-token
dispatch. The daemon reaps any remaining runners when the benchmark exits.
`read_runner_file` needs a runner but does **not** drive a turn or call the LLM:
its setup plants a file via `PUT`, and the timed op is the proxied read (a
@@ -115,6 +116,89 @@ dispatch/streaming/cancel overhead, not model latency.
Add a journey by registering a `Journey` in `journeys.py` (set `needs_runner`
for full-turn journeys).
## Network requests + simulated delay
Two related knobs for reasoning about **network cost** — the round-trips a
journey makes and what they'd cost over a real network, both of which loopback
otherwise hides.
### Requests-per-op (`http_requests` / `avg_http_requests_per_op`)
Every run reports how many HTTP requests **the server handled** during its
timed region, divided by successful ops → requests-per-op. This is the
deterministic, noise-free signal: a change that adds or removes a round-trip
moves the count directly, independent of timing jitter.
The value is the *server-side* count, not just what the benchmark process
issues — so for the full-turn (`needs_runner`) journeys it also captures the
cross-process traffic a client-side hook can't see (runner → server callbacks,
host → server). That's where the count is genuinely unknown and interesting; for
the HTTP/API journeys it's known by construction (`list_sessions` = 1,
`create_session` = 2 for the POST + inline DELETE, etc.).
How it works, and why it never ships in production:
- The server already tracks a cumulative request counter
(`ServerPerformanceMetrics.total_started`), but it lives in the server
subprocess's memory and is only pushed to OTel. The harness needs to *read*
it, so a tiny router (`debug_router.py`) exposes it at
`GET /debug/server-metrics`.
- That router lives under `dev/`, which `pyproject.toml` excludes from the wheel
(`include = ["omnigent*"]`) — a production install can't even import it.
- It's mounted only via the `debug_router_modules` config key, which mirrors the
existing `policy_modules` load-by-dotted-path seam (`create_app`
`_load_debug_routers`). The harness's generated `server.yaml` sets it;
production config never does. A module that fails to import is logged and
skipped, so a stray key is a no-op where `dev/` is absent.
- The harness (`environment.py`) reads the endpoint around each run's timed
region and diffs it (subtracting its own closing poll). Counting is
best-effort: if the endpoint is unreachable the run still reports latency,
just with `http_requests: null`.
**Per-route appendix (`network_routes`).** Beyond the single per-op number, the
endpoint also returns a per-route tally (keyed by the low-cardinality FastAPI
template, e.g. `POST /v1/sessions`), so each journey's `summary` carries a
`network_routes` breakdown — every endpoint the journey hit, its total request
count, and per-op count, sorted chattiest-first. Since the count is near
identical across runs, it's summed across the summary runs and grouped by route.
This is what makes the count *actionable*: for `session_cold_start` it names
which endpoints the ~12 requests/op are spread across (including the
cross-process runner→server / host→server calls), not just the total. The
harness's own counter-poll route is filtered out. The raw per-run map is in each
run's `route_requests`.
**Tunnel round-trips are not counted.** Steady-state server↔runner traffic is
frames multiplexed over one long-lived WebSocket tunnel, not fresh HTTP requests
— so neither this counter nor an HTTP hook sees them as "requests." Counting
tunnel frames would need instrumenting the tunnel transport; it's out of scope
for v1.
### Simulated network delay (`--network-delay-ms`)
Loopback has ~zero latency, so the benchmark can't tell a chatty journey (many
round-trips) from a lean one on wall-clock alone. `--network-delay-ms MS`
(default `0`) injects an artificial sleep before **every request the benchmark
client sends**, via an httpx request event hook — modelling a real client↔server
network hop. Combined with the per-op request count, `delay × requests-per-op`
is the wall-clock cost those round-trips add, so the two features reinforce each
other when testing a network optimization.
**Scope note (v1):** the delay models the **client↔server** hop only — the hop
the benchmark process owns. The cross-process server↔runner tunnel and
server→mock-LLM hops are *not* delayed (they'd need injecting sleep into the
runner's client / the tunnel transport, in separate processes). Documented
follow-up. The nightly and PR workflows run at `0` for stable, comparable trend
data; dispatch the workflow with a higher `network_delay_ms` when investigating
a network optimization.
**Mind the CI time budget.** The delay applies to *every* client→server
request, so it multiplies across the full-turn journeys' round-trips — a cold
start makes ~12 requests/op. A large delay across the whole default journey set
can exceed the workflow's 30-min per-leg timeout (empirically, with the older
poll-based turn driver `network_delay_ms=100` over all journeys timed out; `10`
finishes in ~6 min). For a bigger delay, pair it with a `--journeys` subset of
the HTTP journeys, where the count is 12/op.
## Seeding a realistic corpus
`seed.py` writes a sizeable, deterministic corpus directly through the store
@@ -123,17 +207,26 @@ API (no HTTP, no runner) into the same DB the server then boots against:
```bash
# Seed 5000 sessions × 50 items into a SQLite file, then benchmark against it.
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri sqlite:////abs/path/bench.db --sessions 5000 --items-per-session 50
--database-uri sqlite:////abs/path/bench.db --sessions 5000 --items-per-session 50 \
--projects 20 --filed-fraction 0.5
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri sqlite:////abs/path/bench.db --output bench.json
```
Seeding is **idempotent**: a matching corpus (same sessions/items/schema) is
detected and reused, so re-running is a fast no-op — pass `--reseed` to force,
or a differing config to be warned. SQLite absolute paths need four slashes
(`sqlite:////abs/...`). The reuse marker records the DB's Alembic head read at
seed time, so a corpus from an older schema is automatically reseeded — no
manual revision bookkeeping. `test_seed_creates_listable_corpus` (which seeds
The corpus also seeds **first-class projects** and files a fraction of sessions
into them, so `list_projects` / `list_project_sessions` measure a realistic
sidebar instead of an empty project set. `--projects N` sets the folder count
(0 = none) and `--filed-fraction F` the fraction of sessions filed (round-robin
across the folders); the defaults (20 projects, 0.5) put ~1/40th of the corpus
in each folder. Projects are owned by the reserved `"local"` user the loopback
server resolves to, so the owner-scoped project reads see them.
Seeding is **idempotent**: a matching corpus (same sessions/items/projects/
schema) is detected and reused, so re-running is a fast no-op — pass `--reseed`
to force, or a differing config to be warned. SQLite absolute paths need four
slashes (`sqlite:////abs/...`). The reuse marker records the DB's Alembic head
read at seed time, so a corpus from an older schema is automatically reseeded —
no manual revision bookkeeping. `test_seed_creates_listable_corpus` (which seeds
through the store, running migrations to the current head) is the safety net
that a schema change hasn't broken seeding.
@@ -175,7 +268,7 @@ document without running the harness.
```jsonc
{
"schema_version": 2,
"schema_version": 6,
"generated_at": "<ISO-8601 UTC>",
"git_sha": "<HEAD sha>",
"git_branch": "<branch>",
@@ -183,7 +276,7 @@ document without running the harness.
"harness": "http-only",
"config": {"iterations": 100, "requests": 500, "concurrency": 1,
"runs": 3, "warmup": 10, "with_runner": false,
"backend": "sqlite"},
"backend": "sqlite", "network_delay_ms": 0.0},
"journeys": {
"<journey name>": {
"kind": "latency" | "throughput",
@@ -192,19 +285,47 @@ document without running the harness.
"runs": [ // one per --runs
{"n_success": N, "n_failures": N, "failures": {"HTTP 500": 1},
"wall_time_s": , "mean_ms": , "p50_ms": , "p95_ms": ,
"p99_ms": , "max_ms": , "rps": }
"p99_ms": , "max_ms": , "rps": ,
"http_requests": N, // server HTTP requests during the timed region; null if uncounted
"http_requests_per_op": , // http_requests / n_success; null if uncounted
"route_requests": {"POST /v1/sessions": N, ...}} // per-route breakdown; {} if uncounted
],
"summary": {"avg_mean_ms": , "avg_p50_ms": , "avg_p95_ms": ,
"avg_p99_ms": , "avg_rps": } // averaged across runs
"summary": {"runs_total": 3, "runs_ok": 3, // how many runs the averages cover
"avg_mean_ms": , "avg_p50_ms": , "avg_p95_ms": ,
"avg_p99_ms": , "avg_rps": , // averaged over the runs_ok runs
"avg_http_requests_per_op": , // present only when a run was counted
"network_routes": [ // per-route appendix, sorted by per_op desc
{"route": "POST /v1/sessions", "requests": N, "per_op": }
]} // present only when a run recorded routes
}
// A journey that errored out of measurement entirely instead carries:
// {"kind", "backend", "needs_runner", "runs": [], "summary": {},
// "skipped": true, "error": "HTTPStatusError: ..."}
}
}
```
The `http_requests*` / `route_requests` / `network_routes` fields are the
server-side request count and its per-endpoint breakdown (see *Network* above);
`network_delay_ms` records the simulated client↔server latency the run used.
The per-journey `summary` + `runs` shape mirrors MLflow's gateway benchmark, so
the same ETL flatten works — keyed by `journey` and `backend`. Bump
`SCHEMA_VERSION` on any breaking shape change so the notebook can branch on it.
**Failures never abort the run.** A per-operation error is recorded in that
run's `failures` breakdown (keyed `HTTP 500` etc.); a run in which *every*
operation failed keeps its per-run row but is excluded from the `summary`
averages (`runs_ok` < `runs_total`) so a failed run can't masquerade as an
infinitely fast one. A journey whose `setup` fails (e.g. a 500 resolving a
target session — the exact crash this harness used to die on) records a single
`setup: HTTP 500` failed run and moves on. Any other unexpected per-journey
error is caught in `run.py`, recorded as `"skipped": true` with the `error`
string, and the remaining journeys still run. Skips/all-failed journeys are
non-fatal on their own, but if any CI threshold (`--max-p50-ms` etc.) is
supplied, a journey with no successful sample fails the gate — the guarantee
couldn't be verified.
## Layout
| File | Role |
@@ -212,9 +333,10 @@ the same ETL flatten works — keyed by `journey` and `backend`. Bump
| `run.py` | CLI orchestrator + entrypoint |
| `seed.py` | deterministic corpus seeder (store API) |
| `journeys.py` | `Journey` dataclass, latency/throughput runners, registry |
| `environment.py` | server (± runner + mock LLM) lifecycle; `--database-uri` |
| `environment.py` | server (± runner + mock LLM) lifecycle; `--database-uri`; request-count read + network-delay hook |
| `measure.py` | `RunResult`, percentile, aggregation, thresholds, tables |
| `schema.py` | `SCHEMA_VERSION`, `build_report`, git/host metadata |
| `debug_router.py` | CI-only `GET /debug/server-metrics` plugin router (never shipped in the wheel) |
| `sample_output.json` | committed example of the JSON contract |
The smoke test is `tests/benchmarks/test_benchmark_smoke.py` (boots the server
@@ -260,4 +382,15 @@ seeding.
- **Simulated provider latency.** The mock LLM returns at ~zero latency, which
is what isolates omnigent overhead. A fixed per-response delay knob would let
turns model end-user wall-clock instead; it's a small change behind the
`configure_mock` / `set_mock_fallback` seam if that's ever wanted.
`configure_mock` / `set_mock_fallback` seam if that's ever wanted. (Distinct
from `--network-delay-ms`, which models the *client↔server* hop — see
*Network* above.)
- **Wider network-delay coverage.** `--network-delay-ms` v1 delays only the
client↔server hop (the one the benchmark process owns). Extending it to the
server↔runner tunnel and server→mock-LLM hops would need injecting the delay
into the runner's httpx client and the tunnel transport in their own
processes.
- **Tunnel round-trip counting.** `http_requests` counts HTTP the server
handles, not frames on the persistent server↔runner WebSocket tunnel.
Counting those (for a true per-turn round-trip figure) would mean
instrumenting the tunnel transport's `RequestFrame` dispatch.
+59 -5
View File
@@ -33,6 +33,24 @@ def _fmt_delta(v: float | None) -> str:
return f"{sign}{v * 100:.1f}%"
def _fmt_req(row: dict) -> str:
"""Format a journey's requests-per-op as ``base→cand`` (or a single value).
``—`` when neither side counted; a bare value when only one side has it
(a new journey, or a baseline predating request counting). A change in the
count means a round-trip was added or removed — a deterministic signal
independent of the latency deltas.
"""
b, c = row.get("b_req"), row.get("c_req")
if b is None and c is None:
return ""
if b is None:
return f"{c:.1f}"
if c is None:
return f"{b:.1f}"
return f"{b:.1f}" if b == c else f"{b:.1f}{c:.1f}"
def compare_reports(
baseline: dict,
candidate: dict,
@@ -60,6 +78,28 @@ def compare_reports(
c_p50 = c_summary.get("avg_p50_ms")
c_p95 = c_summary.get("avg_p95_ms")
# A skipped journey (or one whose runs all failed) carries no metric
# keys. Report it as its own status instead of computing a delta off a
# missing value (which would read as a spurious -100% improvement).
c_req = c_summary.get("avg_http_requests_per_op")
if c_p50 is None:
b_j_summary = baseline_journeys.get(name, {}).get("summary", {})
rows.append(
{
"journey": name,
"status": "skipped",
"b_p50": b_j_summary.get("avg_p50_ms"),
"c_p50": None,
"b_p95": b_j_summary.get("avg_p95_ms"),
"c_p95": None,
"delta_p50": None,
"delta_p95": None,
"b_req": b_j_summary.get("avg_http_requests_per_op"),
"c_req": c_req,
}
)
continue
if name not in baseline_journeys:
rows.append(
{
@@ -71,6 +111,8 @@ def compare_reports(
"c_p95": c_p95,
"delta_p50": None,
"delta_p95": None,
"b_req": None,
"c_req": c_req,
}
)
continue
@@ -88,6 +130,8 @@ def compare_reports(
"c_p95": c_p95,
"delta_p50": None,
"delta_p95": None,
"b_req": None,
"c_req": c_req,
}
)
continue
@@ -95,6 +139,7 @@ def compare_reports(
b_summary = b_data.get("summary", {})
b_p50 = b_summary.get("avg_p50_ms", 0.0)
b_p95 = b_summary.get("avg_p95_ms", 0.0)
b_req = b_summary.get("avg_http_requests_per_op")
c_p50 = c_p50 or 0.0
c_p95 = c_p95 or 0.0
@@ -115,6 +160,8 @@ def compare_reports(
"b_p95": b_p95,
"c_p95": c_p95,
"delta_p95": delta_p95,
"b_req": b_req,
"c_req": c_req,
}
)
@@ -122,7 +169,7 @@ def compare_reports(
def _status_style(status: str) -> str:
return {"regression": "red", "new": "cyan", "ok": "green"}.get(status, "")
return {"regression": "red", "new": "cyan", "ok": "green", "skipped": "yellow"}.get(status, "")
def print_table(rows: list[dict], threshold: float) -> None:
@@ -143,6 +190,7 @@ def print_table(rows: list[dict], threshold: float) -> None:
table.add_column("Base P95 ms", justify="right")
table.add_column("Cand P95 ms", justify="right")
table.add_column("Δ P95", justify="right")
table.add_column("Req/op", justify="right")
for row in rows:
style = _status_style(row["status"])
@@ -164,6 +212,7 @@ def print_table(rows: list[dict], threshold: float) -> None:
_fmt_ms(row["b_p95"]),
_fmt_ms(row["c_p95"]),
delta_p95_str,
_fmt_req(row),
)
console.print()
@@ -179,13 +228,13 @@ def build_markdown(rows: list[dict], threshold: float, passed: bool) -> str:
f"Regression threshold: **{threshold * 100:.0f}%** on avg P50 or avg P95.",
"",
"| Journey | Status | Base P50 ms | Cand P50 ms | Δ P50"
" | Base P95 ms | Cand P95 ms | Δ P95 |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
" | Base P95 ms | Cand P95 ms | Δ P95 | Req/op |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
]
for row in rows:
status = row["status"]
emoji = {"regression": "🔴", "new": "🆕", "ok": ""}.get(status, status)
emoji = {"regression": "🔴", "new": "🆕", "ok": "", "skipped": "⚠️"}.get(status, status)
b_p50 = _fmt_ms(row["b_p50"])
c_p50 = _fmt_ms(row["c_p50"])
d_p50 = _fmt_delta(row["delta_p50"])
@@ -195,7 +244,7 @@ def build_markdown(rows: list[dict], threshold: float, passed: bool) -> str:
lines.append(
f"| {row['journey']} | {emoji} {status} "
f"| {b_p50} | {c_p50} | {d_p50} "
f"| {b_p95} | {c_p95} | {d_p95} |"
f"| {b_p95} | {c_p95} | {d_p95} | {_fmt_req(row)} |"
)
lines.append("")
@@ -252,11 +301,16 @@ def main(argv: list[str] | None = None) -> int:
regressions = [r for r in rows if r["status"] == "regression"]
new_journeys = [r for r in rows if r["status"] == "new"]
skipped = [r for r in rows if r["status"] == "skipped"]
if new_journeys:
names = ", ".join(r["journey"] for r in new_journeys)
console.print(f"[cyan]New journeys (no baseline):[/cyan] {names}")
if skipped:
names = ", ".join(r["journey"] for r in skipped)
console.print(f"[yellow]Skipped (no candidate metrics):[/yellow] {names}")
if regressions:
console.print(
f"[red bold]REGRESSION DETECTED[/red bold] in "
+62
View File
@@ -0,0 +1,62 @@
"""CI-only debug router exposing the server's request counter over HTTP.
The benchmark harness runs the server as a subprocess, so it cannot read the
in-process ``ServerPerformanceMetrics`` counter directly. This router surfaces
that counter on ``GET /debug/server-metrics`` so the harness can diff it around
each journey's timed region and report requests-per-op — including the
cross-process traffic (runner → server callbacks, host → server) that a
client-side hook in the benchmark process can't see.
**This never reaches the production server.** Three independent reasons:
1. The module lives under ``dev/``, which ``pyproject.toml`` excludes from the
wheel (``include = ["omnigent*"]``), so a production install cannot import it.
2. It is loaded only via the ``debug_router_modules`` config key, which the
benchmark's generated ``server.yaml`` sets and production config never does.
3. ``create_app`` loads the module tolerantly — an ``ImportError`` logs a
warning and skips, so even a stray config key is a no-op where ``dev/`` is
absent.
``create_app`` reads the module-level ``DEBUG_ROUTERS`` list (mirroring the
``POLICY_REGISTRY`` convention) and mounts each ``(router, prefix, tags)`` entry.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/server-metrics")
async def server_metrics(request: Request) -> dict[str, object]:
"""Return the server's cumulative HTTP request counters.
Reads the process-local :class:`ServerPerformanceMetrics` tracker stashed on
``app.state.server_metrics``. The counters are monotonic since process
start; the harness diffs two reads to get a journey's request volume.
``route_counts`` additionally breaks the total down by low-cardinality
route template (``"METHOD /v1/sessions/{session_id}"`` → count), letting the
harness attribute a journey's requests to specific endpoints — including the
cross-process runner → server / host → server calls a client-side hook can't
see.
:param request: Incoming request, used to reach ``app.state``.
:returns: ``total_started`` / ``total_completed`` / ``total_failed`` /
``in_flight`` plus a ``route_counts`` map from the current metrics.
"""
metrics = request.app.state.server_metrics
snapshot = metrics.snapshot()
return {
"total_started": snapshot.total_started,
"total_completed": snapshot.total_completed,
"total_failed": snapshot.total_failed,
"in_flight": snapshot.in_flight,
"route_counts": metrics.route_counts(),
}
# Consumed by ``create_app(debug_router_modules=...)``: each entry is mounted as
# ``app.include_router(router, prefix=prefix, tags=tags)``.
DEBUG_ROUTERS: list[tuple[APIRouter, str, list[str]]] = [(router, "/debug", ["debug"])]
+301 -56
View File
@@ -23,16 +23,18 @@ from __future__ import annotations
import asyncio
import contextlib
import io
import json
import os
import signal
import socket
import subprocess
import sys
import tarfile
import threading
import time
import uuid
from pathlib import Path
from typing import IO
from typing import IO, NamedTuple
import httpx
import yaml
@@ -55,10 +57,12 @@ _HEALTH_TIMEOUT_S = 90.0
_MOCK_TIMEOUT_S = 15.0
_POLL_INTERVAL_S = 0.2
_TURN_TIMEOUT_S = 180.0
# Budget for the host daemon (session_cold_start journey, with_host) to connect
# its tunnel and register in the hosts table after being spawned. Covers
# Budget for the host daemon (host-backed cold journeys) to connect its tunnel
# and register in the hosts table after being spawned. Covers
# interpreter start + imports + the reverse-tunnel handshake.
_HOST_ONLINE_TIMEOUT_S = 60.0
# Budget for a host-owned runner tunnel to disappear after ``stop_session``.
_RUNNER_OFFLINE_TIMEOUT_S = 30.0
# Terminal SSE events — if one arrives before any delta, the turn produced no
# streamed text (a failure for the TTFT journey).
@@ -81,6 +85,49 @@ _DEFAULT_HARNESS = "openai-agents"
_POLICY_LLM_KEY = "_policy_llm_"
_POLICY_ALLOW = '{"action": "allow", "reason": ""}'
# Dotted path to the CI-only debug router (under ``dev/``, never shipped in the
# wheel). The server loads it via the ``debug_router_modules`` config key to
# expose ``GET /debug/server-metrics`` for per-journey request counting.
_DEBUG_ROUTER_MODULE = "dev.benchmarks.omnigent.debug_router"
_SERVER_METRICS_PATH = "/debug/server-metrics"
class ServerRequestSnapshot(NamedTuple):
"""A point-in-time read of the server's cumulative request counters.
:param total: ``total_started`` — every HTTP request the server has handled
since process start.
:param routes: ``"METHOD /route/{template}"`` mapped to its cumulative
count, for attributing a journey's requests to specific endpoints.
"""
total: int
routes: dict[str, int]
def _sse_session_status(data: str) -> str | None:
"""Extract the status from a ``session.status`` SSE ``data:`` payload.
Returns the status string (``"running"`` / ``"waiting"`` / ``"idle"`` /
``"failed"``) for a ``session.status`` event, else ``None`` (a different
event, the ``[DONE]`` sentinel, or unparseable JSON). Tolerates both the
nested ``{"data": {"status": ...}}`` and flat ``{"status": ...}`` shapes.
:param data: The SSE ``data:`` line body, already stripped of the prefix.
:returns: The session status, or ``None`` when this isn't a status event.
"""
if not data or data == "[DONE]":
return None
try:
payload = json.loads(data)
except (ValueError, TypeError):
return None
if not isinstance(payload, dict) or payload.get("type") != "session.status":
return None
inner = payload.get("data")
status = inner.get("status") if isinstance(inner, dict) else payload.get("status")
return status if isinstance(status, str) else None
def _find_free_port() -> int:
"""Bind an ephemeral port and return it (races are tolerated by retries)."""
@@ -112,9 +159,8 @@ class BenchEnvironment:
:param with_host: When ``True`` (implies ``with_runner``), additionally
spawn a real ``omnigent host`` daemon. Additive over ``with_runner``:
the boot runner still serves the warm journeys, while the daemon lets
the ``session_cold_start`` journey create host-bound sessions that fire
``host.launch_runner`` and launch their OWN fresh runner — so the first
message races the runner's boot, reproducing the true UI cold path.
the cold-start and cold-restart journeys use host-bound sessions that
fire ``host.launch_runner`` and launch their own fresh runners.
:param database_uri: SQLAlchemy URI the server boots against. ``None``
(default) uses a fresh throwaway SQLite file in the temp dir — the
empty-DB path. Pass a pre-seeded URI (e.g. a seeded SQLite file, or a
@@ -124,6 +170,11 @@ class BenchEnvironment:
:param harness: Harness for full-turn agents when ``with_runner`` (default
``openai-agents``, a base dependency needing no vendor CLI binary).
:param model: Model string baked into registered agent specs.
:param network_delay_ms: Artificial latency in milliseconds injected before
every request the benchmark client sends to the server, modelling a
real client↔server network hop that loopback lacks. ``0`` (default)
adds no delay. Combined with per-op request counts, it turns each
journey's round-trip count into wall-clock cost.
"""
def __init__(
@@ -134,6 +185,7 @@ class BenchEnvironment:
database_uri: str | None = None,
harness: str = _DEFAULT_HARNESS,
model: str = _DEFAULT_MODEL,
network_delay_ms: float = 0.0,
) -> None:
# with_host is additive over with_runner: the boot runner still serves
# the warm journeys, and the host daemon additionally lets the cold-start
@@ -143,6 +195,7 @@ class BenchEnvironment:
self.database_uri = database_uri
self.harness = harness
self.model = model
self.network_delay_ms = network_delay_ms
self.base_url = ""
self.mock_url = ""
self.runner_id = ""
@@ -160,16 +213,34 @@ class BenchEnvironment:
self._runner_base_env: dict[str, str] = {}
self._log_handles: list[IO[bytes]] = []
self._agent_cache: dict[str, str] = {}
self._resource_samples: list[dict[str, float]] = []
self._sampler_stop: threading.Event = threading.Event()
self._sampler_thread: threading.Thread | None = None
# ── lifecycle ────────────────────────────────────────────
async def __aenter__(self) -> BenchEnvironment:
await asyncio.to_thread(self._start)
# A request event hook injects the simulated client↔server network
# delay before each request leaves the benchmark process. Registered
# only when a delay is set so the zero-delay default path is untouched.
event_hooks: dict[str, list[object]] = {}
if self.network_delay_ms > 0:
delay_s = self.network_delay_ms / 1000.0
async def _delay_request(_request: httpx.Request) -> None:
await asyncio.sleep(delay_s)
event_hooks["request"] = [_delay_request]
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=300.0,
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
event_hooks=event_hooks, # type: ignore[arg-type]
)
# Start background resource sampler (server CPU + memory).
self._sampler_thread = threading.Thread(target=self._sample_resources, daemon=True)
self._sampler_thread.start()
if self.with_runner:
# ALLOW fallback so a server-side classifier call resolves against
# the mock (never api.openai.com) and returns a valid verdict.
@@ -181,6 +252,9 @@ class BenchEnvironment:
async def __aexit__(self, *exc: object) -> None:
if self.client is not None:
await self.client.aclose()
self._sampler_stop.set()
if self._sampler_thread is not None:
self._sampler_thread.join(timeout=5)
await asyncio.to_thread(self._stop)
def _start(self) -> None:
@@ -216,9 +290,8 @@ class BenchEnvironment:
self._runner_proc = self._spawn_runner(base_env, binding_token)
self._wait_ready()
# The host daemon is ADDITIVE — the boot runner above still serves the
# warm journeys; the daemon exists so the cold-start journey can create
# host-bound sessions that launch their OWN fresh runners on demand
# (the race the cold path measures). The two never share a runner id.
# warm journeys; the daemon exists so host-backed cold journeys can
# launch their own runners on demand. The two never share a runner id.
if self.with_host:
self._host_proc = self._spawn_host(base_env)
self._wait_host_online()
@@ -247,6 +320,98 @@ class BenchEnvironment:
shutil.rmtree(self._tmp, ignore_errors=True)
def _sample_resources(self, interval: float = 1.0) -> None:
"""Sample the server process's CPU and RSS memory at *interval*-second intervals.
Runs in a daemon thread; exits when ``_sampler_stop`` is set or the
process terminates. The first ``cpu_percent`` call always returns 0.0
(psutil baseline) — we discard it so only real measurements accumulate.
"""
try:
import psutil
except ImportError:
return
if self._server_proc is None:
return
try:
proc = psutil.Process(self._server_proc.pid)
proc.cpu_percent() # baseline; discard
except psutil.NoSuchProcess:
return
while not self._sampler_stop.is_set():
try:
cpu = proc.cpu_percent()
mem = proc.memory_info().rss
self._resource_samples.append({"cpu_pct": cpu, "rss_bytes": mem})
except psutil.NoSuchProcess:
break
self._sampler_stop.wait(timeout=interval)
@property
def resource_usage(self) -> dict[str, object]:
"""Summarise sampled CPU% and RSS across the benchmark run.
:returns: A dict with ``cpu_pct`` and ``rss_bytes`` sub-dicts each
containing ``mean``, ``min``, ``max``, ``samples``. Empty dicts
when no samples were collected (psutil unavailable or server never
started).
"""
if not self._resource_samples:
return {"cpu_pct": {}, "rss_bytes": {}}
cpu = [s["cpu_pct"] for s in self._resource_samples]
rss = [s["rss_bytes"] for s in self._resource_samples]
import statistics as _stats
return {
"cpu_pct": {
"mean": _stats.mean(cpu),
"min": min(cpu),
"max": max(cpu),
"samples": len(cpu),
},
"rss_bytes": {
"mean": _stats.mean(rss),
"min": min(rss),
"max": max(rss),
"samples": len(rss),
},
}
async def server_request_snapshot(self) -> ServerRequestSnapshot:
"""Read the server's cumulative request counters (total + per-route).
Reads the CI-only ``GET /debug/server-metrics`` endpoint the server
mounts from ``debug_router_modules``. The counters are monotonic since
the server process started; callers diff two reads to get the requests
the server handled during a window — including cross-process traffic
(runner → server callbacks, host → server) invisible to a client-side
hook in the benchmark process.
The count-poll request itself hits the server, so it increments the
counters; the caller accounts for its own polls when computing per-op
volume. Uses a short-lived client so it never carries the simulated
network delay wired onto ``self.client``.
:returns: A :class:`ServerRequestSnapshot` of ``total_started`` and the
per-route breakdown.
:raises RuntimeError: If the debug endpoint is unreachable, which means
the debug router did not load (a benchmark misconfiguration).
"""
try:
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
resp = await client.get(_SERVER_METRICS_PATH)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise RuntimeError(
f"server debug metrics endpoint {_SERVER_METRICS_PATH} unreachable "
f"({exc!r}); is the debug router loaded? logs in {self._tmp}"
) from exc
body = resp.json()
return ServerRequestSnapshot(
total=int(body["total_started"]),
routes={str(k): int(v) for k, v in body.get("route_counts", {}).items()},
)
# ── spawns ───────────────────────────────────────────────
def _log(self, name: str) -> IO[bytes]:
@@ -284,27 +449,24 @@ class BenchEnvironment:
str(artifact_dir),
]
env = {**base_env}
# The server always boots with a config that loads the CI-only debug
# router (exposing its request counter over HTTP for per-journey network
# counting). In runner mode it additionally routes the server-side
# policy-classifier LLM at the mock, mirroring live_server — without
# that the classifier's client defaults to api.openai.com and errors.
server_config: dict[str, object] = {"debug_router_modules": [_DEBUG_ROUTER_MODULE]}
if self.with_runner:
# Route the server-side policy-classifier LLM at the mock, mirroring
# live_server. Without this the classifier's client defaults to
# api.openai.com and errors. Server-only mode needs no llm config —
# the classifier only builds under OMNIGENT_SMART_ROUTING=1.
server_cfg = self._tmp / "server.yaml"
server_cfg.write_text(
yaml.safe_dump(
{
"llm": {
"model": _POLICY_LLM_KEY,
"connection": {
"base_url": f"{self.mock_url}/v1",
"api_key": "mock-key",
},
}
}
)
)
args.extend(["--config", str(server_cfg)])
server_config["llm"] = {
"model": _POLICY_LLM_KEY,
"connection": {
"base_url": f"{self.mock_url}/v1",
"api_key": "mock-key",
},
}
env["OMNIGENT_RUNNER_TUNNEL_TOKEN"] = binding_token
server_cfg = self._tmp / "server.yaml"
server_cfg.write_text(yaml.safe_dump(server_config))
args.extend(["--config", str(server_cfg)])
return subprocess.Popen(
args,
env=env,
@@ -340,10 +502,8 @@ class BenchEnvironment:
) -> subprocess.Popen[bytes]:
"""Spawn one runner subprocess under *runner_id* + *binding_token*.
Factored out of :meth:`_spawn_runner` so the ``session_cold_start``
journey can spawn additional runners on demand, each under its own id,
binding token, and workspace. The caller must pair *runner_id* with the token
it derives from (``token_bound_runner_id(binding_token)``): the runner
The caller must pair *runner_id* with the token it derives from
(``token_bound_runner_id(binding_token)``): the runner
derives its managed-mint URL from the token internally, so a mismatch
would register the tunnel under one id but mint under another (→ 401).
"""
@@ -640,6 +800,33 @@ class BenchEnvironment:
bound.raise_for_status()
return session_id
async def stop_session_runner(
self, session_id: str, *, timeout: float = _RUNNER_OFFLINE_TIMEOUT_S
) -> None:
"""Stop a host-backed session's runner and wait until it is offline.
``stop_session`` preserves the conversation and its host binding. The
next user message therefore exercises the production auto-relaunch
path instead of starting a new conversation.
"""
assert self.client is not None
if not self.with_host:
raise RuntimeError("stop_session_runner requires with_host=True")
stopped = await self.client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "stop_session", "data": {}},
)
stopped.raise_for_status()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
if snap.json().get("runner_online") is False:
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"runner did not stop within {timeout}s (session {session_id})")
async def write_runner_file(self, session_id: str, relative_path: str, content: str) -> None:
"""Write a file into the runner's default environment over HTTP.
@@ -677,7 +864,15 @@ class BenchEnvironment:
async def drive_turn(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post a user message and poll the session to a terminal state.
"""Post a user message and await the turn's completion over SSE.
Subscribes to the session stream, posts the message, then returns when a
``session.status`` event reports ``idle`` *after* the turn has been seen
``running``/``waiting`` — the SSE equivalent of the old poll-to-idle
loop, but without hammering ``GET /v1/sessions/{id}`` every 200ms (which
polluted the per-journey request count, especially when a turn stalled).
The ``seen_running`` guard ensures a warm session's trailing ``idle``
from a *prior* turn can't end this wait early.
:raises RuntimeError: If not in runner mode, the turn fails, or it does
not settle within *timeout* seconds.
@@ -685,27 +880,64 @@ class BenchEnvironment:
assert self.client is not None
if not self.with_runner:
raise RuntimeError("drive_turn requires with_runner=True")
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
deadline = time.monotonic() + timeout
seen_running = False
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
status = snap.json().get("status")
if status in ("running", "waiting"):
seen_running = True
elif status == "failed":
raise RuntimeError(f"turn failed: {snap.json().get('last_task_error')}")
elif status == "idle" and seen_running:
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"turn did not settle within {timeout}s (session {session_id})")
connected = asyncio.Event()
settled = asyncio.Event()
outcome: dict[str, str] = {}
async def _read_stream() -> None:
seen_running = False
try:
async with self.client.stream( # type: ignore[union-attr]
"GET", f"/v1/sessions/{session_id}/stream", timeout=timeout
) as resp:
# First line means the stream is live (server emits a ready
# heartbeat on connect); post only once subscribed so no
# status event for this turn can be missed.
connected.set()
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
status = _sse_session_status(line[len("data:") :].strip())
if status is None:
continue
if status in ("running", "waiting"):
seen_running = True
elif status == "failed":
outcome["failed"] = "turn failed"
settled.set()
return
elif status == "idle" and seen_running:
settled.set()
return
except httpx.HTTPError as exc:
outcome["error"] = repr(exc)
connected.set()
settled.set()
reader = asyncio.create_task(_read_stream())
try:
await asyncio.wait_for(connected.wait(), timeout=timeout)
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
try:
await asyncio.wait_for(settled.wait(), timeout=timeout)
except TimeoutError as exc:
raise RuntimeError(
f"turn did not settle within {timeout}s (session {session_id})"
) from exc
if "error" in outcome:
raise RuntimeError(f"stream error: {outcome['error']}")
if "failed" in outcome:
snap = await self.client.get(f"/v1/sessions/{session_id}")
last_error = snap.json().get("last_task_error") if snap.is_success else None
raise RuntimeError(f"turn failed: {last_error}")
finally:
reader.cancel()
async def _wait_idle(self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S) -> None:
"""Poll until the session is ``idle`` (a prior turn has settled)."""
@@ -742,9 +974,9 @@ class BenchEnvironment:
:param wait_idle_first: When ``True``, wait for the session to be ``idle``
before subscribing so a prior turn's terminal event can't race this
turn's response (warm-session TTFT). ``False`` for a fresh session whose
first turn is the only one the cold path, where the timed span must
include runner launch + connect, so we must NOT poll it warm first.
turn's response (warm-session TTFT). ``False`` for a fresh session or
a stopped existing session — cold paths where polling for ``idle``
would either warm the runner or wait forever on its disconnected state.
:raises RuntimeError: If not in runner mode, or no response / a terminal
event arrives within *timeout*.
"""
@@ -845,6 +1077,19 @@ class BenchEnvironment:
session_id, text, wait_idle_first=True, timeout=timeout
)
async def cold_restart_first_delta(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post to a stopped existing session and await its first response.
A stopped session is marked failed rather than idle, so this deliberately
skips the warm-session idle poll. The POST itself triggers the host runner
relaunch whose latency this path measures.
"""
await self._post_and_await_first_delta(
session_id, text, wait_idle_first=False, timeout=timeout
)
async def cold_start_first_delta(
self, agent_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:

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