Compare commits

...

101 Commits

Author SHA1 Message Date
Daniel Lok 7b1fe038f5 chore(openapi): regenerate spec against latest main
Rebased onto current main, which added new routes. Regenerated the spec
to cover them:
- POST /v1/sessions/{session_id}/hooks/antigravity-elicitation-request
- POST /v1/sessions/{session_id}/hooks/native-permission-request
- GET/POST  /v1/sessions/{session_id}/agent/mcp-servers
- PUT/DELETE /v1/sessions/{session_id}/agent/mcp-servers/{server_name}

The MCP routes carry a new `session_mcp_servers` tag, so add a matching
_TAGS entry ("Session MCP Servers", placed after Session Resources) with
a display name and description — otherwise the reference would render a
raw, undescribed snake_case group (the latent gap Polly flagged).

Spec is the output of `python scripts/dump_openapi.py`; drift test
passes and the zero-reST invariant holds.

Co-authored-by: Isaac
2026-06-25 15:42:52 +08:00
Daniel Lok 37c5ffa67d fix(openapi): clarify conditional session cookie name and _TAGS scope
Address Polly review notes on the OpenAPI enrichment:

- The session cookie is `__Host-ap_session` only under HTTPS
  (secure_cookies); on plain HTTP it is `ap_session`. Since the sole
  advertised server is http://127.0.0.1:6767, name the sessionCookieAuth
  scheme `ap_session` to match and document the HTTPS-prefixed variant in
  both the scheme description and info.description.
- Note in a comment that _TAGS intentionally covers only the stub-build
  surface emitted by generate_spec() (terminals is WebSocket-only; auth
  is absent unless a login_url provider is configured), so a future HTTP
  route there gets a tag rather than silently rendering undescribed.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 065dbbe357 feat(openapi): give session-list endpoints typed item schemas
GET /v1/sessions and .../child_sessions pointed their 200 schema at the
shared PaginatedList, whose `data` is `list[Any]` (it is reused across
endpoints with heterogeneous item types) — so the rendered reference
example showed an unhelpful empty `data: []`.

Add typed paginated models mirroring the existing
SessionResourcePaginatedList: SessionList (`data: list[SessionListItem]`)
and ChildSessionList (`data: list[ChildSessionSummary]`), and point the
two endpoints at them via responses={200: {"model": ...}} (response_model
stays None — no runtime change). The reference now renders a populated
SessionListItem / ChildSessionSummary example, and both item models are
materialized into components.schemas.

list_session_items keeps PaginatedList: its items are a heterogeneous
transcript union with no single concrete model.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 746836cce6 feat(openapi): convert reST in schema/model docstrings, not just operations
The first reST→Markdown pass only handled operation descriptions, so
Pydantic model docstrings still leaked raw `:param:` field lists into
`components.schemas.*.description` (e.g. Delete Session → ConversationDeleted
rendered ":param id: ... :param object: ..." as literal text).

Generalize the conversion:
- extract a shared parser/rebuilder (`_parse_rst_doc` / `_reformat_doc`);
- reformat every component schema recursively, moving each `:param name:`
  onto the matching `properties[name].description`;
- reformat response descriptions too;
- add a final pass normalizing inline `:role:`X`` roles and `` ``literal`` ``
  spans across all remaining descriptions (responses, info, tags, security);
- flatten multi-line `` ``...`` `` literals containing nested backticks into
  one valid Markdown code span.

Verified: zero residual reST markers anywhere in the spec; ruff clean;
drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 5016a43aff feat(openapi): render reST docstrings as Markdown in the reference
FastAPI uses each route handler's docstring verbatim as the operation
description, but our docstrings are Sphinx/reST — `:param:` / `:returns:`
/ `:raises:` field lists and inline `:class:`Foo`` roles. Docs renderers
(Scalar) treat the description as Markdown, so the field lists collapsed
into one unreadable run of literal `:param x:` text.

Add a post-processing pass in dump_openapi.py that converts each
operation's reST docstring to Markdown:
- `:param name:` whose name matches a query/path parameter is moved onto
  that parameter's description (renders inline in the parameter table);
- request-body / form `:param` entries become a **Parameters** list;
- `:returns:` -> **Returns:** line, `:raises:` -> **Raises** list;
- framework-internal params (request/response/...) are dropped;
- inline `:role:`X`` roles and reST `` ``X`` `` literals normalize to
  Markdown `` `X` `` code spans.

Regenerate openapi.json; drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 1600ebb30b feat(openapi): advertise response schemas for session read/write endpoints
The session-level reads/writes set response_model=None (to skip FastAPI's
response re-validation/serialization), which left their success-response
bodies with an empty schema — so the rendered reference showed `null`
examples. Declare the body schema via responses={<code>: {"model": <Model>}}
on the ten endpoints that return a clean Pydantic model (SessionResponse,
PaginatedList, PermissionObject, ConversationDeleted), keeping
response_model=None so runtime behavior is unchanged.

Proxy / raw-Response / content-type-dispatch routes are left as-is — they
have no clean schema to advertise. openapi.json regenerated (37 -> 27
empty-schema operations); drift test passes.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok b6a232a028 feat(openapi): hide internal endpoints and split out session resources
Mark internal plumbing with include_in_schema=False so it stays out of
the published spec and the public reference: the three harness callback
webhooks (hooks/*), the MCP proxy, Post Event, the elicitation get +
resolve pair, the environment file-diff endpoint, and terminal transfer
(9 operations; 78 -> 69).

Split the session-resource subtree (.../sessions/{id}/resources — files,
terminals, sandboxed environments) out of the broad "Sessions" group
into its own "Session Resources" section. The sessions router inherits a
single tag from include_router, so the split is a prefix-based retag in
dump_openapi.py rather than a router refactor.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Daniel Lok 860ea550a1 feat(openapi): enrich spec metadata and sync reference to the site
Add the document-level metadata that docs/SDK tooling needs but FastAPI
doesn't emit — info.description (purpose, base URL, cookie/proxy auth
model), servers (127.0.0.1:6767), top-level tags with descriptions and
display order, securitySchemes (proxy header + session cookie), and a
synthetic `system` tag for the untagged utility endpoints — in
scripts/dump_openapi.py, and regenerate openapi.json.

Add .github/workflows/sync-openapi-to-site.yml: when openapi.json
changes on main, mint a token from the omnigent-ci App and open/update
a PR on omnigent-site that copies the spec into public/openapi.json,
where it is rendered as the public API reference.

Co-authored-by: Isaac
2026-06-25 15:39:44 +08:00
Pat Sukprasert 4588af3fdc Revert CreateOS os_env provider (#452, #1228) (#1235)
* Revert "fix(os_env): wire createos fields in native parser + atexit cleanup (#1228)"

This reverts commit d6d2dc3a6c.

* Revert "feat(os_env): add CreateOS remote sandbox provider (type='createos') (#452)"

This reverts commit 4b04171633.
2026-06-25 14:38:28 +07:00
xtra 9494f66772 feat(#897): add MCP server management to Agent Info (#1093)
* feat(ui): manage MCP servers from Agent Info

* fix: update MCP server API generated files

* fix: refresh MCP tools after session edits

* fix: remove undefined _compaction_contexts reference in _clear_session_agent_caches

The variable was never defined, causing a NameError that broke
reset-state and all cache invalidation during agent switches.

Co-authored-by: Isaac

* fix(polly-review): lower diff cap to 128 KB to fit within ARG_MAX

The prompt (with embedded diff) is passed via -p CLI arg to uv run.
Large diffs hit Linux ARG_MAX (~2 MB for argv+env), causing
"Argument list too long". Lower the cap from 512 KB to 128 KB to
leave room for the prompt template, env vars, and other argv.

Co-authored-by: Tomu Hirata

* Revert "fix(polly-review): lower diff cap to 128 KB to fit within ARG_MAX"

This reverts commit 3cee3c82ef59ec1924215af91a58c470207a3764.

* feat(ui): add inline delete to MCP server pills in Agent Info

Match the policy pill pattern: clicking a tool pill opens a popover
with description and a Remove button, consistent with how policies
can be deleted inline.

Co-authored-by: Isaac

* fix(ui): remove border around empty MCP servers state in manager dialog

Co-authored-by: Isaac

* feat(claude-native): persist compaction item on compaction completion

When the forwarder observes SessionStart source=compact (compaction
completed), persist a compaction item to the conversation store so
session resume knows the compaction boundary. Previously only the UI
spinner events were published — no durable boundary was stored, making
transcript rebuild from DB load the full pre-compaction history.

Co-authored-by: Isaac

* fix: fall back to in-process runner client when router lookup fails

_get_runner_client returned None when RunnerRouter was set but
couldn't find the session's runner (e.g. local single-user mode
where the runner is in-process but not in the tunnel registry).
This broke MCP tools/list and tools/call for sessions using
spec-declared MCP servers in omni server mode.

Now falls through to the in-process runner client instead of
giving up, matching the behavior when runner_router is None.

Co-authored-by: Isaac

* fix(test): add MCP server hook mocks to AppShell test files

McpServersSection now uses useDeleteMcpServer unconditionally,
so test files that mock @/hooks/useAgents must export it.

Co-authored-by: Isaac

* feat: refresh MCP tool schemas every turn for hot-reload

MCP tool schemas are now resolved on each turn instead of being
cached for the session lifetime. This ensures that MCP servers
added or removed via the Agent Info UI are immediately available
on the next message without requiring a server restart.

Builtin tool schemas (from ToolManager) remain cached. Only the
MCP portion is refreshed — the underlying connections are pooled
in RunnerMcpManager so tools/list is fast after initial connect.

Co-authored-by: Isaac

* perf: only re-resolve MCP schemas when spec hash changes

Instead of fetching tools/list every turn, track a content hash
of the spec's mcp_servers list. MCP schemas are only re-resolved
when the hash changes (server added/removed/edited). The hash is
cleared by _clear_session_agent_caches so UI edits still trigger
an immediate refresh.

Co-authored-by: Isaac

* Revert "feat(claude-native): persist compaction item on compaction completion"

This reverts commit 9b44b8ed0a2fa33fdafc8a60f4268ba2d127f5e0.

* feat: release harness subprocess on agent-cache reset for MCP hot-reload

The Claude SDK client bakes mcp_servers at creation time, so new
MCP tools added via the UI don't appear in the API's tools array
until the client is recreated. On agent-cache reset (triggered by
MCP server edits), release the harness subprocess so the next turn
spawns a fresh one with the updated tool list.

Co-authored-by: Isaac

* fix(ui): disable MCP server Save button when required fields are empty

Co-authored-by: Isaac

* fix(ui): hide MCP server management for native harnesses

Native agents (claude-native, codex-native, etc.) manage their own
CLI tools and don't use the SDK's mcp_servers injection, so editing
MCP servers via the UI has no effect. Set mcp_servers_editable=False
for native harnesses to hide the + button.

Co-authored-by: Isaac

* revert: remove harness release from agent-cache reset

Releasing the harness subprocess on MCP edit caused the running
session to lose all tools. The spec cache clear + MCP hash
invalidation is sufficient — the next turn re-resolves the spec
and rebuilds the tool list without killing the harness.

The Claude SDK client's baked mcp_servers remains a limitation:
new MCP tools appear in the runner's tool list but not in the
SDK's API request until the session is forked or restarted.

Co-authored-by: Isaac

* fix: use compacted_messages in server-side transcript rebuild

compaction_to_history_items (used by _load_initial_history in
workflow.py) was always creating a synthetic summary pair, ignoring
the compacted_messages field. Now it uses compacted_messages when
available, converting them to ConversationItems for the prompt.

This fixes the server-side resume path — the runner-side path
(_convert_raw_items_to_input in app.py) was already updated.

Co-authored-by: Isaac

* feat(ui): show restart toast after MCP server edits

The Claude SDK client bakes tools at creation time, so MCP
changes don't take effect until the session restarts. Show a
toast after create/update/delete to inform the user.

Co-authored-by: Isaac

* style: fix ruff and prettier formatting

Co-authored-by: Isaac

* fix: scope in-process runner fallback to MCP paths only

The previous _get_runner_client fallback leaked the in-process
client into all runner-client paths (stop_session, session
creation), breaking tests that inject a fake runner via
set_runner_client. Move the fallback to _handle_mcp_tools_list
and _handle_mcp_tools_call specifically, where the in-process
runner is needed for local single-user MCP dispatch.

Co-authored-by: Isaac

---------

Co-authored-by: wxrth <191876097+wxrth@users.noreply.github.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-06-25 16:36:18 +09:00
Tomu Hirata 75062a4cd2 fix(polly-review): read diff from file instead of embedding in CLI arg (#1215)
The prompt with embedded diff was passed via -p CLI arg to uv run.
Large diffs hit Linux ARG_MAX (~2 MB for argv+env), causing
"Argument list too long".

Fix: pre-fetch the full diff to /tmp/pr_diff.txt (no size cap) and
tell Polly to read it from disk via sys_os_shell("cat /tmp/pr_diff.txt").
No ARG_MAX issue, no GH_TOKEN needed, no size cap, full diff available.

Co-authored-by: Tomu Hirata
2026-06-25 16:31:40 +09:00
Serena Ruan c967843a31 test(e2e-ui): mark share grant/downgrade/revoke journey flaky (#1229)
The test races on permission propagation: after the owner revokes Bob's
grant, the test immediately re-navigates and expects a 404, but the
revoke may not have propagated to the snapshot read yet (observed in CI:
`assert 200 == 404` at the revoke step). Add the standard
`@pytest.mark.flaky(reruns=2, reruns_delay=5)` marker already used by
other timing-sensitive e2e_ui tests (test_clone_session,
test_mobile_workflow).

Co-authored-by: Isaac
2026-06-25 14:56:23 +08:00
Tomu Hirata 1f36ace848 feat(claude-native): persist compaction item on compaction completion (#1224)
* feat(claude-native): persist compaction item on compaction completion

When the forwarder observes SessionStart source=compact (compaction
completed), persist a compaction item to the conversation store so
session resume knows the compaction boundary. Previously only the UI
spinner events were published — no durable boundary was stored,
making transcript rebuild from DB load the full pre-compaction history.

Co-authored-by: Isaac

* test(claude-native): add tests for compaction item persistence

Cover _persist_native_compaction_item and its integration with the
forwarder loop: happy-path POST, empty-items fallback, completed
triggers persist, and in_progress does not persist.

Co-authored-by: Isaac

* feat(claude-native): include compacted_messages in compaction item

Read post-compaction transcript from Claude's session state via
get_session_messages and persist it as compacted_messages in the
compaction event, so session resume in ephemeral environments can
reconstruct context without the CLI's local transcript files.

Co-authored-by: Isaac

* fix: use compacted_messages in server-side transcript rebuild

compaction_to_history_items (used by _load_initial_history in
workflow.py) was always creating a synthetic summary pair, ignoring
the compacted_messages field. Now it uses compacted_messages when
available, converting them to ConversationItems for the prompt.

This fixes the server-side resume path — the runner-side path
(_convert_raw_items_to_input in app.py) was already updated.

Co-authored-by: Isaac
2026-06-25 15:48:27 +09:00
Serena Ruan 647cc1f931 feat(qwen): mirror native-qwen tool approvals as web elicitation cards (#1213)
* feat(qwen): mirror native-qwen tool approvals as web elicitation cards

When the native-qwen TUI prompts for tool approval, surface the same
approval as a card in the web chat, and let either surface answer it.

qwen's dual-output stream emits a structured `control_request`/
`can_use_tool` whenever a tool needs approval (coexisting with its
in-terminal prompt) and accepts a `confirmation_response` on the input
file; `control_response` marks resolution either way. The new
`qwen_native_permissions.supervise_qwen_approval_mirror` tails the same
`--json-file` the transcript forwarder reads (seeded at EOF so only new
prompts park), POSTs each request to the generic
`/v1/sessions/{id}/hooks/native-permission-request` hook (the
vendor-agnostic one shared with the hermes-/goose-native mirrors) with
`agent="qwen"` + `policy_name="qwen_native_permission"`, and on the web
verdict writes `confirmation_response`. If a `control_response` arrives
while the card is still parked (the user answered in the TUI), it posts
`external_elicitation_resolved` to clear the stale card. Wired alongside
the forwarder under one supervised task in `_auto_create_qwen_terminal`.
Verified end-to-end on a live session (matching request_ids across
request -> confirmation -> response).

Also fix the comment relay's bridge-root allowlist
(`claude_native_bridge._trusted_parent_for_bridge_dir`), which omitted
`qwen-native` and threw "not under an allowed bridge root" for every
native-qwen session.

Docs: mark the elicitation follow-up done and add a Medium follow-up for
compaction/compression mirroring.

Tests: new tests/test_qwen_native_permissions.py (parser, control-event
reader, run-one-approval verdict->confirmation matrix, park->release
cycle); a qwen-flavored native-permission hook round-trip integration
test; and two trusted-parent regression tests for the bridge-root fix.

Co-authored-by: Isaac

* fix(qwen): don't park approvals already resolved in the same poll batch

When a can_use_tool control_request and its control_response land in one
event-file poll batch, the freshly-created park task hasn't POSTed yet, so
the response branch can't release the card and it lingers until the
server-side park timeout. Pre-scan the batch and skip parking any request
whose response is already present — the decision is made, no card needed.

Co-authored-by: Isaac
2026-06-25 14:37:10 +08:00
Abderrahmen Gharsallah 0747e7cdd5 feat(web-ui): implement sidebar toggle hotkeys for left and right side (#852)
* feat(web-ui):implement sidebar toggle hotkeys for left and right sidebars

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* feat(hotkeys): update sidebar toggle hotkeys to use Backslash key

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* feat(shortcuts): add keyboard shortcuts for toggling conversations and workspace sidebars

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* test(e2e-ui): cover sidebar toggle hotkeys (⌘⌥[ / ⌘⌥])

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

* fix(tests): format keydown event modifiers for clarity
Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>

---------

Signed-off-by: agharsallah <17379925+agharsallah@users.noreply.github.com>
2026-06-25 06:33:06 +00:00
Pat Sukprasert d6d2dc3a6c fix(os_env): wire createos fields in native parser + atexit cleanup (#1228)
- spec/parser.py: populate createos_* fields in the native parser, in
  lockstep with the legacy loader. Previously an agent loaded via native
  YAML got type='createos' but base_url/api_key/shape/rootfs were silently
  dropped (env-var/default fallback only).
- createos_os_env.py: register close() with atexit in create_sync so an
  interpreter exit that skips __del__ still tears down the billable VM.
- os_env.py: ruff format fix (blank line after lazy import).
- tests: native-parser createos coverage (populated + default-None) and
  an atexit-registration test.

Co-authored-by: Isaac
2026-06-25 13:16:07 +07:00
pratikbin 4b04171633 feat(os_env): add CreateOS remote sandbox provider (type='createos') (#452)
Add a new `os_env` provider that runs file I/O and shell commands inside
a remote CreateOS sandbox VM instead of local helper subprocesses.

The provider provisions a VM on first use (polling until running),
proxies read/write/edit/shell over the CreateOS control-plane HTTP API,
and destroys the VM on close. It uses a sync httpx.Client wrapped with
run_sync_on_thread, mirroring CallerProcessOSEnvironment.

- createos_os_env.py: _Http transport, status polling, CreateosOSEnvironment
- datamodel.py: 4 createos_* fields on OSEnvSpec
- os_env.py: dispatch type='createos' in create_os_environment() +
  default_os_env_spec_for_type()
- loader.py: parse base_url/api_key/shape/rootfs from agent YAML
- docs/AGENT_YAML_SPEC.md: document the type='createos' block
- tests: unit coverage for read/write/edit/shell, polling, JSend unwrap,
  idempotent close, and the missing-API-key error path

Credentials resolve from os_env.api_key / os_env.base_url or the
CREATEOS_API_KEY / CREATEOS_BASE_URL env vars (base_url defaults to
https://api.sb.createos.sh).

Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-06-25 13:01:33 +07:00
Serena Ruan 165875b545 fix(ui): fold the pin button into the kebab menu on mobile (#1226)
The standalone pin (thumbtack) button was permanently visible on every
session row on mobile, since there's no hover state to gate it like on
desktop. Hide it on mobile (`hidden md:block`) and add a Pin/Unpin item
to the kebab menu instead (`md:hidden`), so mobile gets a single, clean
pin affordance that lives alongside Archive/Share/Rename. Desktop is
unchanged — the quick hover button stays, the kebab item stays hidden.

Co-authored-by: Isaac
2026-06-25 13:56:10 +08:00
Yuan Tang 01bc76ded2 fix(infra): publish omnigent-server-openshell image and wire overlay to it (#1151) (#1190)
The openshell Kubernetes overlay deployed the default server image which
lacks the openshell SDK extra, breaking sandbox launches out of the box.

- CI now builds and publishes ghcr.io/omnigent-ai/omnigent-server-openshell
  (with OMNIGENT_EXTRAS=openshell) alongside the existing server and host
  images, sharing the same tag scheme, SBOM generation, nightly promotion,
  and floating-tag reconciliation.
- The openshell overlay kustomization swaps the base image to the
  -openshell variant via an images: transformer.

Co-authored-by: Corey Zumar <39497902+dbczumar@users.noreply.github.com>
2026-06-25 12:49:34 +07:00
Serena Ruan 4016fe446a feat(ui): swap composer model/effort and harness label positions (#1218)
* feat(ui): swap composer model/effort and harness label positions

The composer picker trigger showed the harness identity ("Claude") while
the read-only status tray below showed the model/effort label ("Opus
Medium"). Since the picker is the control that actually changes model and
effort, the label naming what it controls belonged in the wrong place.

Swap them across all session types:
- AgentPicker trigger now renders `<model> <effort>` with the model in
  the foreground color and the effort muted. The "no selector when the
  session can't switch model/effort from the web UI" rule is preserved via
  the existing hasPickerActions gate; vendor-owned-model native sessions
  (qwen/goose/cursor/pi/opencode) fall back gracefully since their bound
  model isn't the live one.
- ComposerStatusLine now shows the harness/agent identity (e.g. "Claude",
  "Polly (Pi)") via a new composerHarnessLabel() helper, fed as a prop.

Tests updated: status-line model/effort assertions become harness-label
assertions, plus unit tests for composerHarnessLabel and a trigger-label
test asserting model=foreground / effort=muted.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* fix(ui): update e2e tests for swapped labels + guard picker visibility

Two follow-ups after swapping the composer model/effort and harness labels:

1. e2e tests still asserted the old positions, failing CI (shard 2/3):
   - test_agent_picker: the bound agent identity moved to the status tray
     (composer-harness); the trigger now shows the bound model (disabled).
   - test_codex_model_metadata: model/effort moved into the picker trigger;
     the "Codex" harness identity moved to composer-harness.
   - test_fork_switch_agent: a Pi-native session has nothing to switch from
     the web UI, so the trigger renders nothing — the "Pi" identity is now
     carried by composer-harness.

2. Fix a regression the rewritten AgentPicker trigger introduced (flagged in
   review): the `else return null` fallback could hide the entire picker —
   and the model dropdown + bare-`/model` path — for a native session where
   the live model/effort label isn't resolved yet (no spec model, no sticky/
   override model, no selected effort), even though CLAUDE_NATIVE_MODELS still
   gives the dropdown rows to switch. Now the trigger falls back to a stable
   identity label whenever hasPickerActions is true, and only returns null
   when there is genuinely nothing to show and nothing to switch. Added a
   unit test covering the unresolved-label native case.

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-25 13:41:28 +08:00
Edwin He 8edaaeaf6b feat(ap-web): show session owner in the info popover (#1165)
* feat(ap-web): show session owner in the info popover

Surface the session owner (the user_id granted LEVEL_OWNER) in the agent
info popover so a viewer can tell whose session a shared chat is — e.g. a
chat shared to "all workspace users". Reuses the existing
GET /v1/sessions/{id}/owner endpoint via a new useSessionOwner hook; the
row is omitted in single-user mode (no owner) and appends "(you)" when the
viewer owns the session.

Co-authored-by: Isaac

* test(e2e_ui): cover session owner row + (you) state in agent-info popover

Adds a Playwright e2e_ui test (reusing the multi-user `shared` fixture) that
opens the agent-info popover and asserts the new Owner row: a collaborator
(Bob, edit) sees the owner without "(you)", and the owner (headerless `local`)
sees the same row with "(you)". Satisfies the e2e-ui-required gate for the
owner-display UI change.

Co-authored-by: Isaac
2026-06-24 21:39:03 -07:00
Zeyi (Rice) Fan 8088ee02a3 fix(chat): tighten new session composer gutters on phones (#1223)
## Related issue

N/A

## Summary

- The empty new-session page is rendered by NewChatDialog, not
  ChatPage's ConversationContent — so the earlier padding fix (422d190)
  edited the wrong component and had no visible effect.
- The composer + footer-chip container used `px-10` (40px gutters) at
  every breakpoint, leaving wide empty margins flanking the composer
  card on phones.
- Override to `px-4 md:px-10` so phones get 16px gutters and the
  composer no longer feels cramped against the viewport edges; desktop
  keeps the original 40px from the md breakpoint (768px) up.

## Test Plan

- Loaded the empty new-session landing page in a narrow (phone-width)
  viewport and confirmed the left/right gutters around the composer
  card and footer chips are 16px; verified they widen back to 40px at
  >=768px so desktop is unchanged.

## Type of change

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

Verified visually in the browser at phone and desktop widths: the
new-session composer container gutters are 16px on phones and 40px at
the md breakpoint and above. This is a Tailwind class-only change with
no logic to unit-test.
2026-06-25 03:46:25 +00:00
Zeyi (Rice) Fan 14f01000d9 fix: make iOS Connect button feel responsive while connecting (#1220)
## Related issue

N/A

## Summary

- The iOS `ConnectView` Connect button felt unresponsive while it talked
  to the server. `connect()` runs `WorkspaceURLExpander.expandIfNeeded`,
  which issues a HEAD request with an 8s timeout, and the tap itself was
  never acknowledged because `.buttonStyle(.plain)` strips the default
  touch-down highlight.
- Added a `PrimaryButtonStyle` that keeps the existing filled look and
  adds an instant opacity+scale press response, so the tap registers the
  moment the finger lands.
- Added a light haptic via `.sensoryFeedback(.impact)` triggered on
  `isConnecting`, and a "Connecting…" label beside the spinner so the
  busy state reads clearly.
- Disabled the text field and recent-server rows while connecting so the
  whole form reflects the busy state. Connection logic is unchanged.

## Test Plan

- Built the iOS target via `xcodebuild -project Omnigent.xcodeproj
  -scheme Omnigent -destination 'generic/platform=iOS Simulator'
  -configuration Debug build CODE_SIGNING_ALLOWED=NO` — compiles clean
  (only a pre-existing unrelated warning in NativeNotificationManager).
- Manual: tap Connect against a slow/bare-https URL and confirm the
  button dims/scales on press, shows "Connecting…", disables the inputs,
  and still renders the red error message on failure. Haptic confirmed
  on a physical device.

## Type of change

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

Verified by building the iOS target (compiles clean) and by manual
inspection of the Connect flow in the simulator: press feedback,
"Connecting…" label, disabled inputs during connection, and the error
path. The change is presentation-only (button style, haptic, labels,
disabled state) with no change to connection logic, so no automated
tests were added.
2026-06-25 03:40:37 +00:00
Zeyi (Rice) Fan 5678984279 fix(ios): reveal server switcher when the page never speaks over the JS bridge (#1221)
## Related issue

N/A

## Summary

- The iOS server switcher visibility is entirely web-driven: it is hidden on every navigation start and only revealed when the web app calls `setServerSwitcherHidden(false)` over the JS bridge. `didFailProvisionalNavigation` only catches transport failures (DNS/TLS/connection), so a page that loads HTTP-200 but renders blank, crashes its JS before the mount effect runs, or hangs without reaching `didFinish` leaves the switcher hidden forever — stranding the user with no way back to server selection.
- Add a bridge-liveness watchdog in `WebViewModel`: a 6s timer armed on navigation start (`didStartProvisionalNavigation`) that forces the switcher visible if it fires. The first trusted bridge message of any kind cancels it — the page has proven it is alive and owns the switcher state from there. The watchdog is also cancelled on load failure (we route to server selection anyway) and on coordinator teardown.
- This keys the escape hatch on the page actually using the bridge, so there is no pill flash on healthy loads, and a genuinely-alive page that wants the switcher hidden still gets its way.

## Test Plan

- Manual reasoning over the navigation lifecycle: healthy load → first bridge call cancels the watchdog before it fires; blank/crashed/hung page → no bridge call → switcher appears after 6s; transport failure → routes to ConnectView with the watchdog cancelled; fullscreen page calling `setServerSwitcherHidden(true)` → that call cancels the watchdog so it stays hidden.
- `swift format` run clean on both edited files. Not built against a simulator in this environment — recommend a local `xcodebuild` before merge.

## Type of change

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

Verified by tracing the navigation-delegate and bridge-message paths: the watchdog is armed on every navigation start, cancelled by the first trusted bridge message, by load failure, and by coordinator teardown; on expiry it sets `serverSwitcherHidden = false`. No automated iOS UI test harness exists for the WebView shell, so coverage is manual reasoning plus `swift format`. A simulator build/run is recommended locally before merge.
2026-06-25 03:39:32 +00:00
Zeyi (Rice) Fan 46d0dd467c fix(ios): preserve transcript scroll position across keyboard/composer resize (#1170)
## Related issue

N/A

## Summary

- Follow-up to the visual-viewport shell lock. The shell-lock kept the
  composer above the keyboard, but the chat transcript didn't follow: the
  rising composer covered the last message, and re-pinning approaches that
  read use-stick-to-bottom's `isAtBottom` worked once then broke (the shrink
  flips that flag false before any handler reads it) or crept up ~2 lines on
  focus.
- Replace the bottom-pinning logic with `PreserveScrollDistanceOnResize`: a
  `ResizeObserver` on the transcript's scroll container that holds the scroll
  position relative to the bottom (`scrollTop = scrollHeight - clientHeight -
  distance`) on any container resize. `distance` is tracked from genuine user
  scrolls only — scrolls coinciding with a dimension change (the resize clamp
  or our own restore) are ignored so they can't corrupt it. At the bottom you
  stay flush above the composer; scrolled up reading history, you stay on the
  same messages — across unlimited keyboard cycles.
- Watch the container (not visualViewport) so the fix also covers the composer
  growing taller on focus, which steals transcript height without firing a
  visualViewport resize — the source of the ~2-line creep. New messages still
  flow through the library (content resize doesn't change the container box).
- useIOSViewportLock: split the document-pan reset into its own `window`
  `scroll` listener so a stray WebKit pan is snapped back immediately, not only
  on the rAF-coalesced resize; refresh the doc comment to match the verified
  behavior (`visualViewport.height` tracks the keyboard while `innerHeight`
  stays full).
- OmnigentWebView: set `webView.isInspectable = true` under `#if DEBUG` so
  Safari Web Inspector can attach to the web content (opt-in since iOS 16.4);
  shipping builds stay non-inspectable.

## Test Plan

- `npm run type-check` — passes.
- `npx vitest run src/pages/ChatPage.composer.test.tsx` — 47/47 pass.
- On-device (iOS simulator, Vite dev server) with Safari Web Inspector:
  diagnosed via logging that the transcript settled correctly at the bottom
  (dist 0) and mid-history (dist preserved), and that the residual ~2-line
  creep came from a container resize with no visualViewport event (composer
  growth) — which the ResizeObserver now compensates. Verified focusing at the
  bottom keeps the last message above the composer with no creep, and focusing
  while scrolled up holds position, across repeated keyboard open/dismiss.

## Type of change

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

This is iOS WKWebView keyboard/scroll-anchoring behavior that can't be
exercised in jsdom (no real visualViewport, ResizeObserver geometry, or
keyboard). Verified via type-check, the existing chat composer test suite (no
regressions), and on-device inspection through Safari Web Inspector — using
temporary scroll-geometry logging (since removed) to confirm the distance is
preserved at the bottom and mid-history and that the composer-growth reflow is
now compensated.
2026-06-25 03:33:17 +00:00
Sabhya Chhabria 0103946114 fix(antigravity-native): wire omnigent MCP relay so agy gets the sys_* tools (#1194) (#1216)
antigravity-native (agy) was the only native harness with no omnigent MCP
relay, so the wrapped agy could not use any sys_* tool (spawn sub-agent
sessions, drive omnigent terminals, list agents/models, sys_os_*). Wire the
same shared relay cursor/claude/codex use, mirroring cursor #742.

The blocker (why #11 was deferred): agy has no --mcp-config flag and ignores
ANTIGRAVITY_* env knobs; it loads MCP servers ONLY from the HOME-global
~/.gemini/config/mcp_config.json — the same file the user's interactive agy
reads. A naive write clobbers the user's config and is incorrect under
concurrency (the relay command is bridge-dir-specific).

Chosen design: per-session ISOLATED HOME. The runner launches agy with HOME
pointed at <bridge_dir>/agy-home, seeded with a COPY of the user's OAuth token
+ onboarding/migration markers and a bridge-scoped config/mcp_config.json. This
never touches the user's real ~/.gemini, gives each session its own config (no
concurrency clobber), and was verified live: agy under the isolated HOME does
not re-demand OAuth and its /mcp panel shows "✓ omnigent" with the sys_* tools
discovered.

The relay subprocess inherits agy's isolated HOME, so build_mcp_config pins the
relay's HOME back to the runner's real home — otherwise the relay's bridge-root
validation (bridge_root() = $HOME/.omnigent/antigravity-native) would reject its
own --bridge-dir (caught and fixed during live e2e).

- antigravity_native_bridge.py: add build_mcp_config / write_mcp_config /
  write_mcp_bridge_config / seed_isolated_agy_home / agy_home_dir (agy's
  lowercase mcpServers schema + enabledTools auto-approve allowlist).
- claude_native_bridge.py: accept the antigravity-native bridge root in
  _trusted_parent_for_bridge_dir (same $HOME/.omnigent/<harness> shape as codex).
- runner/app.py: start the relay + write the isolated-HOME mcp_config before
  launch in _auto_create_antigravity_terminal; thread HOME into the launch env;
  add an antigravity-native branch to the _run_turn_bg first-turn relay fallback.
- antigravity_native.py: fix the false spec comments that claimed a relay
  already consumed spawn:true / terminals: (now true), keeping terminals: noted
  as still feeding the web-UI new-terminal affordance.

Tests: unit-test the config build/write + isolated-HOME seed + relay wiring +
the antigravity bridge-root acceptance; integration-test that auto-create starts
the relay, writes mcp_config into the isolated HOME, and threads HOME into the
launch env. Live e2e: agy connects to the omnigent MCP server and lists the
sys_* tools (DISCOVERY). The orchestrator must run tool EXECUTION against a live
server (steps in the PR body).

Refs #1194

Co-authored-by: Isaac <isaac@example.com>
2026-06-25 03:25:59 +00:00
Serena Ruan c0eaba34ea fix(ui): toggle arrow indicator when expanding token usage dropdown (#1217)
The token usage details section was showing a static right arrow (▶) even when
expanded. Now the arrow changes to a down arrow (▼) when expanded.

Co-authored-by: Isaac
2026-06-25 11:23:53 +08:00
Zeyi (Rice) Fan e998f18789 fix(ios): freeze the transcript while the edge-swipe drags the sidebar (#1214)
## Related issue

N/A

## Summary

- A left-edge swipe that drives the iOS sidebar drawer also scrolled the
  chat transcript, because the finger's vertical component still reached
  the transcript's scroll container.
- The transcript can't be stopped from the native side: on iOS the page
  is viewport-locked, so it scrolls as an inner `overflow:auto` element
  (`scroller.el`), not `webView.scrollView`. It has to be frozen in the
  DOM.
- Subscribe to the native drag stream (`onNativeSidebarDrag`) in
  ChatPage. While a drag is live (begin/move) the scroll container stops
  responding to touch (`pointer-events: none`), its overflow is locked
  (`overflow-y: hidden`), and its `scrollTop` is pinned via a scroll
  listener so neither a finger-drag nor leftover momentum can move it.
  All three are restored when the drag settles (open/close), and on
  effect cleanup.

## Test Plan

- `tsc --noEmit` passes for the touched file.
- Needs on-device verification on the iOS shell: left-edge swipe to open
  the sidebar and confirm the transcript no longer scrolls during the
  drag, and that normal vertical scrolling still works after the drawer
  settles.

## Type of change

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

DOM/touch behavior inside the iOS WKWebView shell, which the web test
suite can't exercise. Verified the change typechecks; the scroll-freeze
behavior must be confirmed manually on an iOS device/simulator with a
real left-edge swipe. The fix is web-side only, so a web reload tests it
(no native rebuild required).
2026-06-25 02:51:31 +00:00
Sabhya Chhabria 0b49478589 fix(antigravity): bidirectional elicitation sync for agy native (#1200) (#1207)
Antigravity (agy) command-permission and ask-question elicitations now sync
in BOTH directions between the Omnigent web Chat UI and the attended agy TUI.

Root cause (web -> terminal, #1200): the agy write path types every web turn
into the attended TUI (inject_user_message_via_tui), so a permission gate
surfaces as agy's in-process numbered TUI prompt. The bridge delivered the
verdict over HandleCascadeUserInteraction RPC, which flips the backend
trajectory step to DONE but leaves the TUI's own prompt open in parallel
(live-verified in docs/claude/antigravity-rpc-spike-notes.md): the terminal
never advances and the next typed turn lands in the stale prompt's buffer.

Fix (web -> terminal): after a successful RPC delivery, bridge_interaction now
ALSO types the verdict into the agy pane via a new bridge primitive
send_interaction_keys_via_tui, mirroring cursor-native's send_cursor_pane_keys.
A pure mapper to_tui_selection_keys turns the verdict into tmux keys: permission
Approve -> "1","Enter" (Yes), Reject -> "4","Enter" (No); ask_question -> the
selected option id(s) + Enter, or Escape on decline. TUI typing is best-effort
(logged, not raised) so a flaky/exited pane never undoes the delivered verdict.

Root cause (terminal -> web): the reader only PUBLISHED an elicitation on
detecting a WAITING step and never WITHDREW it, so answering directly in the
TUI (or an agy timeout/auto-resolve) left the web card lingering forever
("Respond to the pending request above to continue.").

Fix (terminal -> web): the reader now tracks each surfaced elicitation id and,
when its WAITING step is later seen no longer WAITING, POSTs
external_elicitation_resolved (mirroring cursor-native). Server-side this clears
the web card AND short-circuits any in-flight request_elicitation long-poll to
None, so a racing bridge_interaction does not deliver a stale verdict. Posted at
most once per step; harmless when the web verdict already resolved it (no parked
future -> tombstone), so the two directions never double-resolve.

Tests: web verdict drives the correct TUI keys (approve/reject/ask), TUI failure
does not undo the verdict, no keystroke when nothing delivered; the new bridge
primitive's exact send-keys argv; the to_tui_selection_keys mapper; and the
withdraw path on both poll and stream (clears once, no-op while WAITING, idempotent).

Co-authored-by: Isaac <isaac@example.com>
2026-06-25 02:30:40 +00:00
Pat Sukprasert 3ccdf16b8f feat(kiro): add Kiro to the omnigent setup harness menu (#1204)
The kiro-native harness (added in #899) registers its install spec but was
never wired into the interactive `omnigent setup` overview, so users had no
way to discover/install Kiro from the CLI setup flow (it only appeared in the
web agent picker). Goose/Hermes — the other own-auth native CLIs — already
have rows there.

Add a Kiro row mirroring Hermes: a `_KIRO` sentinel, a level-1 row that shows
the curl install hint when `kiro-cli` is absent (and a sign-in reminder when
present), dispatch to a new `_manage_kiro_harness` drill-in that offers to run
`kiro-cli login`. Kiro owns its own auth (Builder ID / social / Identity
Center), so there is no Omnigent credential to configure.

Test asserts the Kiro row + install hint render when the CLI is absent and the
sign-in step is named when present.

Co-authored-by: Isaac
2026-06-25 09:30:25 +07:00
Zeyi (Rice) Fan c26cdbc974 fix(ios): respect safe-area inset for the Jump to top button (#1208)
## Related issue

N/A

## Summary

- The "Jump to top" pill was pinned at a hardcoded `top-[50px]`, but on
  the iOS shell the ChatHeader and the `.chat-scroll-fade` mask border
  both shift down by `var(--omnigent-inset-top)` (the safe-area inset).
  The pill stayed put, so on notched devices it drifted off the fade
  border and overlapped the header.
- Move the offset to an inline style and add the inset:
  `top: calc(50px + var(--omnigent-inset-top))`. This mirrors the
  established inset pattern (`.chat-scroll-fade`, `.chat-conversation-content`,
  `PageScroll`). The var resolves to `0px` off-shell, so browser and
  Electron behavior is unchanged.

## Test Plan

- Reviewed the diff against the existing inset system in `index.css`
  (`--omnigent-inset-top`, `.chat-scroll-fade` mask).
- Verified the var defaults to `0px` outside the iOS shell, keeping
  non-iOS positioning identical to before.

## Type of change

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

CSS-only positioning change with no test hooks. Verified by reasoning
against the shared inset variables: `--omnigent-inset-top` is
`env(safe-area-inset-top, 0px)`, so the pill now tracks the fade border
on iOS and is unchanged (50px) in the browser and Electron.
2026-06-25 02:16:37 +00:00
ankushbhatiya cd91556d3c feat: add Kimi Code as a harness (#271) (#521)
* feat(kimi): add Kimi Code CLI as a harness (#271)

Wires Moonshot AI's upstream Kimi Code CLI
(https://github.com/MoonshotAI/Kimi-Code) into Omnigent as a first-class
harness alongside Claude Code, Codex, Cursor, Pi, and Antigravity. One
``kimi -p <prompt> --output-format stream-json`` subprocess per Omnigent
turn parses the JSONL transcript on stdout, captures the kimi session id
from the ``role:"meta"`` event for ``-S <id>`` resume on the next turn,
and uses the subprocess's ``cwd=`` for the working directory (upstream
has no ``--work-dir`` flag).

Only the upstream curl-installed ``kimi`` binary is supported. The
legacy pypi ``kimi-cli`` package is intentionally NOT detected — its
command-line surface (``--print``, list-of-blocks content, etc.) is
incompatible with the upstream binary the issue targets.

What landed:

- ``omnigent/inner/kimi_executor.py`` — Inner executor.
  ``handles_tools_internally=True`` (Kimi runs its own bash/edit/read
  tools); supports session resume, ``-C`` continue-last, ``--plan``,
  ``--skills-dir`` (repeatable), per-spawn model override via env-var
  contract.
- ``omnigent/inner/kimi_harness.py`` — FastAPI wrap via
  ``ExecutorAdapter`` with env-driven lazy executor construction.
- Runtime/registry: ``omnigent/runtime/harnesses/__init__.py`` registers
  ``kimi`` + ``kimi-code`` alias; ``omnigent/spec/_omnigent_compat.py``
  allowlist; ``omnigent/harness_aliases.py`` canonicalisation;
  ``omnigent/runtime/workflow.py`` ``AgentHarnessType`` entry +
  minimal ``_build_kimi_spawn_env`` (emits MODEL + CWD only — upstream
  kimi has no per-spawn provider override, so a spec declaring
  provider/Databricks auth now raises loudly).
- CLI/onboarding: ``omnigent kimi`` subcommand (shortcut for
  ``run --harness kimi``), default system prompt entry, ``_CLICK_SUBCOMMANDS``
  allowlist, first-run plan fallback gated on ``kimi`` binary presence,
  ``KIMI_KEY`` install spec with curl install_hint and ``kimi login``
  argv, ``KIMI_SURFACE`` readiness wiring.
- Model layer: ``model_override``, ``model_catalog`` identity entry,
  ``runner/app.py`` model env key + spawn-env dispatch.
- Frontend: ``ap-web/src/components/AgentCard.tsx`` fall-through
  comment (BotIcon for now; dedicated glyph deferred).
- Tests: ``tests/inner/test_kimi_harness.py`` (38 cases covering
  registry, FastAPI routes, env-var factory, argv builder for upstream
  syntax, event translator for content-as-string + ``role:"meta"``
  session capture + stderr fallback, capability flags, run-turn with
  stubbed subprocess, session resume, tools-without-bridge warning).
  Spawn-env tests in ``tests/runtime/test_provider_spawn_env.py``;
  readiness + install-spec tests; ``tests/cli/test_cli.py`` stubs the
  kimi binary check so first-run-plan tests stay deterministic.
- Docs: ``README.md`` mentions, ``docs/AGENT_YAML_SPEC.md`` Kimi
  section, ``examples/kimi_hello.yaml`` single-file launcher,
  ``docs/KIMI_FOLLOWUPS.md`` enumerating deferred work (Omnigent-side
  provider injection + MCP tool bridge via the ``kimi acp`` ACP server,
  native TUI in a tmux pane, dedicated glyph, multimodal/video input,
  mid-turn interrupt, token usage, spec-level plan/thinking fields,
  built-in agent specs).
- E2E: ``tests/e2e/test_kimi_executor_e2e.py`` gated on
  ``OMNIGENT_E2E_KIMI=1`` + ``kimi`` on PATH.

Resolves #271.

Signed-off-by: Ankush Bhatiya <ankushb@gmail.com>

* fix(kimi): address PR review — auth/sandbox/adapter/stream-limit

Incorporates the Polly review on #521:

- B1: drop unrelated `databricks_supervisor` from the harness allowlist
  (passed validation but had no module/builder, crashing at spawn).
- B2: reject declared `executor.auth` in `_build_kimi_spawn_env` (upstream
  kimi has no per-spawn provider override). Removed the unreachable raises
  in `configure_agent_harness_with_provider` (never called for kimi).
- B3: serialize `spec.os_env` into `HARNESS_KIMI_OS_ENV` and apply a
  platform sandbox launcher in `KimiExecutor` (mirrors qwen) so kimi's
  in-process tools run confined when the spec requests it.
- B4: add `Executor.forwards_observed_tool_results()` (True for kimi) so the
  adapter forwards self-contained tool-loop results instead of suppressing
  them as dispatched-tool duplicates.
- B5: pass a 16 MiB stdout `limit=` so large JSONL lines don't overrun
  asyncio's 64 KiB default and crash the turn.
- Non-blocking: drop the random-UUID session-id fallback; leave it None so a
  missed resume hint starts a fresh session instead of passing an id upstream
  may reject.

Adds tests for each and updates docs/KIMI_FOLLOWUPS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(kimi-native): native Kimi Code TUI harness with web-UI transcript + tool approval

Add the kimi-native harness: `omni kimi` launches the interactive kimi TUI in a
tmux pane embedded in the web UI (mirrors cursor-native), alongside the existing
headless SDK `kimi` harness (kept for sub-agent / `run --harness kimi` use).

- harness: kimi_native + bridge/executor/credentials/hook; runner terminal
  auto-create, interrupt/stop, and registry/alias/onboarding/model-catalog wiring
- transcript forwarder: tail the kimi wire.jsonl and mirror user/assistant turns
  into the chat, so replies render in the web UI (not just the embedded pane)
- interactive tool approval: the PermissionRequest hook publishes the web-UI
  approval card and types the verdict (Approve once / Reject) into the TUI
- Kimi glyph (@lobehub/icons), `omni setup` drill-in, and new-session picker
  dedup (native TUI only; the SDK kimi agent is hidden from the picker)

Co-authored-by: Isaac

* fix(kimi-native): web-UI approvals, working dir, latency, icon

Round of fixes from live-testing the native + SDK Kimi harnesses:

- Approvals: the shared PermissionRequest endpoint hard-coded an
  ``elicit_claude_`` id regex, 400-ing every kimi hook POST so the
  approval card never published. Generalize to ``elicit_<harness>_``.
  Add ``timeout = 600`` to the kimi hooks (kimi kills hooks at 30s,
  severing the approval long-poll) and ``-I`` to the hook command
  (kimi runs hooks with cwd=workspace; a workspace with its own
  ``omnigent/`` shadowed the install and the hook died on ImportError).

- Working directory: ``omni --harness kimi`` now runs the SDK kimi in
  the launch folder, matching claude. Add ``kimi`` to
  ``_OS_ENV_HARNESSES`` (launcher os_env block), make the harness wrap
  fall back to ``OMNIGENT_RUNNER_WORKSPACE``, and — the real fix —
  thread the session workspace ``cwd`` (not the /tmp bundle workdir)
  into ``HARNESS_KIMI_CWD`` in ``_build_kimi_spawn_env``, mirroring pi.

- Latency: bring the forwarder poll (0.7→0.25s), bridge poll
  (0.2→0.15s), paste settle (0.3→0.1s) and send timeout (10→5s) to
  claude-native parity; replace the unverified ``_settle_pane`` idle
  markers (carried over from cursor-native, never matched, so every
  web→TUI injection ate the full 30s readiness timeout) with the real
  K2.7 footer marker ``context:``.

- Icon: SubagentsPanel branded SDK-harness sessions (no wrapper label)
  as the generic bot; add a harness-substring fallback mirroring
  AgentCard so ``omni --harness kimi`` shows the Kimi glyph.

- Docs: remove docs/KIMI_FOLLOWUPS.md and reword the 11 code comments
  that pointed at it (the deferred work stays noted inline).

Co-authored-by: Isaac

* fix(kimi): use os.environ.copy() for subprocess env (exfil-scan)

The CI exfil scanner blocks the `dict(os.environ)` shape in added lines
(wholesale-environ-dump heuristic). The native wrappers legitimately copy
the environment for the subprocess they spawn — the grandfathered
claude/codex/pi/cursor/opencode wrappers all do the same. Switch the two
new kimi sites to the idiomatic `os.environ.copy()`, which is identical
behavior and doesn't trip the heuristic.

Co-authored-by: Isaac

* test(e2e-ui): cover Kimi native picker + SDK-kimi dedup

Adds the Playwright e2e_ui coverage the E2E UI Required gate asked for on
the new user-visible Kimi UI:

- test_start_session_kimi_native_picker_and_wrapper_labels: the picker
  renders the harness-derived label "Kimi" (not the raw "kimi-native-ui"),
  and create POSTs the terminal-first wrapper labels
  (omnigent.ui: terminal + omnigent.wrapper: kimi-native-ui).
- test_start_session_picker_hides_sdk_kimi: with both the native and SDK
  kimi rows in the catalog, the picker offers only the native row and drops
  the SDK `kimi` (NEW_SESSION_HIDDEN_AGENTS) — one "Kimi" to pick.

Mirrors the existing pi/opencode/antigravity native-agent tests. Both pass
locally against a spawned server + chromium.

Co-authored-by: Isaac

* test(e2e): cover kimi in the example + live-harness drift guards

Two backend e2e drift guards failed because the kimi PR added the
`kimi`/`kimi-native` harnesses + examples/kimi_hello.yaml without
updating them:

- test_examples_coverage_sync: allowlist `kimi_hello` (SDK-kimi launcher
  YAML) — covered by tests/inner/test_kimi_harness.py + the picker e2e_ui
  suite; a live round-trip needs the kimi CLI + Moonshot auth (not in CI).
  Same shape as the qwen_perm_test entry.
- test_run_harness_live_matrix: exclude `kimi` (needs the kimi CLI +
  Moonshot auth, like hermes) and `kimi-native` (terminal-first TUI via
  `omni kimi`, like kiro-/qwen-/goose-native) from the live gateway probe
  matrix, with docstring rationale mirroring the existing exclusions.

Both pass locally.

Co-authored-by: Isaac

---------

Signed-off-by: Ankush Bhatiya <ankushb@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: aravind-segu <aravind.segu@databricks.com>
2026-06-25 02:01:57 +00:00
Pat Sukprasert bdd950f4dd ci: drop the redundant merge-ready-rerun job from e2e/e2e-ui/integration (#1197)
merge-ready.yml already re-evaluates the gate on `workflow_run` completion
of E2E Tests / E2E UI Tests / Integration Tests, and ci.yml has never had
an explicit re-dispatch -- it relies solely on that workflow_run hop and
works fine. The explicit rerun existed mainly to cover the fork/mirror
push path's brittle workflow_run association (#751/#792); #1004 retired
the mirror and restricted the rerun to same-repo PRs, leaving it doing
exactly what the workflow_run trigger already does. Remove it.

`/merge` and merge-ready's workflow_dispatch entry point remain as manual
re-evaluation fallbacks.

Co-authored-by: Isaac
2026-06-25 08:46:33 +07:00
Pat Sukprasert 1dc50a31a9 fix(e2e): raise REPL launch timeout above the CLI's own cold-start budget (#1195)
test_repl_approval_e2e spawned `omnigent run` with a 60s pexpect
timeout for the launch phase (the first test bears the one-time
daemon + local-server cold boot for the module; the rest reuse it).
But the CLI's own internal cold-start budget is sequential on the
critical path of every launch and sums to ~106s worst case:

  wait_for_host_online           up to 30s
  launch_or_reuse_daemon_runner  ~16.5s  (transient-409 reconnect retry)
  wait_for_runner_online         up to 60s

A 60s test timeout sits *below* that budget, so on the rare slow path
(loaded CI runner, host-tunnel reconnect) the test aborts — still
animating the "Launching your agent…" spinner, before the approval
path is ever reached — earlier than the CLI itself would. That is the
observed flake (TIMEOUT waiting for the ask-demo welcome banner).

Lift the launch-phase timeout to a single `_LAUNCH_TIMEOUT = 120`
constant (internal budget + margin, still under the `--timeout=180`
per-test cap) applied at all 24 spawn / `_wait_for_prompt_ready`
sites. The median launch is a few seconds, so this ceiling only bites
on the tail. The post-launch assertion timeouts (approval, echo,
turn-complete) stay tight so a real hang *after* launch still fails
fast. Also de-stale the docstrings' DBOS references (DBOS has been
removed from the runtime).

Co-authored-by: Isaac
2026-06-25 08:45:54 +07:00
Michael Gardner 6f0257dbc7 feat(kiro): add native CLI harness (#899)
* feat: add Kiro native CLI harness

Signed-off-by: Michael Gardner <gardnmi@gmail.com>

* fix(kiro): avoid ambient env in tmux attach

Signed-off-by: Michael Gardner <gardnmi@users.noreply.github.com>

* fix: restore uv.lock pypi.org sources (drop accidental databricks-proxy re-lock)

A local `uv run` during the merge re-locked uv.lock against this machine's
Databricks-internal pypi proxy, flipping every package source URL. Kiro changes
no dependencies and pyproject.toml is unchanged vs main, so restore main's
uv.lock verbatim (pypi.org sources). Only registry URLs differed — no version
or hash changes.

Co-authored-by: Isaac

* test(e2e-ui): add native-kiro render-parity suite (E2E UI Required gate)

The E2E UI Required gate flagged that #899 changes the agent-picker/session UI
(adds Kiro) without a tests/e2e_ui/** test. Add test_native_kiro_render_parity.py
mirroring the cursor/goose siblings — composer-IN parity, a TUI-originated turn
surfacing OUT, and no duplicate rendering — plus the native_kiro_session fixture.
Skip-gated on kiro-cli + tmux, so it skips in CI (no Kiro account provisioned)
exactly like the goose/cursor suites, and runs for real where Kiro is signed in.

Verified: collects + skips cleanly (kiro-cli absent); ruff clean.

Co-authored-by: Isaac

* fix: restore ap-web/package-lock.json npmjs.org sources (drop databricks npm-proxy)

Same root cause as the uv.lock fix: an npm command during round-1 merge re-resolved
one dependency (yaml-1.10.3) against this machine's Databricks-internal npm proxy
(npm-proxy.cloud.databricks.com), which CI (pinned to registry.npmjs.org) can't reach
-> 'npm ci' ETIMEDOUT. ap-web/package.json is unchanged vs main and Kiro adds no npm
dependency, so restore main's package-lock.json verbatim (clean npmjs.org sources).

Co-authored-by: Isaac

* test(e2e): exclude kiro-native from the live-harness matrix coverage check

test_run_harness_live_matrix_covers_registered_coding_harnesses asserts every
registered coding harness is either in the live no-AGENT e2e matrix or explicitly
excluded. kiro-native is a terminal-first TUI launched via `omni kiro` (tmux pane
+ bridge dir), not `omnigent run --harness kiro-native`, so — like goose-native /
qwen-native / cursor-native — it can't run in this matrix. Add it to the exclusion
set with the matching rationale; its coverage is the kiro-native bridge/executor/
forwarder unit tests + the test_native_kiro_render_parity e2e_ui suite.

Co-authored-by: Isaac

* test(ap-web): set isNativeWrapper in /compact composer menu tests

#1139 gated "/compact" behind isNativeWrapper (hidden for non-native
harnesses), but the three slash-menu-UX tests that assert "/compact"
tops/appears in the suggestions still rendered a non-native composer,
so they now fail on main (and on every PR that merges main).

Render those three with isNativeWrapper:true so "/compact" is offered,
restoring the built-in ordering the tests pin. Test-only; no behavior
change. Fixes the inherited ChatPage.composer.test.tsx red on this PR.

Co-authored-by: Isaac

* test(kiro): cover kiro_native launcher helpers (raise coverage 43%→70%)

The kiro-native launcher (omnigent/kiro_native.py) was the largest
coverage gap on this PR: its CLI/daemon orchestration is only exercised
by the live render-parity e2e, which skips in CI when kiro-cli is
absent. Add focused unit tests (with a fake httpx client) for the
unit-testable surface: executable resolution, launch-argv assembly,
terminal-payload decoding, tmux attach gating, startup-progress
forwarding, preflight, resume-id resolution, and the create/fetch/
ensure/find/wait session helpers (success + error branches).

Lifts kiro_native.py from 43% to 70%; remaining misses are the
daemon-driven async orchestration covered by runner/e2e paths.

Co-authored-by: Isaac

* test(kiro): rename test env var to avoid exfil-scan false positive

The CI exfil scanner flags any added file containing a secret-named
source (regex `[A-Z0-9]+_SECRET\b`) together with a network sink. The
tmux-allowlist test used `OMNIGENT_SECRET` purely as a non-allowlisted
sample var, which matched the secret regex and — combined with the
fake httpx client's .post()/.get() in the same file — tripped the
"secret-named source + network sink" block. Rename it to a neutral
`OMNIGENT_UNLISTED_VAR`; the test's intent (filtering non-allowlisted
keys) is unchanged.

Co-authored-by: Isaac

---------

Signed-off-by: Michael Gardner <gardnmi@gmail.com>
Signed-off-by: Michael Gardner <gardnmi@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-06-25 00:55:25 +00:00
Corey Zumar 3804401b20 docs(readme): list all sandbox providers in the cloud-sandboxes highlight (#1184)
The highlight listed only Modal / Daytona / Islo. Add the other launchers
that ship in the repo -- E2B, CoreWeave, Kubernetes, OpenShell, Boxlite --
as uniform peers in the list, each linked to its canonical site. The
Kubernetes provider (server-managed on-demand Pods) landed in #881.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-24 17:45:45 -07:00
ckcuslife-source e1b18d239f fix(cost): clamp self-reported session cost monotonic to harden budget gate (#1176)
The cost-budget policy enforces DENY/ASK against `session_usage`
(`total_cost_usd` / `policy_cost_usd`), but those values are written by
the `external_session_usage` event under pure SET semantics. That event
is posted with the session owner's own bearer token (the native
forwarder carries no privileged identity), so an owner can replay it
with a falsified low cost: SET would reset the gate's cost to ~0 —
disabling the budget cap — and the daily rollup's `new - old` delta
would go negative, clawing back already-spent per-user daily budget.

Clamp `total_cost_usd` (both the explicit-cost and token-priced
branches) and the enforcement `policy_cost_usd` to `max(old, new)`, and
floor the daily-rollup delta at 0. Cumulative billed cost only ever
rises within a session, so this is a no-op for legitimate reports; a
forged downward report becomes a no-op instead of a bypass. When an
in-flight estimate later resolves below a prior peak the clamp keeps the
peak — conservative, the safe direction for a budget gate.

This is a partial mitigation (Tier 1): it stops the reset/claw-back
vector. It does NOT stop a user who controls the reporting process
itself from under-reporting; closing that requires server-side metering.
2026-06-24 17:45:30 -07:00
Yuan Tang 6141b6691b feat(web): add size and type sort options to changed-files list (#988)
* feat(web): add size and type sort options to changed-files list

Extend the Changed files flat list with two new sort modes (Size and
Type) alongside the existing Filename and Last Edited options. The
selected sort preference is now persisted in localStorage so it
survives page reloads.

* fix: update filesPanelPreferences tests for new sort field

Add the required `sort` property to test assertions and
`writeFilesPanelPreferences` calls. Add a test for invalid sort
value fallback.

* fix: update AppShell test assertion for sort field in preferences

The persisted preferences now include the sort field, so the
localStorage assertion must expect the full object.

* fix: move ChangedSort type to lib/, fix formatting and lockfile

- Extract ChangedSort type and isValidSort to lib/changedSort.ts so
  lib/filesPanelPreferences.ts no longer imports from shell/ (fixes
  inverted dependency flagged in review).
- Fix Prettier formatting in AppShell.test.tsx.
- Regenerate package-lock.json.

* fix: correct deep-link test assertion for unchanged localStorage

The deep-link test seeds localStorage with the old format
(changedOnly only). Since the deep-link override is transient and
must NOT rewrite preferences, the stored value should remain as
originally seeded.

* fix: update test assertions for /compact visibility and deep-link prefs

- ChatPage.composer tests: /compact is now hidden for non-native-wrapper
  sessions (upstream change), so the first menu match is /context, not
  /compact. Update 3 tests accordingly.
- AppShell deep-link test: the stored preference should remain as
  originally seeded (old format without sort/collapsed) since the
  deep-link override is transient and must not rewrite preferences.

* feat(web): add sort options to the All files tree

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

* test(e2e_ui): cover Files panel sort in the All view

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

* test(web): align composer slash-menu assertions with main's /compact ordering

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-24 17:42:00 -07:00
Sabhya Chhabria 5f846b606a fix(antigravity-native): materialize web-turn attachments instead of dropping them (#1175)
A web/mobile user who attached an image/file to an antigravity-native turn
lost it silently: `_content_to_text` only collected `input_text`/`text`
blocks and skipped `input_image`/`input_file`, so the bytes were never
persisted and no path marker was typed into agy. Attachment-only turns were
worse — `_latest_user_text` returned `""` and `run_turn` hard-errored with
"Antigravity native turn had no user text to send".

Mirror cursor-native (the closest analog, which also types into a vendor TUI
over tmux): thread `self._bridge_dir` into `_content_to_text`/`_latest_user_text`,
materialize image/file blocks via the shared `materialize_attachment` helper,
and prepend `[Attached: <path>]` so agy can open the file with its Read tool.
Drop the now-stale docstring claims that bytes cannot be sent through this path.

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 17:06:24 -07:00
Dhruv Gupta edbdca8c0e feat(native): Hermes native TUI harness + synced web approval for hermes-native & goose-native (#1163)
* feat(hermes): add native Hermes TUI harness (hermes-native)

Adds `hermes-native`, the native counterpart to the headless `hermes`
harness (#1132), following the goose-native pattern: `omnigent hermes`
launches the real `hermes` prompt_toolkit TUI in a runner-owned tmux
pane, the harness executor injects each web turn via tmux bracketed
paste, and a forwarder tails Hermes' SQLite `state.db` to mirror the
transcript back into the Omnigent chat view.

Unlike goose-native, Hermes auto-generates its session id (no `--name`),
so the forwarder discovers the session cursor-native style: newest
`sessions` row whose `cwd` matches the workspace and `started_at` is
at/after the launch floor, with a claim guard for concurrent same-cwd
sessions. Like goose-native it applies no Omnigent policy hooks — the
TUI's own approval prompts gate tools, using the user's own `~/.hermes`
config.

New modules: hermes_native.py (CLI), hermes_native_bridge.py (tmux
inject), hermes_native_forwarder.py (state.db mirror),
inner/hermes_native_executor.py + hermes_native_harness.py. Wires the
harness registry, aliases, native-coding-agent metadata, runner terminal
spawn/interrupt/stop, CLI subcommand, resume dispatch, onboarding
readiness, and the ap-web frontend entry. Adds unit tests for the
executor, CLI/wiring, and forwarder (discovery, claim guard, mirroring).

Co-authored-by: Isaac

* fix(ap-web): add "hermes" to ConversationIconKind so the web UI builds

getConversationIconKind returns a native agent's iconKind (now including
"hermes") as a ConversationIconKind; the union was missing "hermes", so
`tsc -b` failed (TS2322) and broke `omnigent[all]` install (web UI build).
Mirrors how "qwen" — also glyph-less — is listed in both unions.

Co-authored-by: Isaac

* fix(hermes-native): render as a native terminal + keep the gold TUI colors

Two fixes from live testing:
- Add `terminal_hermes_main` to ap-web's AGENT_TERMINAL_IDS so isAgentTerminalKey
  recognizes the hermes pane as the agent terminal; without it isShellView
  treated it as a plain shell (and it leaked into the Shells inventory) — the
  same regression pi/cursor/goose/qwen each hit. Adds the matching test.
- Drop the NO_COLOR=1 env on the hermes terminal: it disabled Hermes' themed
  TUI (gold prompt rendered white). The bridge captures the pane with
  `capture-pane -p` (ANSI stripped) and the forwarder reads SQLite, so color
  never interferes with scraping.

Co-authored-by: Isaac

* feat(hermes-native): route tool calls through Omnigent policy (web approval)

The native Hermes TUI now gates tools via Omnigent's approval flow, matching
claude-/codex-native. The runner builds a per-session HERMES_HOME (the user's
full ~/.hermes config copied in, minus state.db, + Omnigent's pre_tool_call
shell hook layered on) and launches the TUI with HERMES_HOME=<dir> and
HERMES_YOLO_MODE=1. The hook calls the server's policy evaluate endpoint, which
parks on an ASK policy until the human responds to the web approval card; YOLO
suppresses Hermes' own in-TUI prompt so the web card is the sole gate (the hook
fires before, and independent of, Hermes' approval check per model_tools.py).
The forwarder tails the per-session HERMES_HOME/state.db. Adds a unit test.

Co-authored-by: Isaac

* feat(goose-native): route tool calls through Omnigent policy (web approval)

The native Goose TUI now gates tools via Omnigent's approval flow. The runner
builds a per-session GOOSE_PATH_ROOT holding an Open-Plugins `omnigent-policy`
plugin whose PreToolUse hook calls the server's policy evaluate endpoint (which
parks on ASK until the human answers the web approval card). Goose's PreToolUse
hook fires independent of GOOSE_MODE and denies on `{"decision":"block"}` — the
same contract as the hermes hook.

GOOSE_PATH_ROOT relocates all of Goose's dirs, so we symlink the real
config/data/state back in (preserving the user's auth + the sessions.db the
forwarder tails); the plugin lives only under the per-session root, so standalone
`goose` never sees it. The hook reads its per-session _OMNIGENT_* values from the
terminal env (Goose inherits env into hooks; verified no env_clear), failing open
when unset. GOOSE_MODE=auto suppresses Goose's own in-TUI prompt so the web card
is the sole gate. Real dirs are resolved by parsing `goose info` (ANSI- and
space-tolerant); if they can't be parsed we launch without gating rather than
break auth. Adds unit tests for the parser and plugin builder.

Co-authored-by: Isaac

* feat(policies): ask_on_os_tools recognizes Goose native tools

Goose namespaces its built-in developer tools as developer__shell /
developer__write / developer__edit / developer__text_editor / etc. Add them to
ask_on_os_tools so the standard approval policy gates a native goose session's
shell/file tools (web approval card) — without this the policy silently no-ops
for goose-native. Adds parametrized coverage mirroring the pi/hermes cases.

Co-authored-by: Isaac

* fix(native): restore vendors' in-TUI approval (drop YOLO/auto + policy-hook gating)

The policy-hook approach suppressed each vendor's own tool-approval prompt
(HERMES_YOLO_MODE=1 / GOOSE_MODE=auto) so only a web card gated — which meant
approvals showed only in the web chat, never in the TUI, and Hermes ran on YOLO.
That's the wrong model for native TUIs.

Revert the runner wiring to vendor-native approval: no HERMES_HOME/YOLO (Hermes
uses ~/.hermes and its own approval prompt; forwarder tails ~/.hermes/state.db),
and GOOSE_MODE=smart_approve so Goose prompts in its TUI. The prompt now appears
in the terminal AND the web's embedded terminal pane (answerable from either).

This is also step 1 of the chosen cursor-native-style synced mirror; step 2 (a
web elicitation card mirrored from the TUI prompt) lands next. The per-session
HERMES_HOME / GOOSE_PATH_ROOT policy-hook helpers are left in the tree, unused,
pending that follow-up.

Co-authored-by: Isaac

* feat(native): synced web approval mirror for hermes-native & goose-native

Surfaces each vendor's in-TUI approval prompt as a web elicitation card, synced
both ways (answer in the terminal OR the web card) — the cursor-native pattern,
now for Hermes and Goose. The vendor's own prompt stays the source of truth and
the fallback; nothing is suppressed.

- Generic POST /sessions/{id}/hooks/native-permission-request route: parks for
  the web verdict and labels the card per-vendor (agent/policy_name from body).
- hermes_native_permissions.py: detects Hermes' `DANGEROUS COMMAND` /
  `Choice [o/s/a/D]:` block (confirmed against hermes-agent locales/en.yaml by
  running it from source), sends `o` (approve) / `d` (deny).
- goose_native_permissions.py: detects Goose's cliclack `do you allow?` +
  Allow/Deny radio (from goose-cli prompt_tool_confirmation) and DRIVES the
  selector — `Enter` for the default Allow, `Down`×N + `Enter` for Deny (N=2
  with "Always Allow", else 1).
- capture_/send_*_pane helpers on both bridges; both mirrors run alongside the
  transcript forwarder under one supervised runner task (like cursor).

The goose arrow-select driving is position-dependent and the one part worth
confirming against a live Goose. Adds parser unit tests for both.

Co-authored-by: Isaac

* chore(native): drop the reverted policy-hook code, superseded by the mirror

The earlier policy-hook elicitation approach (per-session HERMES_HOME and
GOOSE_PATH_ROOT plugin) was reverted in favour of the cursor-native-style synced
approval mirror, leaving its builders dead. Remove them: delete
inner/goose_native_hook.py, drop setup_hermes_native_home /
setup_goose_native_plugin_root / real_goose_dirs and their now-unused imports
from the bridges (keeping the capture_/send_*_pane helpers the mirror uses), and
remove the corresponding tests. Keep ask_on_os_tools' Goose tool-name coverage
(useful for any policy that gates goose tools) and the headless harness's
hermes_policy_hook.py (still used by `harness: hermes`).

Co-authored-by: Isaac

* fix(native): correct hermes approval detection + stop goose card pile-up

Two live bugs in the approval mirrors:

- goose cards piled up and re-appeared at the end: dedup keyed on a hash of the
  scraped tool context above the cliclack widget, which jitters every poll, so a
  new card parked each 0.3s and only the latest cleared on a TUI answer. Switch
  both mirrors to presence-edge: one card per visible-prompt episode (a per-
  session counter id), cleared on the falling edge.

- hermes elicitation never fired: the interactive TUI renders the gate as a
  prompt_toolkit PANEL titled "⚠️  Dangerous Command" with NUMBERED choices
  (1. Allow once … 4. Deny), not the legacy `Choice [o/s/a/D]:` input() prompt
  (fail-closed under prompt_toolkit) that the parser keyed on. Rewrite the parser
  to detect the panel + read each choice's digit from the panel, and answer with
  that digit (Hermes' number-key binding selects AND confirms). Robust to the
  permanent-allowlist option (Deny is 4 with it, 3 without).

Confirmed the panel/keys against hermes-agent cli.py by reading it; the goose
arrow-select driving and these pane formats still want a live confirm. Tests
updated to the real formats.

Co-authored-by: Isaac

* test(e2e_ui): add native Hermes render-parity suite (satisfies E2E UI gate)

Mirrors test_native_goose_render_parity for hermes-native: composer→TUI parity,
a TUI-originated turn surfacing in the web UI, and no duplicate rendering, plus a
native_hermes_session fixture. Skips when hermes/tmux/config are absent (CI
provisions no Hermes account), like the goose/cursor suites. Covers the ap-web
Hermes native-agent UI behavior the E2E UI Required gate flagged.

Co-authored-by: Isaac

* chore(openapi): regenerate openapi.json for native-permission-request route

The new POST /sessions/{id}/hooks/native-permission-request route made the
checked-in openapi.json stale, failing the Pytest (server-rest) drift test.
Regenerated via scripts/dump_openapi.py.

Co-authored-by: Isaac

* test(native): cover the bridges, approval mirrors, forwarder loop, and CLI helpers

The new native modules dropped total coverage below baseline (Coverage gate),
and the e2e suites that would exercise them skip in CI (no vendor binaries).
Add unit tests: tmux bridge (inject/capture/send/spawn-env, mocked tmux); both
approval mirrors (_run_one_approval keystrokes, external_elicitation_resolved,
one-card-per-episode supervise); the hermes forwarder loop (discover→mirror) +
_post_conversation_item; and hermes_native CLI/daemon helpers (spec, payload
decode, tmux-availability, daemon-flow HTTP via a fake client). Lifts the new
modules from ~46% to ~70-85%.

Co-authored-by: Isaac

* test(e2e): exclude hermes-native from the live no-AGENT harness matrix

Registering hermes-native broke test_run_harness_live_matrix_covers_registered_
coding_harnesses (it asserts the matrix covers every registered harness).
hermes-native is a terminal-first TUI launched via `omni hermes` (tmux pane +
bridge), not `omnigent run --harness hermes-native`, and wraps the hermes CLI —
so it's excluded like goose-native/qwen-native/antigravity-native. Its coverage
is the dedicated hermes-native unit tests.

Co-authored-by: Isaac
2026-06-24 17:01:15 -07:00
Sabhya Chhabria edf2c52735 fix(agy): drop literal markdown asterisks in permission card message (#1174)
The Antigravity permission elicitation set the message to
"Antigravity wants to run **{command}**". The web ApprovalCard renders
this message in a plain (non-markdown) <span>, so the asterisks showed
up literally instead of bolding the command. Drop the asterisks and use
"Antigravity wants to run: {command}", consistent with the no-command
fallback wording.

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 16:59:14 -07:00
Pat Sukprasert a8a0646060 fix(ci): exclude editable local packages from OSV pip-audit export (#1142)
The OSV advisory scan (added in #1001) runs `uv export --all-extras`
then `pip-audit` whenever a PR changes uv.lock. uv export emits the
local workspace members (the project itself and sdks/*) as editable
`-e` requirements, and pip-audit aborts on an editable path because it
"cannot be installed when requiring hashes" — so every PR that actually
adds or bumps a dependency fails the Security Gate (the editable crash
happens before any package is even checked).

Filter out the `-e` editable lines before handing the requirements to
pip-audit. Only third-party pinned packages are audited, which is all
OSV has advisories for anyway. Filtering all editable lines (rather
than naming each workspace member) stays correct if members are added.

Co-authored-by: Isaac
2026-06-25 06:52:55 +07:00
Bryan Li e5b25eef80 feat(sandbox): on-demand Kubernetes runner Pod sandbox provider (#881)
* feat(sandbox): on-demand Kubernetes runner Pod sandbox provider (entrypoint-as-host)

Adds the `kubernetes` managed-sandbox provider as an alternative to #881,
using the **entrypoint-as-host** launch model (the #39 "Option 2") instead of
the shared provision-then-exec model.

The runner Pod's container command IS `omnigent host`: an init container
prepares the workspace (mkdir + optional git clone), the main container runs
the host under a tiny PID-1 reaper, and the host dials back over the existing
launch-token tunnel. The token rides a per-Pod Secret (secretKeyRef), never
the Pod spec or an audit-logged surface.

Because the host is never started by exec-ing into a running container, this
drops — by construction — the entire pods/exec subsystem, the credential-over-
stdin path and its cross-provider `run_background(secret_env=...)` base change,
the PID-1 reaper-around-sleep, and the bun#31832 segfault workaround +
node_selector pinning. RBAC drops `pods/exec` and adds only namespace-scoped
`secrets` create/delete.

Shared-layer seam is minimal and additive: a `starts_host_at_provision` flag
plus `new_managed_sandbox_id` / `provision_managed_host` on SandboxLauncher
(default raise), and one branch in `_arm_and_start_host` that registers the
token before provisioning (closing the dial-back race) and rejoins the shared
online-wait + failure-cleanup. No app.py reconciler / host_store change in this
PR (deferred to a follow-up; restartPolicy:Never + labels cover the interim).

~3.1k insertions vs #881's ~6.9k; provider 1467 vs 2140, tests 493 vs 2945.

Tests: provider unit tests (manifest, render, provision/terminate, readiness
diagnostics via a fake client) + managed-host config-parse + entrypoint-seam
wiring. ruff + mypy clean. Live-cluster smoke test still recommended pre-merge.

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

* refactor(sandbox): collapse the managed host-start seam into one launch_host method

Replaces the entrypoint-model plumbing (a starts_host_at_provision flag +
new_managed_sandbox_id + provision_managed_host + a branch in
_arm_and_start_host) with a single overridable launcher method:

  - provision(name) -> str stays the step-1 primitive. Exec providers create
    the box (unchanged); kubernetes RESERVES the Pod name (no Pod yet), so the
    server can arm the launch token against the id before the box exists.
  - launch_host(sandbox_id, *, token, host_id, host_name, server_url, repo_*,
    on_stage) is a new concrete base method whose default IS the exec bootstrap
    (probe $HOME -> mkdir -> clone -> run_background the host), moved off the
    server's _start_host_in_sandbox/_clone_repo_workspace. Kubernetes overrides
    it to create the Secret + Pod.

The server flow is now branchless and uniform for every provider:
provision -> register_managed_host -> launch_host -> wait_for_host_online. The
arm-before-dial-back invariant holds by construction (provision fixes the id;
the token is armed before launch_host does anything that can dial back).

Net -188 lines; managed_hosts loses the four host-start helpers, base gains the
shared default. Other providers (modal/daytona/e2b/islo/cwsandbox/openshell)
inherit the default unchanged. A downstream entrypoint/orchestrating provider
(e.g. Databricks Lakebox) overrides launch_host like kubernetes does.

Tests: 339 passed (exec providers exercise the base default; renamed k8s +
entrypoint-seam tests cover provision-reserves + launch_host override). ruff +
mypy clean.

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

* refactor(sandbox): rename launch_host -> start_host

Word-boundary rename of the launcher method (and the matching test
attributes); relaunch_host / launch_managed_host are unaffected.

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

* docs(deploy): trim overlay verbosity; document in_cluster/kubeconfig/env/resources

Audit pass against the sibling deploy configs: the overlay was heavier than
siblings (e.g. postgres overlay) and duplicated README rationale inline, and the
config example/README omitted real config keys (in_cluster, kubeconfig, env).

- sandbox-config.yaml: trim verbose comments; add commented env / resources /
  in_cluster / kubeconfig examples (all parser-accepted keys).
- kustomization.yaml: cut the two-namespace preamble (it's in README.md); fix the
  '_ensure_sdk would fail every launch' overclaim.
- README.md: add env / in_cluster / kubeconfig rows + a 401 troubleshooting bullet.

Credential keys (ANTHROPIC_API_KEY/OPENAI_API_KEY/CODEX_ACCESS_TOKEN/GEMINI_API_KEY/
GIT_TOKEN) are kept — verified consistent with deploy/modal/README.md. RBAC and the
two-namespace security rationale in role.yaml kept (load-bearing, not frivolous).

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

* fix(sandbox): run k8s runner Pod as the host image's named sandbox user

The Pod pinned runAsUser/runAsGroup/fsGroup=1000, but the official host image
has no user at uid 1000 (only root + the OpenShell 'sandbox' user at 1000660000).
A uid with no /etc/passwd entry has no name, so the shell prompt shows glibc's
'I have no name!' fallback and whoami fails. Run as the image's existing non-root
'sandbox' user (1000660000) instead — still restricted-PSA compliant, but now a
named user (whoami -> sandbox). Verified on a real amd64 cluster.

NOTE: 'git commit' still needs a default identity (the sandbox user's gecos is
empty); that's an image-level follow-up (git config --system user.*).

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

* docs(deploy): drop checked-in placeholder creds Secret; document kubectl create secret

runner-credentials.yaml shipped placeholder values (sk-ant-REPLACE_ME) the
operator had to edit before applying. A checked-in Secret is an anti-pattern,
and the repo's base README already models the idiomatic alternative
(`kubectl create secret generic omnigent-oidc ...`). Remove the manifest and
document `kubectl create secret generic omnigent-creds -n omnigent-sandboxes
--from-literal=...` as a post-apply step (sealed-secrets/external-secrets for prod).

The rest of the overlay stays one-resource-per-file, matching every sibling
overlay (postgres/openshift/openshift-postgres) and kubebuilder/operator-sdk
convention — resource files are deliberately NOT bundled, since that would make
this the only overlay that diverges. Most idiomatic != fewest files.

Net: 10 -> 9 overlay files; `kubectl kustomize` builds identically minus the
placeholder Secret (the only rendered Secret is now the base's omnigent-secrets).

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

* docs(deploy): document server-auth + model-credential config for k8s sandboxes

Brings the k8s overlay README to parity with the islo/cwsandbox credential docs,
which cover three distinct concerns. The overlay had the model-creds piece but was
missing the framework-level server-auth interaction:

- Server auth (managed hosts): the host tunnel uses the per-launch token (the
  per-Pod Secret, automatic), but each session's runner tunnel needs a *server*
  identity — so header/OIDC-proxy or single-user works, while the built-in
  `accounts` provider refuses the runner dial-back (403). Shared by all providers.
- Model credentials: ride the omnigent-creds Secret (envFrom); references modal's
  variable table + the Claude-subscription `claude setup-token` recipe rather than
  duplicating it (cwsandbox's pattern).
- Git credentials: GIT_TOKEN in the same Secret.

Also fixes a broken ../README.md link and adds a troubleshooting bullet for the
accounts-auth runner 403. README 92 -> 152 lines, still tighter than the siblings.

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

* docs(deploy): surface managed-sandbox auth + creds guidance above the overlay README

The credential/auth guidance lived only in
deploy/kubernetes/overlays/sandbox-runners/README.md — three dirs deep, where
operators don't look (sibling providers keep theirs at deploy/<provider>/README.md).
Surface it at the two levels people actually read, linking down for detail:

- deploy/README.md (#auth): a framework-level note that managed sandboxes need
  header/oidc or single-user — the built-in `accounts` mode (the deploy DEFAULT)
  refuses the per-session runner dial-back (403). Applies to every provider; placed
  right where the auth mode is chosen.
- deploy/kubernetes/README.md (sandbox-runners section): a "Credentials & auth"
  callout splitting the two concerns (server auth vs model keys) with links to
  ../README.md#auth and the overlay README.

No content duplicated — the full table/recipes stay in the overlay + modal READMEs.

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

* docs(deploy): warn the harness creds Secret must exist before first launch

A runner Pod's envFrom secretRef (sandbox.kubernetes.secret_name) is
non-optional, so a missing omnigent-creds Secret stalls the Pod in
CreateContainerConfigError instead of launching. Document the ordering +
add a troubleshooting bullet.

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-24 16:45:35 -07:00
Sabhya Chhabria 0631811511 fix(antigravity-native): clean /quit no longer renders a spurious failed card (#1173)
Quitting agy normally (`/quit`, Ctrl-C) from the Terminal panel rendered a
red `required_terminal_exited` failure card and marked the session failed — a
normal user action misclassified as a crash.

Root cause: `antigravity-native` is deliberately excluded from the PTY
`emit_status` role set (the RPC reader owns working-status, not PTY activity),
so the exit-classification memo `_last_session_status` is never flipped to
`idle`; it stays `running`. On a clean quit, `_publish_terminal_exit`'s
`session_was_idle` guard therefore doesn't catch the clean exit and a `failed`
`required_terminal_exited` card is emitted.

Fix: extend the existing qwen-native clean-quit special-case in
`_publish_terminal_exit` to also match `antigravity` (publish a final `idle`
to clear the web spinner + release the harness, no failed card). This mirrors
qwen exactly. Genuine boot failures never reach here — they surface via
`_auto_create_antigravity_terminal`'s error handler →
`_publish_native_terminal_start_error` — so a post-boot antigravity
required-terminal exit is always user-initiated. The intentional `emit_status`
exclusion is left untouched.

Adds a parametrized regression test (qwen + antigravity) asserting a clean
quit publishes `idle` and releases the harness without a `failed` card.

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 16:44:00 -07:00
Sabhya Chhabria 38a11e9ce6 fix(antigravity-native): surface a model/turn ERROR instead of a silent empty reply (#1172)
An agy turn that ends in a model/safety/rate-limit/provider-overload ERROR was
indistinguishable from a normal empty reply: the step mapper committed nothing
(its PLANNER_RESPONSE branch emits only at DONE) and the reader closed the turn
on a plain `idle` edge. The user saw the spinner clear with no text, no error
card, and no retry hint.

Fix:
* Mapper (`antigravity_native_steps`): on a `CORTEX_STEP_STATUS_ERROR` planner,
  emit a visible assistant error item — preferring any `plannerResponse` error
  text, falling back to a generic marker (mirrors the tool-level error marker).
* Reader (`antigravity_native_reader`): close an ERROR turn on a `failed`
  session-status edge (a valid `external_session_status`) rather than `idle`, so
  the web UI shows the turn failed.

Verified: 159 antigravity steps + reader unit tests pass (incl. new
`TestPlannerResponseError` mapper coverage + the reader close-as-failed test).
ruff clean. (A real model ERROR can't be triggered on demand, so this is
unit-verified; the behavior is fully covered.)

Found in the antigravity-native bug-bash (one of 13 confirmed issues).

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 16:43:48 -07:00
Sabhya Chhabria 01db36d38a fix(antigravity-native): record the adopted TUI cascade as external_session_id so resume keeps the conversation (#1171)
* fix(antigravity-native): record the adopted TUI cascade as external_session_id so resume keeps the conversation

A fresh antigravity-native session recorded the WRONG agy cascade for resume, so
any resume / omnigent-server-restart silently loaded an EMPTY conversation —
the whole chat history vanished with no error.

Root cause: the cold-start `StartCascade`s a headless bootstrap cascade and
PATCHed THAT id as the session's `external_session_id`. But the agy TUI mints its
OWN cascade on the first typed turn (web turns are typed into the TUI), which the
read driver ADOPTS in place — and `external_session_id` is set-once, so the
adopted (real) id could never replace the phantom. Resume launches
`--conversation <external_session_id>` → the empty phantom.

Fix: the cold-start no longer records the phantom (runner `_cold_start_agy_conversation`
+ the CLI cold-start); instead the reader records the ADOPTED cascade as
`external_session_id` on first-cascade adoption (`_record_external_session_id`,
best-effort, set-once-safe). Now resume loads the conversation the TUI/web
actually used — parity with claude-native's external-session mirroring.

Verified live (agy 1.0.11): after a web turn, the session's external_session_id
is the adopted TUI cascade (`04109bed…`), NOT the cold-start phantom
(`169db340…`). Unit/integration: 332 antigravity + reader + executor + runner
tests pass; the adopt-in-place reader test now asserts the external_session_id
record; removed the dead cold-start-PATCH helper + its tests.

Co-authored-by: Isaac <isaac@example.com>

* style: ruff format (collapse _record_external_session_id call)

Co-authored-by: Isaac <isaac@example.com>

---------

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 23:24:03 +00:00
Yuan Tang 6c560885e8 fix(env): propagate KUBECONFIG to runner subprocesses (#1152)
KUBECONFIG was missing from _RUNNER_ENV_ALLOWLIST, so kubectl/helm/k9s
inside the agent's shell could not see the host user's configured
clusters, contexts, or namespaces when running via `omnigent claude`.
The env var is a filesystem path (not a bearer secret), analogous to
DATABRICKS_CONFIG_FILE which was already allowlisted.
2026-06-24 15:59:55 -07:00
Corey Zumar d00274af17 fix(cursor-native): cap mirrored response_id and harden the mirror poll loop (#1164)
* fix(cursor-native): cap mirrored response_id and harden the mirror poll loop

The forwarder set response_id = "cursor:" + <64-char blob hash> (71 chars),
overflowing conversation_items.response_id (VARCHAR(64)); on Postgres every
mirror POST 500'd, and because the poll loop advances its high-water rowid only
after a successful POST, it wedged on the first message and re-posted it forever
-- mirroring nothing and flooding the app.

- Cap response_id at the column width (64).
- Bound per-item POST failures: a server rejection (4xx/5xx) is retried a few
  polls then skipped; an ambiguous "maybe delivered" failure is skipped to avoid
  a duplicate bubble; a connection failure retries indefinitely. One poison item
  can no longer wedge the mirror or flood the app.
- Unit tests for the cap and the three failure branches (driving the real loop).
- CI-runnable e2e_ui mirror test: seed a cursor store, run the real forwarder
  into the spawned server, assert the content renders in the web chat. The live
  render-parity test's skip moves from module-level to a per-test gate so the new
  test runs on every PR (cursor-agent has no mock-LLM path).

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

* chore(cursor-native): address PR review comments

- Tests: drain the cancelled forwarder task via
  asyncio.gather(task, return_exceptions=True) instead of
  `with contextlib.suppress(...): await task`, which the code-quality bot
  flagged as an ineffectual statement. Behavior-preserving; drops the
  now-unused contextlib import in both test files.
- Forwarder: note that the response_id cap can theoretically alias the
  (non-unique, non-dedup) grouping key -- only groups two messages under one
  UI response, never data loss (per Polly review note).

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-24 15:56:11 -07:00
Zeyi (Rice) Fan 003b09ff41 fix(ios): lock shell to visual viewport so the keyboard can't pan the page (#1167)
## Related issue

N/A

## Summary

- On the iOS shell the native side keeps the WKWebView full-height when the
  keyboard opens (`.ignoresSafeArea(.keyboard)`) and the web shell is sized to
  `100lvh`, so a focused composer/terminal input sits behind the keyboard and
  WebKit pans the whole document up to reveal it — hiding the header and letting
  the entire page scroll.
- Add `useIOSViewportLock` (called once in `AppShell`): it publishes the live
  `visualViewport.height` to `--omnigent-viewport-height` and snaps any residual
  document pan back to the top. No-op off the iOS shell; scoped to the shell so
  auth pages keep normal scrolling.
- Size `[data-ios-native].app-shell` to `var(--omnigent-viewport-height, 100lvh)`
  so the shell shrinks with the keyboard: inputs stay above it, the header stays
  put, and only inner panes (conversation history, terminal, page bodies) scroll.
- Reconcile keyboard plumbing now that the shell is resized:
  `getIOSNativeKeyboardInset` measures against the layout viewport
  (`window.innerHeight`) instead of the app-shell (which would now read ~0),
  keeping the fixed full-viewport `TerminalsPanel` correct and fixing
  `useIOSNativeKeyboardVisible` detection. Drop the now-redundant manual keyboard
  padding from the flow-based `MainTerminalView` (the shell-lock handles it).

## Test Plan

- `npm run type-check` — passes.
- `npx oxlint` on changed files — clean (only the pre-existing
  `clearFileViewerUrl` exhaustive-deps error in AppShell, confirmed on the base).
- `npm run build` — succeeds; `--omnigent-viewport-height` present in built CSS.
- `npx vitest run` — full suite green, no unexpected failures.
- Manual on-device check still recommended: focus the composer and the terminal
  input on a notched simulator and confirm the header stays fixed, the page no
  longer pans, the input sits above the keyboard, and inner panes still scroll.

## Type of change

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

## Coverage notes

This is iOS WKWebView keyboard/viewport layout behavior that can't be exercised
in jsdom. Verified via type-check, lint, production build (confirming the new
CSS var is emitted), and the full vitest suite (no regressions). The remaining
visual confirmation — header stays fixed and the page no longer pans when the
keyboard opens in chat and terminal views — must be done on a simulator/device
against a live server.
2026-06-24 22:39:29 +00:00
Sabhya Chhabria 8a68b30d85 fix(antigravity-native): unify the agy TUI and web mirror onto one cascade (#1156, #1158) (#1166)
Web turns were delivered over headless `SendUserCascadeMessage` RPC onto a
`StartCascade`-minted cascade the agy TUI never displays, while the agy TUI ran
on its OWN cascade — so the two desynced in both directions:
  * web turns never echoed in the agy TUI (#1156)
  * turns typed directly into the agy TUI never mirrored to the web (#1158)

Converge antigravity-native onto the agy TUI as the single source of truth,
matching claude/codex native:

* Write path (`AntigravityNativeExecutor._deliver`): deliver web/mobile turns by
  TYPING them into the agy TUI pane (`inject_user_message_via_tui`) instead of
  headless RPC. The turn now renders in the TUI AND lands on the cascade the TUI
  displays; agy records it as a real `USER_INPUT` (what the read driver keys on).
  RPC stays the read/control transport only (stream / trajectories / cancel /
  interaction).

* Read path (`run_reader_with_bridge`): when the bound cascade committed NO turns
  (the cold-start `StartCascade` phantom) and the TUI mints its own cascade on the
  first typed turn, ADOPT that cascade in the SAME Omnigent session (rewrite bridge
  state, no fork) instead of misreading it as a `/clear` and forking a new session
  — which stranded the user's session empty while the turn filled a forked one. A
  genuine `/clear` (bound cascade HAD turns) still forks. `supervise_reader` now
  reports the committed-turn count for this decision.

Result: bidirectional agy-TUI <-> web sync on ONE cascade — web turns appear in the
TUI and mirror to the web; TUI-typed turns mirror to the web — true parity with
claude/codex native.

Verified live against agy 1.0.11 on a local server: a web turn renders in the TUI
and commits to the ORIGINAL session (user-before-assistant); a 2-turn flow stays
on one session as [user, assistant, user, assistant]; the reader logs "adopted the
first TUI-minted cascade in place (no fork)". Unit: 103 executor+reader tests
(incl. new adopt-in-place + TUI-inject-error coverage), 311 broader antigravity
tests, and 205 runner-native integration tests pass; ruff clean.

Note: the now-unused RPC-delivery helpers (`_resolve_ready_cascade_id` /
`_resolve_plan_model` / `_wait_for_state` + model-resolution fns) are retained for
a focused follow-up cleanup; the live write path is `_deliver` -> TUI inject.

Fixes #1156
Fixes #1158

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 22:01:14 +00:00
Zeyi (Rice) Fan 36b2a11c4a feat(ios): unify shell inset handling into a single system (#1162)
## Related issue

N/A

## Summary

- Replace the ad-hoc, per-page padding and the duplicated `[data-ios-native]`
  CSS magic numbers with one inset system. A single set of composite CSS
  variables (`--omnigent-inset-top/bottom`, `--omnigent-header-height`) in
  `index.css` is the source of truth; off the iOS shell they resolve to plain
  `env(safe-area-*)`/0, so the same code works in browser, Electron, and iOS
  with no `isIOSShell()` branching.
- Make the native layer the source of truth for the floating bars' footprint:
  a shared `InsetMetrics` in Swift drives both the SwiftUI layout and a new
  `emitInsets` bridge push; `nativeInsets.ts` mirrors it into the CSS vars.
  Bar visibility (already web-owned) is folded in at the existing bridge call
  sites. This kills the native<->CSS drift that the hardcoded spacer had.
- Add a shared `<PageScroll>` primitive that owns header clearance + top/bottom
  insets, and adopt it across Inbox, Settings, Members, and Policies. Auth
  pages (Login/Register) get safe-area padding without breaking centering.
- Fix the reported bug: Inbox/Settings buttons covered text because those pages
  reserved nothing for the native bottom bar and omitted `safe-area-inset-*`.

## Test Plan

- `npm run type-check` (clean), `npx oxlint` on changed files (only a
  pre-existing `_bootProbe` warning), `npm run build` (succeeds; confirmed the
  new inset vars are present in the emitted CSS).
- `npx vitest run`: 2990 passed; the only 3 failures are in
  `ChatPage.composer.test.tsx` and were confirmed pre-existing on a clean tree
  (ChatPage untouched). Native bridge tests pass 27/27.
- iOS: `swift format lint` clean; `xcodebuild` for the Omnigent scheme on the
  iPhone 17 Pro simulator -> BUILD SUCCEEDED.

## Type of change

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

## Coverage notes

Verified via web type-check, oxlint, the full vitest suite, and a production
build (inset CSS vars confirmed in the bundle output), plus an iOS simulator
build (BUILD SUCCEEDED) and swift-format lint. The bridge changes are covered
by the existing `nativeBridge.test.ts` (27/27). Runtime visual confirmation on
a notched simulator (content clearing the native bars, visibility toggling the
bottom inset) is the remaining manual step and needs a live server to render
Inbox/Settings end-to-end.
2026-06-24 21:19:27 +00:00
Zeyi (Rice) Fan faa43a8018 fix(ios): animate sidebar toggle and add drawer leading-edge shadow (#1147)
## Summary

- The iOS shell's mobile sidebar drawer snapped open/closed when toggled
  via the collapse/expand button — no animation. Root cause: this is
  Tailwind v4, where `translate-x` utilities move the panel via the
  `translate` CSS property, but the `[data-ios-native] .conversations-sidebar`
  override (which wins on specificity over the web's `transition-transform`
  class) declared only `transition: transform`. So the button toggle changed
  an untransitioned property and snapped; the drag animated only because it
  sets an inline `transform`. Switched the rule to transition both `transform`
  and `translate`, which also smooths drag-to-close.
- Added a leading-edge `box-shadow` to the drawer (plus a stronger dark-mode
  variant) so it reads as a native layer lifted above the chat as it slides,
  instead of a flat sheet. Gated with `:not([data-collapsed])` — the existing
  open-vs-collapsed convention — so the full-bleed overlay casts no sliver
  along the screen edge while parked off-screen.
- Both rules are scoped to `[data-ios-native]` inside `@media (width < 48rem)`,
  so the desktop and mobile-web experiences are untouched.

## Type of change

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

## Coverage rationale

Ran `npx vitest run src/index.css.test.ts` (6 passing) — its regression
suite parses the real CSS source and pins the `:not([data-collapsed])`
open-vs-collapsed selector convention this change reuses for the shadow.
Visual slide/shadow behavior verified manually in the iOS shell; no
test harness drives WKWebView CSS rendering.

Co-authored-by: Isaac
2026-06-24 21:08:00 +00:00
Sabhya Chhabria d07b4edb51 fix(antigravity-native): register terminal_antigravity_main as an agent terminal so Chat/Terminal toggle shows (#1157) (#1160)
`terminal_antigravity_main` was missing from `AGENT_TERMINAL_IDS`, so the
agy TUI pane read as a *user shell*: `isShellView` hid the Chat/Terminal
pill in Terminal view, stranding the user in the terminal with no way back
to Chat, and the pane leaked into the Shells inventory. Same failure mode
(and fix) as the earlier pi/cursor/goose/qwen omissions.

Add the id to the set, extend the docstring, and add a regression test
mirroring the sibling native panes.

Fixes #1157

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 21:06:16 +00:00
Sabhya Chhabria fb6dd69bbf fix(antigravity-native): commit the user turn from the read path so it renders above the reply (#1155) (#1159)
The pure-RPC web/mobile write path (`SendUserCascadeMessage`) fires no
"direct POST /events" to persist the user's turn, yet the step mapper
skipped `CORTEX_STEP_TYPE_USER_INPUT` on exactly that assumption — so the
user message was NEVER committed to the omnigent session. The web UI's
optimistic input bubble had no committed counterpart to reconcile against
and dropped below the streamed assistant reply.

Mirror the user turn from the read path (parity with claude/codex/cursor
native, which all commit the user message from their forwarder): emit a
committed `message` item (role `"user"`) for `USER_INPUT`, extracting the
text from `userInput.userResponse` (fallback `userInput.items[].text`).
The turn opens on the `USER_INPUT` step — before the planner response —
so the user message commits first and renders above the reply. The reader
dedups `USER_INPUT` by its per-turn `executionId`, so it emits exactly
once per turn.

Verified: 221 antigravity unit tests pass; the two-turn reader regression
now asserts `[user, assistant, user, assistant]` ordering.

Fixes #1155

Co-authored-by: Isaac <isaac@example.com>
2026-06-24 14:03:53 -07:00
Bryan Li da05b924f3 feat: Antigravity harness (SDK + native agy CLI) at parity with claude/codex (#892)
* build(antigravity): add google-antigravity SDK dep + host image (agy CLI, lsof, procps)

The antigravity SDK harness needs the google-antigravity package; the managed
host image needs the agy CLI on PATH plus lsof/procps for the executor's process
discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity): onboarding — agy auth, harness install/readiness, Gemini provider config

Detects/installs the agy CLI, recognizes the Gemini provider family + GEMINI_API_KEY,
and wires antigravity into the model catalog, override resolution, and effort levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): native agy harness — registration, bridge state, launch + TUI delivery

Registers the antigravity-native harness (aliases, wrapper labels, resume
dispatch), the launch config, and the per-conversation bridge state. The bridge
also carries the tmux send-keys delivery (inject_user_message_via_tui) used to
type web turns into the agy TUI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): transcript forwarder (read path) + connect-RPC discovery

Mirrors agy's JSONL transcript into the Omnigent session (with post-hoc policy
audit), and discovers agy's connect-RPC port by conversation-ownership probe so
the forwarder can bind the right brain dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): TUI web-turn executor + runner/runtime/server wiring

The executor types every web turn into the agy TUI (a connect-RPC SendAgentMessage
is logged as a SYSTEM_MESSAGE the forwarder would not mirror), and the runner
auto-creates the agy terminal + forwarder, advertising its tmux pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity): ap-web — agent card, new-chat flow, native-agent wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(antigravity): e2e-ui new-chat picker shows Antigravity + terminal labels

Adds the tests/e2e_ui gate test for the ap-web changes: stubs /v1/agents with the
native Antigravity agent, opens the new-chat composer, asserts the agent chip
renders the harness-derived label 'Antigravity' (not the raw 'antigravity-native-ui'),
and that send POSTs the terminal-first wrapper labels (omnigent.ui=terminal,
omnigent.wrapper=antigravity-native-ui). Mirrors the pi-native picker test; runs
against a no-agent server (agent-independent UI behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): use os.environ.copy() to clear exfil scanner

The Security Scan's exfil-scan.py flags `dict(os.environ)` in added lines
as a wholesale-environ-dump shape (regex `(json.dumps|dict|str|repr)\(\s*
os.environ`). The direct-tmux-attach helper only copies the environment to
drop TMUX before exec'ing `tmux attach` -- a legitimate subprocess-env
build, byte-identical to the sibling claude/pi native harnesses, not an
exfil. Switch to the idiomatic `os.environ.copy()` (already used in
omnigent/onboarding/sandboxes/bootstrap.py), which returns the same
dict[str, str] snapshot and is not matched by the heuristic. No behavior
change; unblocks Security Scan and the 7 cascading Security Gate checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): make launch tests hermetic (stub agy binary)

The four `test_launch_and_record_*` tests drove `_launch_and_record` →
`build_agy_launch`, which uses `agy_binary_path()` as argv[0] unconditionally
and raises `RuntimeError` when agy is absent from PATH — true in CI. They only
passed locally because agy happens to be installed. One test tried to patch
`_mod.agy_binary_path`, but `build_agy_launch` resolves the name in its OWN
module (`antigravity_native_launch`), so that patch was ineffective.

Add an autouse fixture that stubs `agy_binary_path` at both lookup sites
(launch module + the antigravity_native re-export), and drop the ineffective
per-test patch. Proven via a no-agy reproduction: the real resolver raises,
the tests fail without the fixture and pass with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(onboarding): keep gemini out of the openai-family "Other provider" picker

Adding the `gemini` catalog provider (for the antigravity SDK flavor) put it in
`key_providers()` but not in `_PRESET_KEY_PROVIDERS`, so `other_key_providers()`
no longer excluded it. Gemini then leaked into the openai-family "Other
provider" catch-all — whose tail is documented as "all openai-family" — and,
sorting before `xai`, became picker entry #1. Selecting "Other → #1" stored the
entry under the `gemini` family (KeyError: 'openai' in the add-other test).

Gemini already has its own "Gemini — API key" top-level entry (gemini-family
scoped), so it belongs in `_PRESET_KEY_PROVIDERS` like openai/anthropic/
openrouter. Add it there; update test_add_menu_options_ordering for the new
first-party Gemini key entry and assert the gemini-family scoped subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ap-web): stub AntigravityIcon in test-setup so suites load under vitest

`SubagentsPanel.tsx` now imports `AntigravityIcon` (@lobehub/icons/es/
Antigravity), whose glyph drags in @lobehub/fluent-emoji → @emoji-mart/data.
Those JSON modules need an import attribute that Node refuses under vitest, so
every suite reaching SubagentsPanel (AddAgentDialog, AppShell.subagent-nav,
SubagentsPanel) failed to LOAD — "needs an import attribute of type json".
The sibling @lobehub icons (Claude/Codex/Cursor) are already stubbed here for
the same broken-nested-resolution reason; AntigravityIcon was simply missing.
Add the matching stub. Verified: with it the 3 suites load (negative control:
without it SubagentsPanel.test.tsx fails to load on the fluent-emoji chain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(antigravity-native): de-flake restart-cursor forwarder test

`test_restart_with_persisted_cursor_emits_only_new_steps` waited for the
emitted item event, then cancelled the forwarder and asserted the persisted
cursor was 4. But the forwarder posts the item THEN advances the cursor, so
the immediate cancel could interrupt before the cursor write landed — a
CI-load race that failed as `assert 2 == 4`. Wait for the cursor itself
(strictly stronger: it implies the item was already mirrored), mirroring the
first-run loop. Stable across 20 local repeats; full forwarder file green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(onboarding): family-filter the "Other provider" tail at the chokepoint

Adversarial review (codex) flagged that keeping gemini out of the openai-family
"Other provider" picker via _PRESET_KEY_PROVIDERS alone is exclusion-list based:
a future non-openai catalog family omitted from that tuple would leak into the
openai-only catch-all again (the gemini bug, reincarnated). The "Other provider"
option is openai-family scoped (_add_option_families), so converge the fix at the
chokepoint — other_key_providers() now filters to OPENAI_FAMILY, not just the
preset list. Zero behavior change today (the whole current tail is openai-family);
it hardens the class of bug. Also note in the agy-stub fixture that the real
missing-binary path is covered in test_antigravity_native_launch.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): address #892 review — durable SET resume cursor + tests

Responds to PattaraS's 5 findings on PR #892:

1. Forwarder no longer drops a not-yet-written out-of-order step across a
   restart. The durable resume cursor is now the EXACT SET of acked step
   indices (forwarded_steps), suppressed by MEMBERSHIP, not a single <=
   high-water: agy writes step_index both non-contiguously AND out of order,
   so a <= floor advanced past a {12,14} batch silently dropped a later 13.
   The set is carried across same-conversation resume rewrites
   (_launch_and_record + runner auto-create) and materializes a legacy
   <=-floor into the set on upgrade. (bridge + forwarder + runner)
2. Pin the agy install: the bootstrapper has no version flag (always fetches
   latest from its auto-updater manifest), so the Dockerfile now fails the
   build when the installed agy != AGY_EXPECTED_VERSION (1.0.10) — a silent
   harness break becomes a conscious, visible bump.
3. Test the eager terminal-close finally seam (reattached / DETACHED).
4. Test the suppress-by-id branch (_dispatched_call_ids) directly — both arms.
5. Fix stale docstring: web turns inject via tmux send-keys, not connect-RPC
   SendAgentMessage (which agy logs as a SYSTEM_MESSAGE).

Verified: 201 affected tests pass; ruff + format clean; a live omnigent
end-to-end run confirms the out-of-order step survives a forwarder restart
and renders in the web UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): CLI reattaches to runner-owned terminal (no double-launch)

A fresh/cold-resume `omnigent antigravity` launch bound the runner and then ALSO
ran `_launch_and_record`, double-launching the agy terminal: binding the runner
triggers the runner's idempotent auto-create of `antigravity:main`
(runner/app.py `_auto_create_antigravity_terminal`, which owns the terminal for
every antigravity-native session), so the CLI's redundant terminal POST 500'd
("already observed as required") AND its `clear_bridge_state` wiped the bridge
state the runner wrote — leaving the session `failed` and every web turn erroring
with "Antigravity native bridge state is missing".

Fix: after binding the runner, reattach to the runner-owned terminal
(`_await_runner_antigravity_terminal` polls for it post-bind, mirroring the
existing pre-bind resume reattach which can't catch the post-bind auto-create).
A CLI-side launch stays only as a defensive fallback, so the change can only help
or be neutral. Also corrects the now-stale "the runner has no agy auto-create
branch" docstrings (the branch was added in 3666dbb0). Restores claude/codex
parity for fresh CLI launches.

Adds a regression test (fresh launch reattaches, never calls `_launch_and_record`)
and keeps the cold-resume fallback test fast via a shortened wait.

Verified: 168 affected tests pass; ruff + format + mypy clean. Live confirmation
of a working send still pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): CLI defers forwarding to the runner on reattach

Coupled follow-on to the double-launch fix, found in live testing: when the CLI
reattaches to a runner-owned terminal it was STILL starting its own
`supervise_forwarder` in `_attach_terminal`, while the runner already runs one
(it auto-creates "terminal + forwarder" together). Two tailers POSTing the same
agy transcript double-mirrored every step — verified live as duplicated chat
messages and a duplicate one-time degrade notice.

Fix: only start the CLI-side forwarder when NOT `prepared.reattached` (the
fallback where the CLI launched its own terminal and is the sole mirror source);
otherwise defer to the runner's forwarder. Same "runner owns the antigravity
session" cleanup as the launch fix.

Adds regression tests (reattached → no CLI forwarder; not-reattached → CLI
forwards), counting the call deterministically rather than the cancellable task
body.

Verified live: with this + the launch fix, a fresh `omnigent antigravity` session
sends from the web chat with no "bridge state missing", agy responds, and the
reply mirrors back exactly once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): reattach on the local-server launch path (no double-launch/forward)

The double-launch/double-forward fixes (7df3ba4d, f4ce3ce8) only patched the
daemon prepare path (_prepare_antigravity_terminal_via_daemon). The default
`omnigent antigravity` (local server) goes through _prepare_antigravity_terminal,
which bound the runner then unconditionally called _launch_and_record with NO
post-bind reattach -- racing the runner's _auto_create_antigravity_terminal
exactly as the daemon path did. The local CLI usually wins (so it mostly worked),
but when the runner wins, _launch_and_record's clear_bridge_state wipes the
runner's bridge state (web turns fail "Antigravity native bridge state is
missing"), its redundant terminal POST 500s, and reattached=False starts a second
supervise_forwarder -> double-mirror.

Mirror the daemon fix: after _bind_session_runner, poll for the runner-owned
terminal (_await_runner_antigravity_terminal) and reattach (reattached=True)
instead of launching; the CLI launch stays a defensive fallback. When no runner
is bound (pure-local CLI), the path is unchanged (the CLI is the sole owner).

Adds a regression test for the local path (fresh launch reattaches, never calls
_launch_and_record). Found by adversarial review (gemini).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(antigravity-native): make the port-unresolved RPC test hermetic

test_conversation_id_owned_by_pid_none_when_port_unresolved stubbed
discover_language_server_port -> None but not _candidate_agy_rpc_ports, so when
the pid-scoped port is unresolved the production fallback scanned EVERY live agy
connect-RPC port. On any host/CI runner with a concurrent agy that fallback found
real ports and ran _conversation_matches -> calls != [] -> the test failed
(reproduced live by two reviewers). Stub _candidate_agy_rpc_ports -> [] too so
the test exercises the genuine "no port from either source" branch hermetically.
Source is unchanged (it correctly returns None either way). Found by review
(gemini + opus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): correct RPC probe request/response shape; note sub-step at-least-once

- antigravity_native_rpc.py module header described the GetConversationMetadata
  probe REQUEST as {"metadata": {"rootConversationId": ...}}, but the code sends
  {"conversationId": ...} and metadata.rootConversationId is the RESPONSE echo.
  Correct the header (request flat, response nested).
- _post_events: note the at-least-once duplicate is also sub-step -- a step
  bundles a message + N function_calls, so one item's failed POST re-posts the
  whole step (re-emitting already-committed siblings) on restart.

Found by review (gemini + opus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): RPC core rework design spec

Design for reworking the antigravity-native harness runtime onto agy's
connect-RPC surface (live-verified): structured trajectory-step reads
(GetCascadeTrajectorySteps / StreamAgentStateUpdates) replacing JSONL
transcript-tailing, interaction bridging (ask_question + run_command
permission via HandleCascadeUserInteraction → omnigent elicitations), and a
real interrupt (CancelCascadeSteps). Eliminates the transcript-mirror
fragility class (out-of-order cursor, live double-render, user-message
duplication) and closes the interactive-prompt gap. Periphery from #892
(onboarding/auth, registration, terminal infra, Docker pin, ap-web picker) is
reused; turn-send stays on tmux send-keys pending a user-turn RPC. Wire shapes
captured in memory agy-rpc-interaction-bridge.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): RPC core rework implementation plan

13-task TDD plan for the RPC core rework (per the design spec): a discovery
spike (turn-send + read-mode + step-type fixtures), the RPC client
(trajectory steps / handle_user_interaction / cancel), a pure step→item
mapper (no delta, skips USER_INPUT), the read driver, the interaction bridge
with the timeout re-read loop, the server elicitation adapter + hook, real
interrupt via CancelCascadeSteps, runner wiring, forwarder cutover, and live
parity verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* spike(antigravity-native): record RPC step fixtures + turn-send/read-mode decisions

Capture live agy 1.0.10 GetCascadeTrajectorySteps fixtures (11 live, 1
synthesized) covering every step type Tasks 4/5 map: USER_INPUT,
PLANNER_RESPONSE (text + tool_call ask_question/run_command),
RUN_COMMAND WAITING/DONE, ASK_QUESTION WAITING/DONE, plus
CONVERSATION_HISTORY/CHECKPOINT/LIST_DIRECTORY; ERROR synthesized from
the live WAITING shape (labelled, with _fixtureProvenance).

Record decisions with evidence in docs/claude/antigravity-rpc-spike-notes.md:
- turn-send: KEEP tmux send-keys (send-keys turn records as USER_INPUT
  with source USER_EXPLICIT; no user-turn RPC exists; SendAgentMessage
  mis-records as SYSTEM_MESSAGE).
- read-mode: default StreamAgentStateUpdates (first steps frame ~130ms
  after a turn) with GetCascadeTrajectorySteps poll fallback; request
  MUST be connect-enveloped (bare JSON => protocol error). Poll-first is
  an acceptable de-scope.

Also live-confirmed: permission + askQuestion answer round-trips
(HandleCascadeUserInteraction => 200, step flips DONE); CancelCascadeSteps
{cascadeId} => 200 but no-op on a WAITING-for-interaction step (Task 10
must validate cancel against RUNNING steps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC client — trajectory steps + cancel

Add two unary connect-RPC methods mirroring _conversation_matches:
- get_trajectory_steps(port, cascade_id) -> list[dict]: POSTs
  {"cascadeId": ...} to GetCascadeTrajectorySteps, returns resp["steps"].
- cancel_cascade_steps(port, cascade_id) -> bool: POSTs {"cascadeId": ...}
  to CancelCascadeSteps, returns True on HTTP < 400, False on error.

Both respect _assert_loopback_url + _sync_client(_HTTP_TRANSPORT) so the
MockTransport seam covers them in tests. Also adds the two method name
constants alongside the existing _METHOD_FORCE_STOP_CASCADE_TREE.

TDD: 2 new tests written first (RED: AttributeError), then impl (GREEN).
Full file: 47/47 passing, ruff+mypy --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): address Task 2 review — drop type:ignore, raise_for_status, fail-open test

- Remove # type: ignore[arg-type] from test_get_trajectory_steps: narrow
  seen["body"] with isinstance(body, (bytes, bytearray)) before json.loads,
  so mypy accepts it without any suppression.

- Add response.raise_for_status() in get_trajectory_steps before .json():
  non-2xx responses (e.g. HTTP 500 "trajectory not found") may not be JSON,
  so decoding them would raise JSONDecodeError (undocumented). raise_for_status
  raises httpx.HTTPStatusError (subclass of httpx.HTTPError) on non-2xx,
  matching the documented :raises: and catchable at one site by Task 6.
  Updated docstring to explain the intentional raise (not fail-open) contract.

- Add test_cancel_cascade_steps_false_on_transport_error: asserts the primary
  safety contract (ConnectError → False) that was previously untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC client — handle_user_interaction

Add AntigravityRpcError exception class and handle_user_interaction() unary
connect-RPC method to the existing antigravity_native_rpc module. Delivers
interaction answers (question responses / approvals) to agy by POSTing to
HandleCascadeUserInteraction with trajectoryId+stepIndex nested inside
interaction (required by proto-JSON encoding). Raises AntigravityRpcError
carrying the raw response body on non-2xx so Task 8 can detect the overloaded
"input not registered for step N" race string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): pure step→item mapper (no delta, skip USER_INPUT)

Create omnigent/antigravity_native_steps.py with map_step_to_events() for
the RPC-based read path. Fixes two live bugs: drops output_text_delta so the
web UI no longer double-renders assistant text, and skips USER_INPUT steps so
the user message is not duplicated (already persisted by direct POST /events).

Handles CORTEX_STEP_TYPE_* format (camelCase fields, argumentsJson strings)
rather than the transcript format. WAITING tool steps emit no output event;
DONE steps emit function_call_output keyed via the FIFO allocator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): WAITING-interaction extractor

Add PendingInteraction TypedDict and pending_interaction() to
antigravity_native_steps.  Returns None for DONE steps even when
requestedInteraction is present (status-keyed, not field-keyed).
Extracts trajectory_id via a new _trajectory_id() helper that mirrors
_step_index().  19 new fixture-driven tests; 55 total green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): surface is_multi_select in pending_interaction spec

Add _merge_is_multi_select() helper that reads is_multi_select from
metadata.toolCall.argumentsJson and injects it into a fresh copy of
the requestedInteraction.askQuestion spec dict per question index.
Defaults to False when argumentsJson is absent or malformed; never
mutates the input step. 5 new tests (fixture False, synthetic True,
absent json, malformed json, no-mutation); 60 total green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): address Codex review of RPC client — wrap transport errors, guard steps body, add tests

CDX-IMP2: Wrap handle_user_interaction's client.post in try/except
httpx.HTTPError; re-raise as AntigravityRpcError("transport error
contacting agy: {e}") so the Task 8 bridge has one exception type for
all delivery failures (transport and non-2xx alike). Non-2xx still raises
AntigravityRpcError(response.text) to preserve the body for "input not
registered" detection. Add test_handle_user_interaction_raises_rpc_error_on_transport_error.

CDX-MIN4: Guard get_trajectory_steps response body against {"steps": null}
or non-dict body: use isinstance checks before list() so a malformed 2xx
can't raise TypeError. Document that non-JSON 200 raises ValueError (Task 6
driver catches broadly).

CDX-MIN5: Add test_get_trajectory_steps_raises_on_500 — pins the non-2xx
raises contract (not fail-open, unlike cancel).

CDX-MIN6: Broaden cancel_cascade_steps except from httpx.HTTPError to
Exception with comment explaining deliberate fail-open intent; covers
ssl.SSLError and other errors outside the httpx hierarchy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): address Opus/Codex review of step mapper — real tool-call ids, slot-0 index, robustness

OPUS-IMP1: use agy's real tool-call ids for function_call/output pairing.
plannerResponse.toolCalls[].id on invocation and metadata.toolCall.id on
result steps are used directly; _ToolCallIdAllocator is fallback-only when
the id field is absent (resume-mid-turn). Out-of-order multi-result regression
test verifies FIFO would mis-pair but real-id pairing is correct.

CDX-IMP1 + OPUS-MIN1: _step_index accepts string-encoded ints (agy sends some
numerics as strings) and treats a missing stepIndex as 0 (proto omits
zero-valued scalars) rather than silently dropping the step.

OPUS-MIN2 / Task4-M1: modifiedResponse precedence over response is now tested
with a synthetic step where the two fields differ; the choice is documented
(post-moderation text, present and equal to response in live fixtures).

OPUS-MIN3 / Task4-M2: collapse dead double USER_INPUT guard into a single
`if step_type == _TYPE_USER_INPUT: return []`.

Task4-M3: remove unused _TYPE_CHECKPOINT / _TYPE_CONVERSATION_HISTORY
constants (catch-all return [] handles them; keeping them added noise).

CDX-MIN3: fix _SOURCE_USER comment ("model-generated" → "user-submitted input").

T5FIX-MIN: collapse redundant `except (json.JSONDecodeError, Exception)` in
_merge_is_multi_select to `except Exception`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): drop test type:ignore, remove orphaned constant (review follow-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-native): simplify RPC client + step mapper (code-simplifier pass)

Move _METHOD_HANDLE_CASCADE_USER_INTERACTION to the top-level _METHOD_* constant
block where all sibling method constants live, removing the out-of-place
inline definition between AntigravityRpcError and handle_user_interaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC read driver

Add omnigent/antigravity_native_reader.py: the read-path driver that
replaces the transcript-tail forwarder's read loop. It discovers agy's
cascade id (from bridge state, past the agy_conv_* placeholder) and
connect-RPC port (port-first, conversation-ownership confirmed), then
polls GetCascadeTrajectorySteps, maps each new step to Omnigent
conversation items (Task 4 mapper), posts them, emits RUNNING/IDLE
external_session_status edges on turn transitions (replicating
TranscriptParser's stateful heuristic), and hands WAITING steps to the
Task 8 interaction bridge via an on_pending_interaction callback.

- Dedup by (trajectory_id, step_index) identity in an in-memory seen-set
  (no durable cursor — retired in Task 12); re-reads post nothing.
- One _ToolCallIdAllocator per run; real agy ids keep pairing
  order-independent.
- httpx.HTTPError (transport + non-2xx) and ValueError (non-JSON 200) on
  a poll are logged and swallowed; the loop never dies on a transient.
- Injectable stop predicate bounds the loop under test.

TDD: 9 tests (dedup, USER_INPUT-skip, WAITING-once, status transitions,
error recovery, placeholder-wait). ruff + mypy --strict clean; no
type:ignore / noqa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(server): antigravity elicitation adapter

Add pure shape-mapping adapter that converts a PendingInteraction dict
(ask_question or permission) into ElicitationRequestParams for the web UI,
and converts the ElicitationResult back into the HandleCascadeUserInteraction
payload. Mirrors _codex_elicitation.py's ask_question/permission patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): interaction bridge with timeout re-read

Add omnigent/antigravity_native_interactions.py: the detect→elicit→deliver
bridge for the agy RPC harness. It surfaces a WAITING interaction as an
Omnigent elicitation, awaits the verdict, and delivers it via
HandleCascadeUserInteraction — handling agy's WAITING-interaction timeout
gotcha (design §2.1):

- re-reads the freshest WAITING step at delivery time (never the captured
  detection-time ids — agy may have timed the step out and retried at a
  higher stepIndex while the human deliberated);
- on the overloaded HTTP 500 "input not registered for step N", re-reads for
  a NEW higher-index WAITING step and re-surfaces a fresh elicitation against
  it (new deterministic id per step_index);
- bounds the loop with max_retries so a timeout-retry storm terminates;
- returns (no delivery) on a None verdict (human timeout/cancel) and on any
  non-"input not registered" RPC error.

Three async seams (get_steps / request_elicitation / deliver) keep the
timeout logic unit-testable without a live agy. deliver defaults to a
_deliver_via_rpc wrapper that offloads the sync handle_user_interaction to a
worker thread (mirrors the Task 6 read driver), since the bridge is async.

TDD: 9 unit tests (happy path, input-not-registered re-read, permission
accept, staleness-before-first-delivery, None verdict, no-WAITING-step,
non-retryable error, bounded retry storm, deterministic id). ruff +
mypy --strict clean; no type: ignore / noqa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(server): antigravity elicitation hook endpoint

Add POST /v1/sessions/{session_id}/hooks/antigravity-elicitation-request —
the runner→server bridge for the agy native interaction bridge (Task 8).
The bridge POSTs {elicitation_id, params} here; the endpoint parks on the
shared harness elicitation registry, emits response.elicitation_request
for the web UI, awaits the approval verdict, then returns the raw
ElicitationResult JSON (simpler than the codex hook: no JSON-RPC envelope
to build — the bridge does that via to_interaction_payload). Timeout
returns empty 200 so the bridge reads None and leaves the agy WAITING step
to expire on its own. Mirrors the codex-elicitation-request path exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): Phase 2 full-RPC-parity spec (turn-send, streaming, usage, model, rotation)

All shapes live-verified against agy 1.0.10. Resolves the §7 turn-send open
question (SendUserCascadeMessage) and adds streaming-delta / token-usage /
model-change / new-conversation-rotation parity with the codex+claude harnesses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC client — send_user_cascade_message + model catalog

Adds two typed connect-RPC wrappers to antigravity_native_rpc.py (Task T-A):

- send_user_cascade_message(port, cascade_id, text, *, plan_model) POSTs the
  exact verified body shape {cascadeId, items:[{text}], cascadeConfig:{plannerConfig:{planModel}}}
  to SendUserCascadeMessage, recording USER_INPUT (not SYSTEM_MESSAGE). Raises
  AntigravityRpcError on transport errors or HTTP >= 400, carrying the raw body
  so the executor can surface model/validation errors (e.g. "neither PlanModel
  nor RequestedModel specified"). Mirrors handle_user_interaction.

- get_available_models(port) POSTs {} to GetAvailableModels and returns the
  parsed catalog {models:{<key>:{model, displayName, recommended, ...}}} for
  runtime model enum resolution. raise_for_status() on non-2xx; returns {}
  on a non-dict 200 body. Mirrors get_trajectory_steps error contract.

TDD: 6 new tests (MockTransport, no live agy); all 58 tests pass.
Ruff/mypy --strict clean; no # type: ignore or # noqa anywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC client — stream_agent_state_updates (connect server-stream)

Add the connect-protocol server-stream client for agy's
StreamAgentStateUpdates, the live-delta source the T-D streaming reader
will consume. Opens a persistent streaming POST, reassembles connect
frames from the raw byte stream, and yields each DATA frame's parsed JSON
update dict in arrival order, stopping on the end-of-stream trailer.

Framing (live-verified, agy 1.0.10; design §10.2):
- Request: one connect-enveloped message [0x00][BE-len][{"conversationId"}],
  Content-Type application/connect+json (via new _encode_connect_envelope).
- Response frames [flag][BE-len][payload]: flag 0x00 = data (yielded),
  flag & 0x02 = trailer (stop), flag & 0x01 = compressed (raise — agy sends
  uncompressed, so a set bit is a decode mismatch).
- Buffer-based reassembly: one chunk is never assumed to be one frame —
  several frames may pack into a chunk and a frame (incl. its 5-byte header)
  may straddle chunks; a bytearray holds bytes until a full frame is present.

Uses a dedicated _STREAM_TIMEOUT (read=None) so the long-poll is not aborted
mid-turn; reuses _assert_loopback_url and the _async_client seam (signature
widened to httpx.Timeout | float; docstring refreshed — it now has a live
caller).

TDD: 7 tests via httpx.MockTransport streaming responses (custom
AsyncByteStream with controlled chunk boundaries) cover the request
envelope, in-order multi-frame yields, split+packed frame reassembly,
header-split reassembly, trailer termination, the compressed-frame raise,
and the non-loopback URL refusal. mypy --strict clean; no type/lint
suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): raise on connect trailer error in stream_agent_state_updates

In connect server-streaming a mid-stream server failure is reported in
the end-of-stream TRAILER PAYLOAD as {"error": {...}} — NOT via HTTP
status, because the 200 + headers were already flushed before the failure.
The previous code treated any flag & 0x02 trailer as a clean stop, making
an errored stream indistinguishable from clean completion and silently
truncating the turn for the T-D streaming consumer.

stream_agent_state_updates now parses the trailer payload (new
_connect_trailer_error helper, which fails safe toward a clean stop on an
empty / non-JSON / non-object / no-error payload) and raises
AntigravityRpcError carrying the stringified error when the trailer holds
a non-empty error object. Clean trailers (empty payload, {}, or any
payload without a truthy error) still return normally — behavior is
otherwise identical. The framing layer is the right place for this so T-D
gets one failure surface and does not have to inspect trailers itself.

Tests (same MockTransport streaming style): an error trailer after data
frames yields those frames then raises (asserting the data was delivered
in order before the raise); empty-payload and {} trailers are clean stops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): reader streaming mode (output_text_delta + poll fallback)

Stream-primary read driver: consume StreamAgentStateUpdates for live
output_text_delta typing parity, falling back to the committed-only poll loop
on any stream error (httpx.HTTPError / AntigravityRpcError trailer).

- Per GENERATING PLANNER_RESPONSE frame, prefix-diff plannerResponse.modifiedResponse
  and emit the new suffix as one external_output_text_delta (stable per-step
  message_id antigravity:<conv>:<step>:planner, final=False); commit the DONE
  message via the mapper afterward. Delta-first ordering + stable id satisfies the
  SPA single-render reconciliation contract.
- Dedup committed items by (trajectory_id, step_index), recorded only once a step
  is SETTLED (DONE/ERROR/USER_INPUT) so a tool-result seen RUNNING before DONE is
  not deduped early and its output dropped (stream observes every status frame).
- Relocate the delta builder out of the soon-retired forwarder into the mapper
  module as output_text_delta_event + planner_message_id (suffix + configurable
  final); the reader depends on the mapper, not the forwarder.
- Reasoning-stream skipped: no external reasoning-delta POST contract exists;
  folding thinking into output_text_delta would corrupt the message (see report).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): gate committed planner message on DONE (no poll-path double-render)

The mapper emitted a planner `message` at ANY status (only tool-results were
DONE-gated). The poll fallback does not intercept GENERATING (only the stream
path does), so a poll catching a planner GENERATING then DONE posted TWO
messages for one step — the exact double-render the RPC rework removes, on the
fallback path.

Gate the PLANNER_RESPONSE committed items (message + function_calls) on
status == DONE, symmetric with the existing tool-result gate. A non-DONE
(GENERATING) planner now maps to [] — its partial text is conveyed only via the
streaming reader's output_text_delta events. Effect: exactly one committed
message with the FINAL text on BOTH the stream and poll paths; the stream still
emits live deltas, the poll stays committed-only.

The _is_settled tool-result dedup fix from the prior commit is retained and now
consistent: a planner records `seen` only at DONE (when it produces committed
items). All planner fixtures are DONE, so no Task-4 mapper test needed updating.

Tests: poll-path regression (generating→done → one message, final text, no
deltas); stream-path analog strengthened to assert final committed text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): reader telemetry — session usage + model change

Implements design §10.3 (external_session_usage) and §10.4
(external_model_change) in the RPC read driver.

- _model_usage_from_step: extracts agy string-int modelUsage fields
  (inputTokens/outputTokens/cacheReadTokens) from PLANNER_RESPONSE DONE
  steps; maps to cumulative_input_tokens/cumulative_output_tokens/
  cumulative_cache_read_input_tokens + model (displayName).
- _requested_model_enum_from_step: reads
  userInput.userConfig.plannerConfig.requestedModel.model from USER_INPUT.
- _resolve_display_name: resolves enum→displayName via GetAvailableModels
  catalog; falls back to raw enum when unknown.
- _ensure_catalog: fetches and caches the model catalog once per reader
  run (asyncio.to_thread); logs + returns {} on failure (best-effort).
- _maybe_emit_session_usage / _maybe_emit_model_change: fired inside
  the key-not-in-seen branch of _process_committed_step so replay of
  already-seen steps never re-emits. Model-change deduped by
  state.posted_model_enum (raw enum, not displayName).
- _ReaderState extended with posted_model_enum, model_catalog, port.
- 7 new tests cover: usage emission + field mapping, usage replay dedup,
  missing-usage graceful skip, first-turn model-change, same-model no-re-emit,
  model switch mid-session, model replay dedup, unknown enum fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(antigravity-native): emit running cumulative session usage (SET-semantics)

The server prices per-turn cost as delta = (new cumulative) - (old cumulative).
Emitting agy's per-model-call inputTokens/outputTokens directly caused the
server to compute a zero delta on turn 2+ (since each turn's per-call value
was the same), freezing the cost badge after turn 1.

Fix: accumulate per-call modelUsage values in _ReaderState and emit the
running totals, matching codex's tokenUsage.total (cumulative, SET semantics).

Also:
- Thread the real step_index through to OutboundEvent for both usage and
  model-change events (was hardcoded to 0).
- Add _ReaderState.cumulative_* reset comment for T-G /clear rotation.
- Add test_two_turn_usage_is_cumulative regression guard: two turns of 1000
  input tokens → turn 1 posts 1000, turn 2 posts 2000 (not 1000 again).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC-driven executor — real interrupt + RPC turn-send

Make AntigravityNativeExecutor fully RPC-driven, retiring the tmux send-keys
write path (Task 10 + Task T-B):

- interrupt_session: resolve cascade id (= conversation id) from bridge state,
  discover the connect-RPC port, and call CancelCascadeSteps. Documents the
  live-verified limitation (C3): cancel stops a RUNNING cascade and is a NO-OP on
  a WAITING-for-interaction step (a DENY via the interaction bridge unblocks that).
  Returns False on placeholder / no port / cancel failure.
- run_turn + _deliver: deliver turns via SendUserCascadeMessage instead of
  send-keys. Per-turn planModel is resolved at runtime (two-tier, design §10.4):
  echo the latest USER_INPUT step's requestedModel.model, else fall back to the
  recommended GetAvailableModels entry. ExecutorConfig.model/effort stay
  informational (agy owns model selection on this write path).
- First turn (Option A, pure RPC): on the agy_conv_* placeholder, wait for the
  runner to mint the real id (Task 11), then send; surface a clear "not ready"
  ExecutorError if it never lands rather than typing into the TUI to mint it.
- AntigravityRpcError from the turn-send is surfaced (carrying agy's message),
  not swallowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): RPC conversation cold-start bootstrap (StartCascade)

The runner now mints the agy conversation over connect-RPC on a fresh
host-spawned launch (StartCascade) instead of seeding only an agy_conv_*
placeholder, so the executor's turn-1 has a real cascade_id. The existing
supervise_forwarder spawn is kept (Task 11b swaps it for the reader) and now
binds the cold-started conversation directly.

- antigravity_native_rpc.start_cascade(port, cascade_id, *, source): POSTs
  {cascadeId, source} to StartCascade; 200 -> None, non-2xx/transport ->
  AntigravityRpcError (mirrors send_user_cascade_message).
- runner.app._cold_start_agy_conversation: polls the Heartbeat-OK connect-RPC
  port (bounded), StartCascades a runner-minted uuid4, and overwrites bridge
  state's conversation_id with the real id via update_conversation_id.
  Best-effort/non-raising so a failure leaves the placeholder for the forwarder
  and never aborts the launch. Wired into _auto_create_antigravity_terminal on
  fresh (not resume) launches, after the terminal starts and before the forwarder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): runner wires RPC streaming reader + interaction bridge

Swap the antigravity auto-create's transcript-forwarder spawn for the RPC
streaming reader (supervise_reader, T-D) and wire its on_pending_interaction
to the Task 8 interaction bridge via the Task 9 elicitation hook, making the
full RPC chain live (cold-start 11a -> reader T-D -> bridge Task 8 -> hook
Task 9 -> executor Task 10/T-B). 11a's cold-start is untouched; the reader
replaces the forwarder only and reuses the same single-instance per-session
task registry.

- Widen OnPendingInteraction to (cascade_id, port, pending) so the bridge gets
  the SAME ids the reader discovered (no re-discovery race); thread them through
  the single delivery point in _process_committed_step.
- Add production elicitation glue in app.py (_post_agy_elicitation_request,
  _request_agy_elicitation) mirroring codex's long-poll re-POST + body handling,
  and _run_antigravity_reader which owns the client and runs supervise_reader
  with the bridge-wired callback.
- Tests: reader callbacks updated to the new contract (poll + stream paths
  assert cascade_id/port threading); auto-create harness stubs the reader; new
  end-to-end wiring test (pending -> hook POST {elicitation_id, params} ->
  handle_user_interaction delivery; task named antigravity-reader-{session_id}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes)

The RPC streaming reader (Task 11) replaced the transcript-tail forwarder on the
runner path; this completes the full cutover (Option A) by migrating the last
forwarder consumer — the CLI ``omnigent antigravity`` attach fallback — to the
reader + interaction bridge, then deleting the forwarder and its now-dead durable
read cursor.

- Extract a shared ``run_reader_with_bridge`` helper into
  ``antigravity_native_reader`` (Omnigent client + elicitation POST/retry +
  ``on_pending``→``bridge_interaction`` + ``supervise_reader`` spawn). The runner's
  ``_run_antigravity_reader`` and the CLI ``_attach_terminal`` both call it; the
  elicitation machinery moves out of ``runner/app.py``.
- CLI ``_attach_terminal`` (non-reattached fallback only) now spawns the reader +
  a one-shot cold-start as background tasks at attach-start (cancelled in
  ``finally``), mirroring the runner. agy is started on attach
  (``tmux_start_on_attach=True``), so cold-start + reader run concurrently with the
  attach and poll agy in; the post-hoc ``audit_policies`` path is dropped in favor
  of real-time elicitation. The fallback TUI shows the empty ``>`` banner because
  the cold-started RPC conversation is headless (documented).
- Both cold-starts (CLI + runner) now PATCH the cold-started cascade id onto the
  session as ``external_session_id`` (best-effort, mirroring codex/pi) so a later
  ``--resume`` continues agy's actual conversation — the read-path replacement for
  the forwarder's ``_patch_external_session_id``. The CLI cold-start is guarded to
  run only on a placeholder id (skipped on resume), so ``--resume`` is not
  clobbered by a fresh ``StartCascade``.
- Drop the durable read cursor (``forwarded_steps`` / ``forwarded_step_index`` /
  ``update_forwarded_*``) from bridge state and both launch paths; the reader uses
  an in-memory seen-set. Legacy on-disk cursor keys are tolerated and ignored.
- Delete ``antigravity_native_forwarder`` + its test; sweep forwarder-era
  docstrings across the rpc/launch/reader/runner/CLI/audit/post-delivery modules.

Behavior-preserving for the surviving paths (runner reader + CLI reattach); the
existing suites passing is the proof. The relocated shared types
(``OutboundEvent`` / ``_ToolCallIdAllocator`` / ``_AGENT_NAME`` /
``_TOOL_ARG_DISPLAY_KEYS``, now canonical in ``antigravity_native_steps``) are
included here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): harden external_session_id cold-start PATCH against silent rejection (CLI+runner)

Follow-up to the decision-2=(b) external_session_id PATCH (landed in the
preceding commit): the best-effort PATCH only caught a transport
``httpx.HTTPError`` and ignored 4xx/5xx *responses* (httpx does not raise on
those), so a server-side rejection — and the lost ``--resume`` continuity it
implies — was silently swallowed on BOTH the CLI fallback and runner paths.

- Inspect ``status_code`` after the PATCH and log a warning on ``>= 400`` on
  both ``_cold_start_agy_conversation`` (CLI) and ``_patch_agy_external_session_id``
  (runner), mirroring the codex recorder PATCH. Still strictly best-effort: a
  rejection (or transport error) never raises, and the cascade id is already in
  bridge state so the chat mirror is unaffected; only resume fidelity degrades.
- Add focused coverage for the runner best-effort helper (None-client no-op,
  transport-error swallow, 4xx-rejection warning) and a CLI 4xx-rejection test.
- Fix a stale "resets the resume cursor" comment on the runner cold-start (the
  durable cursor was removed in the cutover) and remove a pre-existing
  ``type: ignore[arg-type]`` in the CLI test's ``_mock_client`` by typing the
  handler as ``Callable[[httpx.Request], httpx.Response]``.

The placeholder/resume guard that makes ``--resume`` continue agy's prior
conversation (skip cold-start + PATCH on a non-placeholder id) is intact on both
paths and covered by tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(antigravity-native): cover legacy durable-cursor key tolerance on bridge read

Addresses the Task 12 review's minor finding: the cutover removed the
forwarded_step_index / forwarded_steps durable-cursor fields, and
read_bridge_state must tolerate (ignore) them in a forwarder-era state.json.
Extends the legacy-fields test to carry both cursor keys and asserts they are
absent from the parsed dataclass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-native): code-simplifier pass (readability, behavior-preserving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): address 3-way review — functional-RPC timeout, IDLE-on-DONE gate, stream re-entry backoff, runner cold-start guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): run interaction bridge off the reader loop with single-in-flight guard

3-way review (codex+gemini, with a repro) found the reader loop blocked for the
full duration of a human interaction: _maybe_handle_interaction awaited the
elicitation long-poll (up to ~24h) inline, freezing streaming/tool-output/status
and risking stream severance. The naive create_task fix the reviewers proposed
would double-fire on agy's WAITING-timeout retry steps (it re-issues at a higher
step_index), so this adds a single-in-flight guard: the bridge runs off-loop as a
tracked _ReaderState.interaction_task; while one is active the loop skips spawning
another (the in-flight bridge owns the retries via its own freshest-WAITING
re-read); a done-callback clears the slot; supervise_reader cancels it on teardown.

Tests: streaming continues while an interaction is pending (gemini's repro),
single-in-flight guard suppresses a retry-step double-fire, done-callback clears
the slot for a later interaction, and reader teardown cancels the in-flight task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): scope cold-start to the session's agy pid (avoid wrong-agy cross-bind)

The cold-start picked candidates[0] (the lowest Heartbeat-answering agy
connect-RPC port). On a host running several agy instances under one runner
(sub-agent fan-out, shared runner, `omnigent run --server` multi-session) this
could StartCascade onto a FOREIGN agy and permanently bind the session to the
wrong conversation, since no conversation exists yet to disambiguate.

Scope the cold-start port to THIS session's own agy via its tmux pane:
pane -> pane pid -> agy pid in the pane's process subtree -> that pid's
connect-RPC port. agy is the pane process on the simple `exec agy` launch and a
descendant (sandbox launcher -> bwrap -> agy) on a sandboxed launch, so the
resolver checks the pane pid itself then walks descendants intersected with the
live agy pids. Falls back to the existing candidate scan when no local pane is
reachable (remote runner) or the pane cannot be resolved, so single-agy hosts
and remote runners are unaffected; the fallback is logged.

Both cold-starts (runner + CLI) are threaded the pane and share the new
resolve_cold_start_agy_rpc_port helper. Placeholder/resume guards, the
port-bind timeout/poll loop, and the external_session_id PATCH are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): surface agy reasoning/thinking stream (parity)

Gemini Thinking-model variants stream chain-of-thought at
plannerResponse.thinking (design 10.2), which the RPC reader and step
mapper never read — so reasoning was dropped, a parity gap vs the
in-process antigravity executor (which emits the same reasoning SSE pair).

Reader: mirror the modifiedResponse text-delta path for thinking — a new
per-step reasoning prefix tracker on _ReaderState, _partial_planner_thinking
extractor, and _emit_partial_reasoning_delta (prefix-diff suffix per
GENERATING frame, started=True only on a step's first delta). Reasoning is
emitted BEFORE the response delta (10.2 ordering) and the tracker is cleared
on commit alongside the text tracker. A planner with no thinking emits
nothing (no regression to text streaming).

Steps mapper: output_reasoning_delta_event builder for the transient
external_output_reasoning_delta event. Reasoning is delta-only — the mapper
commits NO reasoning item (matching codex/claude/the in-process executor,
none of which commit reasoning content); the SPA finalizes the reasoning
block when the assistant message arrives.

Server: external_output_reasoning_delta external event type publishes
response.reasoning.started (once, when data.started) + response.reasoning_text.delta
SSE — the events the SPA already maps (sse.ts) and renders (blockStream.ts).
The reasoning-content wire bridge did not exist for native harnesses; only
text (external_output_text_delta) and effort (external_reasoning_effort_change)
did. Nothing is persisted.

Tests: reader streaming (incremental reasoning deltas with started-once,
reasoning-before-text ordering, no-thinking no-regression, no-growth dedup);
mapper builder shape + no committed reasoning item on DONE-with-thinking;
server route (started publishes both SSE, continuation publishes delta only,
malformed delta rejected). No suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): cold-start keeps polling when the session's agy isn't up yet (no foreign-agy fallback)

R2 review found a residual cross-bind on the CLI path. CLI terminals use
`tmux_start_on_attach=True`, so the pane runs `tmux wait-for; exec agy` and agy
is only exec'd when the human attaches — but the cold-start polls CONCURRENTLY
with the attach. During that early-poll window the pane is just the shell, so the
pane resolver found no agy and returned None, and `resolve_cold_start_agy_rpc_port`
fell through to `_candidate_agy_rpc_ports()[0]`. If a foreign agy was the only
candidate, StartCascade bound this session into the FOREIGN agy — the exact
durable cross-bind the scoping targets.

Fix: distinguish THREE pane states via a new `PaneAgyResolution`
(`resolve_pane_agy_rpc_port_state`):
  1. agy found + port resolved        -> scoped port.
  2. agy found + port unattributable  -> candidate fallback (restricted /proc;
     one-agy-per-pod, so the lone candidate is ours — preserves k8s behavior).
  3. NO agy found yet                 -> return None, keep polling (do NOT touch
     candidates — a foreign agy could be the only one).
No pane supplied (remote runner) still falls back to candidates.

Also: only thread the pane into the CLI cold-start when the tmux socket exists
LOCALLY (mirror `_can_attach_direct_tmux`), so a remote runner's server-side
socket path doesn't trigger ~80 doomed `tmux display-message` spawns per poll and
correctly routes to the no-pane -> candidate path.

`resolve_pane_agy_rpc_port` is retained as a thin port-only wrapper. Bounded
deadline/poll loop, placeholder/resume guard, and external_session_id PATCH
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): guard multi-question askQuestion + detect stale /clear-rotated conversation

Three R4 edge-guard fixes from the 3-way review.

Fix A — multi-question askQuestion no longer broadcasts one answer to all.
agy's askQuestion can carry several questions[i] (each with its own option
ids + is_multi_select), and the agy wire wants one response entry PER
question. But ElicitationResult.content is flat (one selectedOptionIds /
writeInResponse, no per-question key), so the SPA can only collect a single
answer end-to-end. The prior code broadcast that single answer to EVERY
question — semantically wrong. Now we answer ONLY the first question and
leave the rest to agy, logging the limitation. Single-question (the
dominant, working case) is unchanged. Full per-question support needs a
schema + SPA-form change and is flagged as a follow-up.

Fix B — detect a TUI /clear that rotates the bound conversation.
On the CLI-fallback path, a human running /clear in the agy TUI mints a NEW
cascade id; the reader bound the old one at discovery and would keep
mirroring the now-dead conversation silently. Each stream frame names the
active conversation (update.conversationId, design §10.5); the reader now
compares it to the bound cascade id and, on a mismatch, logs a clear warning
and stops mirroring rather than failing silently. Absent/empty/ matching
conversationId is not a rotation (false-positive-free on the normal path).
Full automatic re-bind + Omnigent session rotation (T-G) is flagged as a
follow-up; for the headless runner path it is obviated by the 1:1 design.

Fix C — docstring nit (doc-only). output_reasoning_delta_event no longer
claims it "matches the in-process executor (same SSE pair)"; the in-process
antigravity executor emits only reasoning_text deltas and relies on an
IMPLICIT reasoning-start, whereas this path emits an EXPLICIT
response.reasoning.started. Both end with no committed reasoning item.

Tests: multi-question answers only the first + does not broadcast + logs
(single-question stays silent); a rotated conversationId stops+warns and
does not mirror the dead step, while matching/absent ids do not false-fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): hedge /clear-rotation guard field path as unverified (R4 review)

R4 review found Fix B's premise — that StreamAgentStateUpdates frames carry
``conversationId`` at the frame top level (design §10.5) — is UNVERIFIED and
contradicted by the evidence: real stream captures show steps frames only as
``update.mainTrajectoryUpdate.stepsUpdate.steps[]``, and the only live-verified
conversation-id echo is NESTED (``metadata.rootConversationId`` from
GetConversationMetadata). §10.5 is planning intent (rotation tagged unimplemented
follow-up T-G), and the reader test is self-referential (hand-sets the field).

The control flow is correct (the early ``return`` is terminal — it does NOT fall
through to the guard-less poll loop), and the field-path FIX needs a live capture
that can only be taken during Task 13 (live-e2e). So this commit makes the code
honest rather than guessing: docstrings/comments now flag the top-level field
path as a design ASSUMPTION pending a Task 13 live ``/clear`` capture (dump the
raw post-rotation frame; if the id is nested, fix ``_frame_conversation_id`` and
swap the hand-built helper for a captured fixture). Also notes the two-axis
uncertainty (field location + whether a foreign frame ever reaches this stream —
§10.5 names GetAllCascadeTrajectories as the PRIMARY signal; this per-frame check
is only the secondary one).

Doc/comment-only; no behavior change. Fix A (multi-question guard) and Fix C
(reasoning docstring) reviewed correct and unchanged. 43 reader tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-native): code-simplifier pass (readability, behavior-preserving)

Behavior-preserving readability cleanup over the antigravity-native RPC rework.
No logic, signature, or control-flow changes; all gates green (ruff/mypy/pytest).

- antigravity_native.py: R5 docstring consolidation. Folded the scattered
  historical references to retired mechanisms (transcript-tail forwarder, durable
  resume cursor, tmux send-keys) into one concise, accurate preamble at the top of
  the module docstring. Trimmed the now-redundant repetitions in the read/write
  bullet, the _launch_and_record docstring + inline comment, and the
  _attach_terminal note, while keeping the locally load-bearing facts (the dropped
  pre-tool audit / no refresh-capable reader auth, and the _patch_external_session_id
  "replacement for the retired forwarder's id capture" notes).

- antigravity_native_rpc.py: extracted the byte-identical POST+raise tail shared by
  handle_user_interaction, send_user_cascade_message, and start_cascade into a
  private _post_rpc_raising(port, method, body) helper. Removes ~33 lines of
  duplication; each caller now just builds its body and delegates. Identical wire
  behavior (URL, headers, JSON body, transport-error wrapping, raw-body raise on
  >=400).

- antigravity_native_steps.py: extracted the repeated
  metadata.sourceTrajectoryStepInfo navigation shared by _step_index and
  _trajectory_id into a private _source_traj_info(step) accessor.

- antigravity_native_reader.py, antigravity_native_interactions.py,
  inner/antigravity_native_executor.py, server/routes/_antigravity_elicitation.py:
  unchanged — reviewed, no redundancy worth removing without behavior/clarity risk
  (and the reader's /clear-rotation honesty hedges are deliberately preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): 3-way re-review fixes — USER_INPUT dedup, reasoning re-anchor, stream guards, observability

I-1 (ship-blocker): antigravity_native_steps.py + antigravity_native_reader.py —
  USER_INPUT dedup-key collision. USER_INPUT steps have a per-conversation-stable
  trajectory_id and no stepIndex, so every turn's USER_INPUT collided on
  (trajectory_id, None) and was silently de-duped after turn 1 (no per-turn
  RUNNING/IDLE status edge, no model-change). Added _execution_discriminator
  (executionId/createdAt) and widened _StepKey to a 3-tuple, folding the
  discriminator in only for steps that lack a stepIndex. Steps WITH a stepIndex
  key as (traj, idx, None) — unchanged dedup for seen/interacted (interaction and
  content steps always carry a stepIndex). Test now uses real per-turn executionId
  (no synthetic stepIndex): test_two_real_wire_turns_each_emit_running_then_idle +
  test_step_key_distinct_for_user_input_turns_without_step_index +
  TestExecutionDiscriminator.

A (important): antigravity_native_reader.py — _emit_partial_reasoning_delta
  re-anchored reasoning_prefixes[idx] only inside the growth branch, so a
  non-monotonic thinking rewrite froze reasoning deltas permanently. Moved the
  re-anchor out of the if (mirrors the text path). Test:
  test_stream_reasoning_reanchors_after_non_monotonic_rewrite.

B (important): antigravity_native_rpc.py — stream_agent_state_updates wrapped the
  DATA-frame json.loads; a malformed frame raised a bare JSONDecodeError that the
  supervisor does not catch (reader died silently, no poll-fallback). Now raises
  AntigravityRpcError. Test: test_stream_agent_state_updates_raises_on_malformed_json_frame.

C (important): antigravity_native_bridge.py — update_conversation_id now returns
  bool and logs a WARNING (naming the dropped id) on a None state read instead of
  silently dropping the real cascade id. Both cold-start callers
  (antigravity_native.py, runner/app.py) check the result and warn on False. Test:
  test_update_conversation_id_returns_false_and_warns_when_no_state.

D (minor): antigravity_native_rpc.py — stream_agent_state_updates now checks
  response.status_code >= 400 right after the stream opens (httpx stream() does not
  raise on non-2xx; an unframed error body looked like a clean empty stream and
  reconnected forever). Used the explicit status_code form to avoid httpx
  streaming-body read issues. Routes into the reader's poll-fallback. Test:
  test_stream_agent_state_updates_raises_on_non_2xx_status.

E (minor): antigravity_native_interactions.py — _freshest_waiting dropped the
  cross-kind any_kind fallback; it now returns strictly same-kind (or None), since
  agy keys delivery on trajectoryId+stepIndex with no kind check. Tests:
  test_freshest_waiting_returns_none_for_only_different_kind +
  test_freshest_waiting_returns_highest_same_kind.

F (minor): antigravity_native_interactions.py + antigravity_native_reader.py —
  reworded the bridge's no-verdict log so it no longer claims timeout/cancel
  exclusively (hook rejection also yields None); enriched the reader's elicitation
  4xx WARNING to flag a likely misconfigured hook. Log wording only.

G (minor): antigravity_native_interactions.py — the "input not registered" race
  discriminator is now matched case-insensitively (str(exc).lower()), so a
  capitalization change in agy's 500 body cannot reclassify the retryable race as
  fatal and drop the human's verdict. Test:
  test_input_not_registered_match_is_case_insensitive.

Gates: ruff clean; mypy unchanged at 29 pre-existing baseline errors (0 new);
587 tests pass across the antigravity-native suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): correct GetAvailableModels/USER_INPUT-model/stream-frame wire envelopes (live e2e) + real-wire fixtures

A live e2e against agy 1.0.10 proved the branch's three RPC wire envelopes
were wrong; the prior synthetic fixtures encoded the wrong shapes, so the
tests passed while the real wire failed every turn. Captured the real wire
and corrected both the code and the fixtures.

BUG 1 (FATAL — model resolution failed every turn): GetAvailableModels
returns {"response": {"models": ...}}, not {"models": ...} at the top level.
get_available_models now unwraps body["response"] (falling back to the body
itself defensively, {} for a non-dict), so both consumers
(_recommended_model, _resolve_display_name) read catalog["models"] again.
The get_available_models test now mocks {"response": {...}} and asserts the
unwrapped catalog; consumer tests already used the post-unwrap shape.

BUG 2 (FATAL — tier-1 model echo always None): the live USER_INPUT step
carries plannerConfig.planModel as a STRING (the same field
send_user_cascade_message sends), not requestedModel.model (a dict).
Executor _latest_requested_model and reader _requested_model_enum_from_step
now read planModel first and fall back to requestedModel.model for any
TUI-origin step using the old shape. Fixtures relocated requestedModel ->
planModel (steps/user_input.json; reader helpers _user_input_with_model /
_user_input_real_wire; executor helper _steps_with_model); model-change and
echo tests keep the same expected enums. Added one focused fallback test on
each side (reader + executor) to keep the requestedModel.model path covered.

BUG 3 (CRITICAL — stream mirrored nothing): each StreamAgentStateUpdates
DATA frame is a connect envelope {"update": {...}}; the reader read
mainTrajectoryUpdate/conversationId at the top level, so every frame yielded
0 steps and the stream-primary reader mirrored nothing (a 0-step frame does
not raise, so poll-fallback never fired). The generator now unwraps
parsed["update"] (falling back to the parsed dict defensively) before
yielding, so the reader's _frame_steps/_frame_conversation_id work unchanged.
The rpc-stream tests now build {"update": {...}} frames (via _data_frame) and
assert the generator yields the unwrapped payload; a new test covers the
no-envelope defensive fallback. Reader tests feed logical (post-unwrap)
frames and are unchanged.

All three fixes verified against the captured agy 1.0.10 wire. The Fix B
/clear rotation guard is intentionally untouched (a separate follow-up
replaces it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(antigravity-native): real /clear rotation via GetAllCascadeTrajectories (T-G), replacing the dead per-frame guard

The R4 per-frame /clear guard was a proven no-op: a StreamAgentStateUpdates
stream is bound to ONE cascade and only ever reports THAT cascade's id, so a
per-frame "did the conversation change?" check can never observe a sibling
conversation. This replaces it with real, out-of-band rotation detection +
automatic Omnigent session rotation, mirroring the codex forwarder.

STEP 1 (RPC primitive). antigravity_native_rpc.get_all_cascade_trajectories:
POSTs {} to GetAllCascadeTrajectories, raise_for_status (NOT fail-open, like
get_trajectory_steps/get_available_models), returns the parsed body (the
trajectorySummaries map). Documented with the live-verified shape.

STEP 2 (pure detection). antigravity_native_reader._detect_rotated_cascade:
selects the newest-active ROOT cascade (trajectoryType CORTEX_TRAJECTORY_TYPE_-
CASCADE) by lastUserInputTime (falling back to lastModifiedTime), parsing ISO-
8601 robustly (trailing Z -> UTC). Rotates only when the current cascade differs
from the bound one AND is strictly newer than the bound entry's own activity;
returns None when the bound entry is absent (never rotate blindly), when the
newer entry is a bare /clear mint (no activity timestamps yet), or for a
non-CASCADE (subagent) sibling.

STEP 3 (session rotation). _rotate_session_for_cascade mirrors codex's
_create_thread_replacement_session API sequence: GET old snapshot -> POST
/v1/sessions (old agent_id + INHERITED labels, so the new session resolves to
the SAME bridge_dir; agy's bridge_dir is keyed off the launcher bridge-id, not
the session id) -> PATCH runner_id -> PATCH external_session_id=new cascade ->
POST terminal /transfer -> write_bridge_state(new session+cascade) -> PATCH old
runner_id="". Best-effort: any failure logs a WARNING and returns None (the
reader keeps the old binding). Bridge state is rewritten only after the new
session is created+bound, so a mid-sequence failure never points it at a
half-created session.

STEP 4 (wire-up). supervise_reader spawns a _watch_for_rotation background task
that polls GetAllCascadeTrajectories every few seconds (the stream cannot see a
sibling); on detection it flips the body's stop and supervise_reader returns the
new cascade id. run_reader_with_bridge now LOOPS: bind -> supervise -> on a
returned cascade id, _rotate_session_for_cascade -> rebind (re-enter supervise,
which rediscovers from the rewritten bridge state with a fresh _ReaderState).
A failed rotation keeps the old binding and adds the cascade to skip_cascade_ids
so it never hot-loops detect->fail->detect. The elicitation hook reads the
current session id through a holder so a post-rotation interaction targets the
new session. Existing teardown (interaction-task cancel in finally) is preserved
and now also cancels the rotation detector.

STEP 5 (cleanup). Removed the dead per-frame guard (_frame_names_other_-
conversation, _frame_conversation_id, the rotation check + R4 honesty-hedge
comments in _stream_loop) and the reader test helper _frame_with_conversation +
the two /clear-rotation reader tests it backed. Updated stale comments/docstrings
that referenced the dead guard or the unverified top-level conversationId field
path (superseded by T-G).

Tests: get_all_cascade_trajectories (returns/non-dict/500); _detect_rotated_-
cascade (newer sibling, minted-unused, only-bound, older, non-cascade, bound-
absent, lastModifiedTime fallback, equal-activity, malformed ts, real capture);
supervise_reader returns the new cascade on rotation + honours skip_cascade_ids;
_rotate_session_for_cascade exact codex API sequence + bridge-state write + None
on create failure; run_reader_with_bridge rebind loop (advances session id) +
keeps-old-binding-on-failure. mypy: 29 pre-existing, 0 new.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): actuate /clear rotation by cancelling the wedged stream (T-G deadlock)

The Task T-G /clear-rotation reader DETECTED a rotation but never ACTUATED
it. `supervise_reader` ran the rotation detector concurrently with the
reader body, but `await`ed the body DIRECTLY (`_stream_loop`, falling back
to `_poll_loop`). When the detector fired it set `rotation_holder` and
flipped `_body_should_stop()` to True — but that stop is only re-checked at
`_stream_loop`'s outer `while` and after its inner `async for`. After a TUI
/clear the bound cascade goes IDLE and the connect stream blocks forever
inside `aiter_bytes()` (the idle long-poll uses a deliberately deadline-less
read), so neither checkpoint is reached: `_stream_loop` never returns, the
`finally` never runs, `supervise_reader` never returns, and
`run_reader_with_bridge` never calls `_rotate_session_for_cascade`. No
replacement session, no terminal transfer, no rebind — web turns kept
targeting the dead conversation. Found by a live e2e.

Fix: run the reader body as a cancellable task (`antigravity-reader-body`)
and have the rotation callback cancel it in addition to recording the new
cascade id. Cancellation raises CancelledError inside `aiter_bytes()`, which
unwinds `stream_agent_state_updates`' `async with` cleanly (httpx supports
cancellation) where a cooperative stop re-check cannot run. The body task is
created BEFORE the detector starts (referenced via a holder) so the callback
can never fire before the task exists. `await body_task` distinguishes a
ROTATION cancel (rotation_holder set → fall through and return the new id)
from an EXTERNAL shutdown cancel (rotation_holder empty → re-raise so it
propagates, never a phantom rotation). The existing finally still cancels
the rotation + interaction tasks in the documented order, and now also
finalizes the body task on every exit path so nothing leaks. Neither
`_stream_loop` nor the generator catches CancelledError (their excepts cover
only httpx.HTTPError / AntigravityRpcError), so the cancel is not swallowed.

Adds a regression test that wedges the stream on a never-firing event (the
live /clear-then-idle shape) with the detector reporting a rotation, and
asserts `supervise_reader` RETURNS the new cascade id under a tight
`wait_for` budget (a regression times out loudly instead of hanging the
suite); plus a test that an external cancel of a wedged reader propagates
CancelledError rather than being mistaken for a rotation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): suppress runner turn-lifecycle idle (live-e2e double-idle)

Live e2e found every web turn emitted a premature response.completed (0 items)
+ session.status idle at ~0.3s, THEN the real reasoning/text/usage ~1.8s later
against the already-completed response (spinner stops, then text appears).

Root cause: the runner's `_publish_turn_status` (runner/app.py) suppresses the
turn-lifecycle session.status edge for terminal-backed harnesses whose status is
owned by a native observer — claude/pi/cursor-native suppress BOTH running+idle,
codex-native suppresses idle (its injection task returns before the model turn).
antigravity-native was in NEITHER set, so its turn-lifecycle running+idle leaked
alongside the RPC reader's own edges. The executor's SendUserCascadeMessage
returns the instant agy accepts the turn, so the runner's idle fires ~2s before
agy streams output; the server derives response.completed from that idle, hence
the empty premature completion.

Fix: antigravity-native shares codex's shape — add it to the codex-native idle
suppression (publish `running` for immediate accept feedback; the RPC read driver
owns the accurate `idle` once agy's output completes). The server then keeps the
response in_progress until the reader's real idle, so output streams into the
live response instead of after a phantom completion.

Tests: parametrized test_message_turn_lifecycle_status_suppressed_for_terminal_backed_harnesses
now covers antigravity-native (expected ["running"], no idle). 610 antigravity-surface
tests pass; mypy unchanged at the 29-error pre-existing baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): /clear rotation at claude parity (transfer existing agy, no external_session_id, no auto-cold-start loop)

A live e2e proved the prior T-G /clear rotation infinite-loops, spawning
~1 orphan agy + session every 3-5s. Root cause: the rotation POSTed a new
session AND PATCHed its external_session_id=new_cascade. But POST /v1/sessions
for an antigravity-native session makes the runner auto-cold-start a brand-new
agy (_auto_create_antigravity_terminal fired for EVERY such session), which
minted its OWN cascade AND set the new session's external_session_id. The
rotation's external_session_id PATCH then hit that already-set,
set-once-immutable field -> 400 -> rotation aborted; but the cold-start had
already rebound the reader to its fresh cascade -> the detector re-fired ->
infinite session-spawn loop.

This mirrors claude's _create_clear_replacement_session, which already does
/clear rotation correctly. agy, like claude, is ONE long-lived process hosting
many cascades; a /clear mints a new cascade on the SAME process, so the
replacement TRANSFERS the existing terminal (it does NOT re-spawn) and rewrites
bridge state so the reader rebinds to the new cascade on the same process.

Two changes, both copied from claude:

1. _rotate_session_for_cascade (antigravity_native_reader.py): drop the
   external_session_id PATCH entirely (claude never does it — the new cascade is
   already live on the existing agy, reached via the rewritten bridge state, not
   via a later --resume). New sequence: GET old snapshot -> POST /v1/sessions
   (agent_id + inherited bridge-id label) -> PATCH runner_id -> terminal
   /transfer old->new -> write_bridge_state(session_id=new, conversation_id=Y)
   -> clear old runner_id. The bridge-state write lands AFTER the transfer, so
   the runner's auto-create guard (below) still sees the OLD session owning the
   terminal while the new session binds.

2. The auto-cold-start-avoidance mechanism, replicated exactly from claude:
   claude gates _auto_create_claude_terminal on _terminal_inbound, computed by
   _claude_native_terminal_arrives_via_transfer — it reads the shared bridge's
   active session and returns True when a DIFFERENT session on the same bridge
   owns a live terminal (the one about to transfer in), so auto-create skips.
   It's race-free because the rotation writes the new active-session marker only
   AFTER the transfer, so at bind time the bridge still names the old
   terminal-owning session. Added the antigravity mirror
   _antigravity_native_terminal_arrives_via_transfer (reads
   read_bridge_state().session_id against the antigravity:main terminal) and
   wired the antigravity branch with the same _antigravity_inbound gate +
   "rotation target" skip log.

After a successful rotation the reader is bound to Y; GetAllCascadeTrajectories
shows Y as the most-recently-active root cascade == bound, so
_detect_rotated_cascade returns None and the detector does not re-fire.

Tests: rewrote the rotation sequence test to assert the claude sequence and that
NO external_session_id PATCH is made; added a parametrized runner guard test
(mirroring the claude one) proving an antigravity rotation-target session does
NOT trigger _auto_create_antigravity_terminal while fresh/dead-terminal sessions
still do. Verified the guard is load-bearing (neutering it reds the
rotation-target case). Found by live e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): record T-D poll-path double-render follow-up (2960b9b2) in SDD report

Accurate SDD report update documenting the earlier poll-path double-render fix
(commit 2960b9b2): map_step_to_events now DONE-gates PLANNER_RESPONSE committed
items symmetrically with the tool-result gate, so both stream and poll paths post
exactly one final message. Left unstaged across the session; committed now to
finish with a clean working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(antigravity-native): document /clear-before-first-turn rationale in _detect_rotated_cascade

Behavior-identical comment clarification. The bound_activity-is-None branch
(rotate to any active sibling) is INTENTIONAL: it handles the
/clear-before-first-turn case (a freshly-bound cascade that never took a turn,
then a sibling the user actually used) — staying bound there would strand the
reader on the dead pre-/clear cascade. A final-review pass proposed "hardening"
this to stay-bound; that would regress this reachable case, so the comment now
records why the branch exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): close every tool call in the step mapper (P0 #2)

The RPC step mapper emitted a `function_call` for every entry in
`plannerResponse.toolCalls` unconditionally, but only emitted a paired
`function_call_output` for three result types (RUN_COMMAND /
LIST_DIRECTORY / ASK_QUESTION) at DONE with non-empty text. Three common
paths therefore left a permanently-dangling `function_call` (the reader
is the sole completion signal and the server pairs strictly by call_id,
so an unpaired call renders a perpetual in-progress tool card):

  (a) result types with no extractor (VIEW_FILE / CODE_ACTION, live on
      agy 1.0.10) fell through to `return []`;
  (b) terminal-ERROR tool steps (e.g. an ignored/timed-out interactive
      prompt that flips WAITING->ERROR) returned [];
  (c) a successful RUN_COMMAND whose `combinedOutput.full` is proto3-
      omitted (cd / mkdir / redirects) returned [].

Fix: treat a step as a tool result when it is a known type OR carries a
`metadata.toolCall.id`, and on a terminal status (DONE/ERROR) always emit
exactly one `function_call_output` keyed on that id — type-specific text
when available, an error marker on ERROR, else an empty string. WAITING /
RUNNING / PENDING still emit nothing (no result yet). System steps with
no toolCall.id (CHECKPOINT / CONVERSATION_HISTORY) remain skipped.

Tests: flip the ERROR test to assert a paired error output, add closure
coverage for empty-output DONE commands and unmapped result types, and a
guard that id-less system steps are still skipped. 84 mapper + 102 reader
tests pass.

Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Isaac

* fix(antigravity-native): close the turn on a terminal/degenerate planner (P0 #4)

The reader opened a turn (RUNNING) on USER_INPUT but only closed it (IDLE)
on a DONE PLANNER_RESPONSE that carried assistant text and no tool calls.
A turn that ended in any other terminal shape — a terminal-ERROR planner,
or a DONE planner with neither text nor a tool call — never fired IDLE, so
`turn_active` stuck True: the web/mobile spinner spun forever AND the next
turn's USER_INPUT could not re-open RUNNING (it is gated on `not
turn_active`), leaving the UI frozen.

Add `_is_turn_close_step`, used by `_emit_step` in place of the narrower
`_is_assistant_text_close_step`: a turn now also closes on a terminal-ERROR
PLANNER_RESPONSE and on a DONE PLANNER_RESPONSE that dispatches no tool
call (degenerate end). A planner that DOES dispatch a tool call is still a
continuation (never a close), and non-planner/tool-result steps never close
(a recovery planner follows). The existing text-close predicate and its
tests are unchanged.

Known follow-up (out of scope here): a turn interrupted mid-flight from the
agy TUI where agy emits no terminal planner step still relies on the next
planner to close; a periodic reconciliation against agy's cascade status
would cover that fully.

Tests: 5 predicate cases + an integration test proving an ERROR-planner
turn emits RUNNING then IDLE. 69 reader tests pass.

Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Isaac

* fix(antigravity-native): make agy ask_question round-trip over the web UI (P0 #3)

The agy elicitation adapter stamped the question under the params key
`ask_question` and expected the web verdict to carry `selectedOptionIds`.
But the SPA only renders the interactive AskUserQuestion form off the
`ask_user_question` key, and that form posts a flat `{question -> selected
label(s)}` map — it never produces `selectedOptionIds`. So an agy
ask_question rendered as a generic approve/reject card and, on accept,
the adapter received `content=None` and delivered `{"askQuestion":
{"responses": []}}` — the user's actual choice was silently dropped.

Fix (reuses the existing, tested SPA form — no behavioral frontend
change):
- `_agy_ask_question_params` now also stamps the question under
  `ask_user_question` in the Claude AskUserQuestion shape (agy option
  `text` -> Claude option `label`; each question gets a synthetic string
  id == its index). The raw agy spec stays under `ask_question` for the
  reverse mapping.
- `_agy_ask_question_response` now consumes the form's answer map (keyed
  by question id, valued by selected labels / custom text) and maps each
  label back to its agy option id by matching option `text`; unmatched
  labels become `writeInResponse`. EVERY question is answered, so the
  prior single-question limitation is gone — multi-question prompts
  round-trip fully.
- ApprovalCard: title agy prompts "Antigravity needs your input" instead
  of defaulting to "Claude has questions" (mirrors the codex branch).

Tests: rewrote the adapter interaction-payload tests to the real form
shape, added `ask_user_question` params coverage + multi-question
round-trip, updated the bridge interaction tests, and added a frontend
title test. Adapter/interactions (105) + ApprovalCard (35) pass.

Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Isaac

* fix(executor-adapter): drop id-less ToolCallComplete instead of emitting an empty-call_id output (P0 #1)

The shared `ExecutorAdapter` replaced the old blanket suppression
(`if self._current_ctx is not None: return`) with an id-scoped check
(`call_id = ... or ""; if call_id and call_id in self._dispatched_call_ids:
return`) so internal-tool executors (antigravity) could surface their own
tool outputs. But the `or ""` coercion left the id-less path UNGUARDED:
`if call_id and ...` is False for `call_id == ""`, so an id-less
`ToolCallComplete` now fell through and emitted a `function_call_output`
with `call_id == ""`.

`ExecutorAdapter` is shared by every adapter-backed harness. pi emits its
`ToolCallRequest`/`ToolCallComplete` with no metadata/call_id at all
(omnigent/inner/pi_executor.py:2140,2211), so this fired deterministically:
an empty-id output cannot pair (downstream pairs STRICTLY by call_id and
discards empty ones) and rendered a stray ghost "Waiting for output" card —
a regression vs main, whose blanket rule suppressed these. claude-sdk /
cursor / openai-agents are reachable via the same id-less path.

Fix: suppress BOTH a dispatched id AND an empty call_id
(`if not call_id or call_id in self._dispatched_call_ids: return`). This
restores main's suppression for id-less completions while keeping the PR's
real-id emission for internal-tool executors (antigravity stamps a real
positional id, so its completions still emit and pair). This matches the
contract the code comments and the sibling test
`test_internal_errored_tool_complete_emits_output_with_real_call_id`
already assert ("must NOT carry call_id == ''").

Also fixes the `tool_call` mock harness, which modeled an unrealistic
asymmetric shape (request with a real call_id, completion id-less) — a real
handles_tools_internally executor stamps the id on both, so the mock now
does too, and its observed function_call + function_call_output pair.

Tests: add `test_idless_tool_complete_is_suppressed`; the adapter suite +
antigravity(sdk/native) + claude-sdk + codex + cursor + copilot +
openai-agents + pi executor suites all pass (590 tests).

NOTE (for human review): this is shared code across 7 harnesses. Unit
suites are green, but a live multi-harness smoke (pi + claude-sdk tool
rendering) is worth doing before merge.

Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Isaac

* fix(ci): regen openapi.json, exclude antigravity-native from live matrix, reformat

Three failures surfaced once the security gate was waived and the gated
jobs ran for the first time:

- Pytest `test_openapi_drift`: the committed `openapi.json` was stale.
  Regenerated via `scripts/dump_openapi.py` so it includes the new
  `/v1/sessions/{id}/hooks/antigravity-elicitation-request` endpoint (and
  the `external_output_reasoning_delta` post_event docstring pulled in by
  the main merge).
- E2E `test_run_harness_live_matrix_covers_registered_coding_harnesses`:
  `antigravity-native` is a registered coding harness but a terminal-first
  TUI launched via `omnigent antigravity` (not `omnigent run --harness ...`)
  AND is Gemini-native (no Databricks-gateway probe wiring), so it is
  excluded from `expected_live_harnesses` like
  claude-native / goose-native / antigravity.
- Pre-commit ruff-format: reformat `tests/test_antigravity_native_interactions.py`
  (the P0 #3 content-shape edit shortened those calls enough to fit on one
  line; ruff-format collapses them).

Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Isaac

* fix(antigravity-native): use the functional RPC timeout for model + cascade reads

get_available_models and get_all_cascade_trajectories are FUNCTIONAL connect-RPCs
but were built on the tight _PROBE_TIMEOUT_S (2s) reserved for port-discovery
probes. The module's own timeout policy (antigravity_native_rpc.py:100-115)
mandates _RPC_CALL_TIMEOUT_S (30s) for functional calls: a 2s deadline raises an
un-retried TimeoutException against a momentarily-busy agy.

- get_available_models resolves the per-turn model enum on the send path with no
  retry (executor._resolve_plan_model); a 2s abort surfaced a spurious "no model"
  error and failed the turn instead of completing it.
- get_all_cascade_trajectories is the /clear-rotation functional poll (morally a
  step-read, like get_trajectory_steps which already uses 30s).

Connection-refused (a force-killed agy port) still raises ConnectError
immediately — not subject to the read timeout — so the wider deadline only adds
headroom for an alive-but-busy agy; it never delays the dead-port path
(verified live: ConnectError in <20ms against a refused port).

Discovery probes (_heartbeat_ok, _conversation_matches) keep _PROBE_TIMEOUT_S.
Tests updated to assert both functions now use the functional timeout and that
the probes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-native): log the rotation detector's benign ConnectError at DEBUG

_watch_for_rotation polls GetAllCascadeTrajectories every few seconds. When the
agy port is gone — torn down / rotated / shut down before this fire-and-forget
detector is cancelled — each tick raises httpx.ConnectError (connection refused)
and was logged at WARNING, spamming the log during an otherwise-clean teardown.

Add a ConnectError arm that logs at DEBUG and continues; the broad
(httpx.HTTPError, ValueError) arm is unchanged, so a hung-but-listening port
(ReadTimeout) and every other fault still WARN. Control flow is identical (both
continue). A genuinely dead agy stays loudly visible: the reader BODY
(stream + poll-fallback) independently WARNs on the path that matters; this only
de-dups the secondary detector's redundant noise.

Tests: a real-ConnectError tick logs exactly one DEBUG record and zero WARNINGs
while the loop retries; a ReadTimeout tick still logs WARNING. Live-verified
through the real _watch_for_rotation against a real OS connection-refused port
(2 ConnectError ticks -> 2 DEBUG, 0 WARNING, no rotation, no leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): make the top-level-elicitations guard environment-invariant

test_top_level_elicitations_route_is_not_mounted asserted a flat 404, but
create_app mounts a catch-all SPA (Mount path="") whenever a local web-ui build
exists at omnigent/server/static/web-ui/ (a gitignored dev artifact, absent on
main/CI). Starlette's StaticFiles matches any path but rejects a non-GET method
with 405, so the test passed on CI (404) yet failed in a worktree with a local
SPA build (405) — environment-fragile, unrelated to whether the legacy route is
mounted.

Harden it to express the real contract two complementary ways:
- route table (app fixture): no APIRoute serves POST /v1/elicitations/{id}
  (catches an exact re-mount even if its handler would 404 at runtime).
- HTTP (client fixture, same app): status is 404 or 405 — both mean "no handler
  ran". A re-mounted legacy handler returns 400/501/2xx for this body, never
  404/405, so the guard still bites.

Passes with and without the local SPA build present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ap-web): render native session for /compact composer tests (#1139 fallout)

PR #1139 ("hide /compact for non-native harnesses") gated the /compact
slash command behind `showCompact = isNativeWrapper`, but did not update
ChatPage.composer.test.tsx — three tests there use /compact as the
representative first built-in command (default highlight, ArrowDown
target, and the effort-visibility anchor) and render via composerProps()
whose default isNativeWrapper is false, so /compact is now hidden and the
assertions fail (`Unable to find [data-testid="slash-menu-item-compact"]`).

Render those three tests as a native-wrapper session (isNativeWrapper:
true) so /compact appears, matching #1139's intent. The default helper is
left non-native so the /model-routing test that relies on it is unchanged.

Note: this breakage also exists on main (ChatPage.tsx + this test file are
identical there); the same fix applies upstream.

Co-authored-by: Bryan Li <bryanli@users.noreply.github.com>
Co-authored-by: Isaac <isaac@example.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Bryan Li <bryanli@users.noreply.github.com>
Co-authored-by: Isaac <isaac@example.com>
2026-06-24 19:53:32 +00:00
Dhruv Gupta 5616b13dbf fix(polly): drop the opencode sub-agent to stay loadable on older clients (#1150)
polly ships an `opencode` sub-agent (`harness: opencode-native`) plus a codex
`allowed_harnesses: [codex-native, opencode-native]` opt-in. Any client whose
harness allowlist predates `opencode-native` — the whole installed base before
that release — fails to validate the spec and can't launch *any* polly (matei's
incident).

The graceful-degradation fix (#1145, merged) stops a future such addition from
bricking the orchestrator, but it only helps clients that *carry* it. Removing
opencode from polly now also unblocks already-deployed older clients, which
can't be retrofitted — belt and suspenders. Verified: an `omnigent==0.2.0`
client (allowlist predates `opencode-native`) fails to load main's polly today,
and loads this opencode-free polly cleanly with `claude_code`/`codex`/`pi`.

Reverts polly to its three-worker roster (claude_code / codex / pi):
  - delete examples/polly/agents/opencode/
  - drop `opencode` from tools.agents and every prompt reference (back to
    "exactly THREE sub-agents", three-vendor cross-review)
  - drop the codex `allowed_harnesses` opt-in, so polly can't spawn an
    opencode-native child via an args.harness override either — no
    `opencode-native` is left anywhere in polly's spec surface.

debby is unchanged (keeps the optional OpenCode perspective; default fanout is
still claude + gpt). The opencode harness itself is untouched.

Tests:
  - test_opencode_polly_debby_worker.py: replace polly's "declares opencode"
    assertions with a negative guard (polly stays opencode-free, incl. no
    allowed_harnesses override); keep the debby coverage.
  - test_example_polly.py: roster back to three workers / three vendors;
    function-policy count 7 -> 6.
  - test_chat.py brain-harness-override: drop opencode from polly's expected
    worker harnesses.

Co-authored-by: Isaac
2026-06-24 19:12:54 +00:00
Zeyi (Rice) Fan 211c1e0273 chore: change pr template (#1080) 2026-06-24 11:31:42 -07:00
Dhruv Gupta 7f6637bcc4 fix(spec): gracefully drop unsupported sub-agents on the execution path (#1145)
An older client (runner/host) that resolves a spec produced by a newer
server fails to launch the *whole* agent when any sub-agent names a
harness the client's allowlist doesn't know. matei hit this when polly
gained an `opencode` sub-agent: old runners failed every polly dispatch
with `sub_agents['opencode'].executor.config.harness: must be one of
[...], got 'opencode-native'` — one unrunnable sub-agent took down the
entire orchestrator.

Add `prune_invalid_sub_agents` to `spec.load()`: when set, a sub-agent
whose subtree fails validation is dropped (removed from `sub_agents` and
the parent's `tools.agents` reference) with a WARNING, and the rest of
the spec loads. The root must still validate — a genuine root error
always raises. Pruning is depth-first, so a bad grandchild doesn't take
out an otherwise-valid sub-tree.

Enabled only on the execution paths, where a bundle was already
validated by the server that produced it, so a sub-agent failure means
version skew (this client can't run it), not an authoring mistake:
  - runner `_resolve_agent_spec_from_server` (matei's exact path)
  - server-side `AgentCache` load/replace/extract ("old host" case)
Authoring/upload paths (`omnigent run`, `validate_agent_bundle`) stay
strict so real harness typos still surface to the author.

Tests:
  - tests/spec/test_load.py: drop unknown-harness sub-agent, strict
    default still fails, root error never masked, no-op when all valid,
    WARNING is logged, grandchild pruned without losing a valid child.
  - tests/server/test_builtin_bundles.py: the real shipped polly/debby
    bundles survive a newer-server sub-agent the client can't validate —
    parent + every real worker load; only the unsupported one drops.

Co-authored-by: Isaac
2026-06-24 18:29:53 +00:00
Tomu Hirata 1b53b9ed70 feat(harness): add Hermes Agent harness with policy enforcement (#1132)
* feat(harness): add Hermes Agent harness with policy enforcement

Add harness: hermes that wraps the Hermes Agent CLI as an Omnigent
executor. Address review comments: remove harness-specific docs from
AGENT_YAML_SPEC.md and enforce Omnigent policies on Hermes native
tools via a --pre-tool-hook script that evaluates PHASE_TOOL_CALL
against the Omnigent server before each tool execution.

Co-authored-by: Isaac

* refactor(hermes): use HERMES_HOME + native pre_tool_call hook for policy enforcement

Replace the made-up --pre-tool-hook CLI flag with Hermes' real
pre_tool_call shell hook mechanism. Now creates a per-session
HERMES_HOME (like Codex's CODEX_HOME) containing:
- config.yaml with hooks_auto_accept and the pre_tool_call hook
- omnigent-policy-hook.sh wrapper that sets env vars
- shell-hooks-allowlist.json to skip consent prompts

The hook uses Hermes' native protocol: JSON on stdin with
hook_event_name/tool_name/tool_input, and {"decision": "block",
"reason": "..."} on stdout to deny.

Co-authored-by: Isaac

* fix: remove examples/hermes, add hermes to spec harness allowlist

Remove the example bundle (not needed for the harness itself) to
fix the e2e coverage sync test. Add "hermes" to OMNIGENT_HARNESSES
so user-authored harness: hermes specs pass validation.

Co-authored-by: Isaac

* fix(test): exclude hermes from e2e harness coverage matrix

Hermes requires its own CLI binary and authenticates through its own
provider config rather than the shared gateway/profile probe wiring,
so it cannot be exercised by the standard HARNESS_PROBES matrix.

Co-authored-by: Isaac

* fix(hermes): merge user config into per-session HERMES_HOME + add to omni setup

The per-session HERMES_HOME (created for policy hooks) was missing the
user's model/provider config from ~/.hermes/config.yaml, causing
"No inference provider configured" errors. Now merges the user's config
and .env into the per-session directory.

Also adds Hermes to omni setup (install spec, readiness gate, interactive
menu with `hermes model` drill-in).

Co-authored-by: Isaac

* fix(hermes): only merge inference-relevant keys from user config

The full user config includes sections like secrets.bitwarden that
reference env vars (BWS_ACCESS_TOKEN) not available in the Omnigent
harness context. Filter to only model/provider keys needed for
inference authentication.

Co-authored-by: Isaac

* fix(hermes): copy auth.json into per-session HERMES_HOME

Hermes stores provider credentials (from `hermes auth` / `hermes model`)
in auth.json. The per-session HERMES_HOME needs this file to
authenticate with the configured inference provider.

Co-authored-by: Isaac

* fix(hermes): strip ⚠ warning lines from Hermes output

Hermes emits warnings with ⚠ prefix (e.g. tirith scanner notices) in
addition to "Warning:" prefixed lines. Strip both so they don't leak
through to the user.

Co-authored-by: Isaac

* fix(hermes): use correct allowlist format for shell hooks

Hermes' allowlist format is {"approvals": [{"event": ..., "command": ...}]},
not {command: true}. The wrong format caused hooks to be registered but
not allowlisted, so policy enforcement never fired.

Also added diagnostic logging for when HERMES_HOME setup is skipped.

Co-authored-by: Isaac

* fix(hermes): increase hook timeout to 86400s for ASK policy support

The shell hook subprocess timeout must match the server's ask_timeout
(one day) so the hook stays alive while the human responds to a web-UI
approval card. With the previous 60s timeout, ASK policy evaluations
would time out and Hermes would silently skip the hook.

Co-authored-by: Isaac

* style: fix ruff formatting for hermes executor and harness install

Co-authored-by: Isaac

* feat(policy): add Hermes tool names to file & shell approval policy

The built-in "Require Approval for File & Shell Operations" policy only
matched tool names from Claude/Codex/Cursor/Pi. Hermes uses different
names (terminal, execute_code, read_file, write_file, search_files)
which were not recognized, so policy enforcement silently allowed all
Hermes tool calls.

Co-authored-by: Isaac
2026-06-24 16:32:47 +00:00
ashrafosman c197cc716a feat(deploy): host Omnigent on Databricks Apps backed by Lakebase Postgres (#956)
* feat(deploy): host Omnigent on Databricks Apps backed by Lakebase Postgres

Add a Databricks Apps deploy layer and make the DB engine refresh
Lakebase's short-lived OAuth token per connection.

Token-aware engine (omnigent/db/utils.py):
- Opt-in, backward compatible. A SQLAlchemy `do_connect` listener mints a
  fresh OAuth token as the connection password on every NEW connection,
  and pool_recycle drops to 600s so tokens refresh ahead of their ~1h
  expiry. Activates only when a token provider resolves — gated on
  OMNIGENT_LAKEBASE_INSTANCE or an injected provider
  (set_lakebase_token_provider). Static SQLite and static-password
  Postgres URIs are byte-for-byte unchanged (pool_recycle stays 1800,
  no listener). Token minted via
  WorkspaceClient().database.generate_database_credential.
- Unit tests cover: static path unchanged, token callback invoked per
  connection, env/override resolution, and both pool_recycle values.

Databricks Apps deploy layer (deploy/databricks/):
- src/app.py: thin shim over the generic Docker entrypoint — bridges
  DATABRICKS_APP_PORT->PORT and the injected Lakebase PG* vars into a
  password-less DATABASE_URL, then reuses _resolve_config/build_app.
  Migrations run through the token-aware engine. Header auth by default.
- src/app.yaml, databricks.yml (DAB), deploy.py, grant_sp_perms.py.
- Single replica by design (in-memory runner registry); ARTIFACT_DIR
  points at a persistent UC Volume (or OMNIGENT_ARTIFACT_URI=s3://).
- README documents the Lakebase URI format, token rotation, the
  single-replica constraint, and artifact-store setup.
- Added alongside deploy/modal (not a replacement); indexed in
  deploy/README.md.

Co-authored-by: Isaac

* fix(deploy): address cross-review on Lakebase grant + token-refresh test

- grant_sp_perms.py: replace substring-based "already exists" detection
  with the typed databricks.sdk.errors.ResourceAlreadyExists, so genuine
  4xx/5xx errors are no longer swallowed. When --superuser is requested
  and the role already exists, fetch it and ALTER (delete + recreate with
  DATABRICKS_SUPERUSER membership) instead of silently skipping, making
  first-boot migrations safe.
- test_utils.py: strengthen the static-path test to enumerate the engine's
  actual do_connect listeners and assert the set is empty, then prove the
  assertion is sensitive by installing the real listener and confirming it
  appears. A regression that wrongly attaches a token listener now fails.
- deploy.py: include --superuser in the printed post-deploy grant command.

Co-authored-by: Isaac

* fix(deploy): make Lakebase --superuser upgrade crash-safe

The --superuser upgrade path for an existing role did delete-then-recreate
inline. If the recreate failed after the delete succeeded, the app's
Postgres role was permanently gone and DB auth broke until manual repair.

The Lakebase role API (databricks-sdk 0.115.0) exposes only
create/delete/get/list — no update/alter/patch verb (verified against
DatabaseAPI), so a non-destructive elevation isn't possible. Instead make
the delete+recreate transactional: capture the existing role's full config
first, delete, recreate inside a try/except, and on ANY recreate failure
best-effort restore the original role and re-raise with a clear error.
Invariant: the role is never left deleted-and-not-recreated.

Extracted the logic into _upgrade_role_to_superuser and added unit tests in
tests/deploy/test_grant_sp_perms.py covering: recreate-failure restores the
original role, total failure flags the missing role, already-superuser does
no destructive work, and the happy-path upgrade.

Co-authored-by: Isaac

* fix(deploy): make role delete part of crash-safe superuser upgrade transaction

The destructive delete_database_instance_role call in
_upgrade_role_to_superuser sat outside the recovery try/except. If the
delete RPC removed the role server-side but then failed on the response
(timeout/transport error), the function exited immediately — never
attempting recreate/restore and never raising the explicit MISSING-role
guidance. That left a plausible deleted-and-not-recreated path unhandled.

Wrap the delete in try/except. On a delete error, probe the live role
state: if the role is gone (delete took effect despite the error), run
the same recreate/restore path as a post-delete failure (restore the
captured config; if THAT fails, raise the distinct MISSING-role error
with manual-repair guidance). If the role still exists, nothing was
destroyed, so raise a clear error without recreating. Invariant holds on
every path: the role is never left deleted-and-not-recreated without
raising the explicit MISSING-role guidance.

Add tests covering delete-after-removal (restore succeeds → role intact;
restore fails → MISSING error) and delete-with-role-still-present
(non-destructive, clear error, role unchanged).

Co-authored-by: Isaac

* fix(deploy): narrow role-delete probe to typed not-found

The delete-error recovery probe caught *any* exception from
get_database_instance_role and treated it as "role gone", which could
misclassify a transient/unrelated probe failure and fire a spurious
restore (or even double-create an intact role).

Narrow the probe to the SDK's typed NotFound family so only a genuine
"role missing" drives the recreate/restore path. Any other probe error
now surfaces an explicit INDETERMINATE-state error with operator
guidance instead of being silently classified as gone — preserving the
crash-safety invariant (never exit a possibly-deleted role without
explicit MISSING/INDETERMINATE guidance).

Tests: model the SDK's typed not-found in the fake probe; add coverage
for (a) genuine not-found probe -> restore runs, and (b) transient
non-not-found probe error -> INDETERMINATE error, no spurious restore.

Co-authored-by: Isaac

* test(db): mark psycopg-dependent engine tests with @pytest.mark.databricks

The three tests that build a postgresql+psycopg engine need the
`databricks` extra (psycopg). The marker routes them to the dedicated
`Pytest (databricks)` lane (omnigent-ai/omnigent#1140) and deselects
them from the lean lanes, which run `-m "not databricks"`.

Co-authored-by: Isaac

---------

Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-06-24 15:56:41 +00:00
Pat Sukprasert 2b8822588f ci(test): add Pytest (databricks) lane for the databricks extra (#1140)
The `databricks` extra (psycopg / databricks-sdk / mlflow) isn't installed
on the standard pytest lanes (they use `--extra all --extra dev`, which has
databricks-sdk but not psycopg). So a test that builds a postgresql+psycopg
engine or calls the Databricks SDK fails with `ModuleNotFoundError: psycopg`
on the catch-all `misc` lane.

Add a `databricks` pytest marker and a dedicated `Pytest (databricks)` lane
that installs `--extra databricks` and runs `-m databricks`. The standard
lanes now run `-m "not databricks"`, so marked tests are deselected there
and selected only in the new lane. Register the marker in pyproject and gate
the lane in merge-ready's required checks.

Decouples the upcoming Lakebase token-engine tests (psycopg-dependent) from
the lean lanes via the @pytest.mark.databricks decorator.

Co-authored-by: Isaac
2026-06-24 22:34:12 +07:00
Kobi Kadosh 92a99cbdf8 feat(web_search_nimble): send X-Client-Source header on search requests (#1103) 2026-06-24 14:59:14 +00:00
Tomu Hirata 64c3f46c6c fix(ui): hide /compact for non-native harnesses (openai-agents-sdk, claude-sdk) (#1139)
/compact only works for native wrappers (claude-native, codex-native)
which inject the slash command into the terminal. SDK harnesses don't
support explicit compaction yet — the Claude Agent SDK lacks a compact
control request, and sending /compact as a user message is a no-op.

Hide the command from the slash-command menu and show an error if typed
manually in non-native sessions.

Co-authored-by: Isaac
2026-06-24 14:39:26 +00:00
Rafael Souza 07c84eb3c5 feat(tools): sys_session_share — agent-facing session sharing (#985)
* feat(tools): sys_session_share — agent-facing session sharing

Add a runner-dispatched `sys_session_share` built-in tool so an agent can
grant another user (or the public) access to a session from inside its own
run — no shell, no binary, no PATH/sandbox assumptions. It manages access
grants via PUT /v1/sessions/{id}/permissions over the runner's authenticated
server client.

- session_id defaults to the caller's own conversation (share "this" session
  with just a user_id); level is read/edit/manage mapped to the server's
  numeric level; __public__ grants anonymous read.
- Registered always-on alongside the read-only session discovery tools;
  authority is whatever the server enforces (caller needs manage-level, which
  the session owner has).
- Auto-included in the session-query REST surface via _SESSION_QUERY_TOOLS.

Part 1 of the session-sharing CUJ in #983 (the agent-first path). The
companion `omnigent share` CLI follows as a separate PR.

Tests: dispatch handler (path/body/level mapping + success), typed error
mapping (404/401/403), client-side level validation, and always-on
ToolManager registration.

Co-authored-by: Isaac

* fix(tools): gate sys_session_share opt-in; surface server detail on 4xx

Addresses review on #985: share mutates access control (it can expose a
session to a third party or, via __public__, to anonymous read of the full
transcript), so the read-only tools' "no new authority" rationale does not
apply — the server can confirm manage-level access but cannot tell owner
intent from a prompt-injected agent.

- Drop sys_session_share from the unconditional registration in
  _register_sub_agent_tools; gate it behind the same `tools.agents` /
  `spawn: true` opt-in as send/close/create.
- Surface the server's own error message on 4xx the typed branches don't
  claim (e.g. the 400 "Public access is limited to read-only (level 1)" for
  a __public__ grant above read) instead of flattening to "returned 400",
  via a small _omnigent_error_message helper that reads the
  {"error": {"message": ...}} envelope.

Tests: share is absent without opt-in and present under spawn / declared
agents; 4xx detail surfacing returns the server's verbatim message.

Co-authored-by: Isaac

* refactor(tools): gate sys_session_share on a dedicated `share` flag

Replaces the spawn/declared-agents opt-in (review follow-up on #985) with a
purpose-built, tri-state `share:` capability flag — sharing is a distinct
authority from spawning children, and folding it into `spawn` forced agents
that only want to share to also enable arbitrary child-spawning.

New top-level spec flag `share:` (SharePolicy, modeled like `spawn:`):
- `none` (default): sys_session_share is not registered.
- `non-public`: registered; may grant named users only.
- `public`: registered; may additionally grant `__public__` (anonymous read).

This flag is now the SOLE enabler of the tool, fully decoupled from
spawn / tools.agents. Plumbed through both spec paths: spec/parser.py +
spec/types.py (AgentSpec), and the inner datamodel (AgentDef.share,
loader, AgentDef->AgentSpec translation), mirroring how `spawn` flows.

Enforcement is two-layered:
- Advertisement: ToolManager registers the tool only when share != none,
  and passes allow_public so the schema advertises `__public__` only under
  `public`.
- Hard gate: the runner's _session_share_via_rest enforces the policy
  before the PUT (none/unknown -> refuse all; non-public -> refuse
  __public__). The server can't see the spec's share flag, so the runner
  is the real gate — a prompt-injected call naming the tool can't escalate.

Tests: share parsing (each policy + default + invalid fails loud);
registration gated by share and decoupled from spawn/agents; schema
reflects allow_public; dispatch gate refuses when disabled / refuses
__public__ under non-public / allows it under public.

Co-authored-by: Isaac

* refactor(spec): rename share flag to `agent_session_sharing`

`share` was misleading — it reads like a switch on whether the session can
be shared at all, but it has no bearing on server-API or CLI sharing. It
only governs whether the AGENT may share the session it is running in, via
the sys_session_share tool. Rename the spec flag (and the AgentDef field /
YAML key) to `agent_session_sharing` to say exactly that: the agent, the
verb share, the session it acts on.

Pure rename — no behavior change. The SharePolicy enum and its
none/non-public/public values are unchanged; only the field/key name moves,
across both spec paths (parser + AgentSpec, and the inner AgentDef / loader
/ AgentDef->AgentSpec translation) plus the runner's policy read and error
messages. Tests and docstrings updated to match.

Co-authored-by: Isaac

* docs(spawn): fix stale `share:` refs in SysSessionShareTool docstrings

The flag was renamed to `agent_session_sharing:`, but three docstring
references in SysSessionShareTool still said `share:`. Align them with
the actual spec key.

Co-authored-by: Isaac

---------

Co-authored-by: Rafa Souza <rafa.souza@databricks.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-06-24 14:20:20 +00:00
Pat Sukprasert 0ca153f4a3 docs(deploy): add Databricks Apps deployment guide (#952)
* docs(deploy): add Databricks Apps deployment guide

OSS shipped the `databricks` extra and DatabricksVolumesArtifactStore but
not the deploy guide (deploy/databricks/ is excluded from the internal→OSS
export). Three dangling references pointed at the missing dir
(pyproject.toml psycopg comment + two .gitignore lines).

Add a genericized deploy/databricks/ — deploy.py, build.sh, grant_sp_perms.py,
databricks.yml, src/app.py, src/app.yaml, README.md — with internal infra
scrubbed: public PyPI (honors UV_INDEX_URL), example.databricks.com host, no
influencer target, generic app/profile names. Drops the internal CD-ops
SKILL.md.

Wire it into deploy/README.md (menu row + tree). Fix a self-contradicting
.gitignore line that ignored deploy/databricks/**/*.whl despite the adjacent
comment — it would have broken `bundle deploy` file sync.

Co-authored-by: Isaac

* fix(deploy): address Databricks deploy review comments

- deploy.py: drop dead `backups = {}` reassignment in main()'s finally
  (flagged by code-quality bot).
- grant_sp_perms.py: build psycopg connection params as keyword args
  instead of interpolating the Lakebase OAuth token into a conninfo
  string, so token contents can't be mis-parsed.
- README.md: fix first-time setup ordering — the SP grant requires the
  app/SP, which only exist after an initial deploy; make the
  deploy → grant → redeploy sequence explicit. Clarify the Lakebase
  resource-slug (databricks-postgres) vs SQL dbname (databricks_postgres)
  mapping. Document the X-Forwarded-Email / header-auth trust boundary.

Co-authored-by: Isaac

* style(deploy): ruff-format deploy.py

Reflow a help string that fits on one line after shortening the
example app name. No behavior change.

Co-authored-by: Isaac
2026-06-24 13:44:43 +00:00
Serena Ruan 417b914a4d feat(qwen): add native-qwen TUI harness with resume, readiness gate, and clean-exit (#1134)
Add a terminal-native Qwen Code harness (`qwen-native`, alias `native-qwen`)
that embeds the live `qwen` TUI in the web UI, alongside the existing ACP
`qwen` harness. Unlike the goose/cursor tmux-send-keys natives, it drives
qwen's built-in remote-control protocol: web turns are appended to qwen's
`--input-file` and the transcript is mirrored back by tailing the structured
`--json-file` event stream.

Highlights (all verified against qwen v0.18.1-preview.1):
- Bridge/executor/forwarder/CLI-wrapper + full registration (harness registry,
  aliases, native-coding-agent, wrapper labels, install spec, readiness,
  resume dispatch, resource role, server built-in seeding so Qwen Code shows in
  the new-session picker).
- Readiness gate: the executor waits for qwen's first `system` event before the
  first submit, fixing the boot-order race where a message appended before
  qwen's input watcher started was silently dropped.
- Session resume via the `external_session_id` convention (consistent with
  claude-/codex-/pi-native, fork-capable): deterministic per-conversation qwen
  session id, `--session-id` on first launch, `--resume` once a recording
  exists; qwen restores its own TUI history and emits only new events, so no
  double-mirroring.
- Clean TUI quit: a qwen required-terminal exit is treated as a normal
  shutdown (publishes idle, no `required_terminal_exited` crash card).
- Web UI: terminal pane recognized as an agent terminal; composer hides the
  model/effort chip for vendor-owned-model native sessions.

Docs: docs/QWEN_NATIVE_DESIGN.md (design) and docs/QWEN_FOLLOWUPS.md
(elicitation card, usage/cost/model surfacing tracked as follow-ups).

Tests: executor, CLI wrapper, bridge/forwarder, server seeding, and web
(nativeCodingAgents, chatStore flags, useTerminals, statusLine).

Co-authored-by: Isaac
2026-06-24 21:38:03 +08:00
Pat Sukprasert 65a2859807 chore(ci): label UI Snapshot job [non-blocking] (#1122)
The UI Snapshot job is non-blocking for now; make that obvious in the
check name so reviewers don't treat a failure as a merge blocker. Only
the job display name changes; the workflow name stays "UI Snapshot" so
the ui-snapshot-fail-comment.yml trigger keeps matching.

Co-authored-by: Isaac
2026-06-24 13:28:46 +00:00
Serena Ruan 99d73d0b67 fix(server): create fork agent clone atomically to stop /v1/agents leak (#1125)
* fix(server): create fork agent clone atomically to stop /v1/agents leak

The fork route pre-created the cloned agent via agent_store.create()
(which never sets session_id, so the row is born as a session_id=NULL
"built-in") and committed it in its own transaction, BEFORE
fork_conversation ran in a separate transaction to bind session_id.

When fork_conversation then raised — most commonly a stale
up_to_response_id from "Fork from this response" — the pre-created row
was orphaned forever as a session_id=NULL ghost. GET /v1/agents lists
exactly the session_id IS NULL rows, so each failed fork added a
phantom "Claude Code"/"Codex" entry to the agent pickers.

Fix: create the clone inside fork_conversation's transaction (mirroring
switch_conversation_agent / create_session_with_agent), so it is born
with session_id set and rolls back with the rest of the fork on any
failure — no orphan can survive. The clone now also reuses the source
agent's name verbatim (no "(fork ...)" suffix): session-scoped rows are
exempt from the unique built-in-name index, so the suffix was only ever
a workaround for the now-removed NULL-session window.

Frontend: add the built-in/custom divider (and display-order sort) to
the fork/switch agent picker, mirroring the new-session picker, via a
shared agentGrouping module.

Tests: store-level (clone is session-scoped; failed fork leaves no
orphan) + end-to-end regression (failed fork adds nothing to
/v1/agents) + route assertions that the clone is minted atomically.

Co-authored-by: Isaac

* style(ap-web): prettier-format NewChatDialog agentList memo

Co-authored-by: Isaac

* test(e2e-ui): fork clone binds verbatim target name, not a (fork …) suffix

The fork route now clones the target agent under its own name (session-
scoped rows are exempt from the unique built-in-name index), so the Pi
fork binds a bare 'pi-native-ui' instead of 'pi-native-ui (fork <id>)'.
Update the precondition to assert the verbatim name; the model-picker
slug→display-name mapping ('pi-native-ui' → 'Pi') is still exercised.

Co-authored-by: Isaac
2026-06-24 19:59:38 +08:00
Serena Ruan cfb05db785 Revert "Native Windows support (core / degraded mode) (#1109)" (#1129)
This reverts commit c11c6a38d1.
2026-06-24 19:46:31 +08:00
Tomu Hirata 8a7b788491 feat(ui): add Create custom agent to new-session picker (#1098)
* feat(ui): add "Create custom agent" to new-session agent picker

Users can now create a custom agent directly from the agent dropdown on
the new session page. The dialog collects a name, description, harness,
and system instructions, builds a minimal agent bundle (.tar.gz)
client-side, and uses the existing multipart POST /v1/sessions endpoint
to create the agent + session atomically.

Co-authored-by: Isaac

* feat(ui): add MCP tools to create-agent dialog + e2e tests

- Add MCP server configuration UI to CreateAgentDialog: users can add
  multiple MCP servers with stdio (command/args/env) or HTTP (url/headers)
  transport, with dynamic add/remove rows
- Update agentBundle.ts to serialize MCP servers as inline `tools:` entries
  in the generated config.yaml (parsed by _parse_inline_mcp_servers)
- Add e2e UI tests covering the full create-agent flow:
  - Dialog opens from agent dropdown
  - Form fields render correctly
  - Creating an agent + submitting produces a multipart POST
  - MCP server configuration in the dialog
  - Cancel closes dialog without side effects

Co-authored-by: Isaac

* fix(ui): make harness required in create-agent dialog

Remove the "Default" option — omitting the harness produces an unusable
executor type. The picker now defaults to "Claude SDK" (first entry in
BRAIN_HARNESS_LABELS) and always writes the harness into the bundle.

Co-authored-by: Isaac

* fix(ui): add required model field + fix /c/undefined navigation

Two bugs:
1. Bundle had no executor.model, causing "Not logged in" — the omnigent
   executor rejects specs without a model. Add a required Model input
   (defaults to claude-sonnet-4-20250514) that writes executor.model
   into the generated config.yaml.
2. Navigation went to /c/undefined because the multipart POST response
   uses `session_id` (CreatedSessionResponse) while the code read `id`.
   Normalize in createBundledSession so callers see a consistent shape.

Co-authored-by: Isaac

* fix(ui): launch runner on host after bundled session create

The multipart POST /v1/sessions only creates DB rows — it doesn't
launch a runner on the host (unlike the JSON path which does both).
After the bundled create, call POST /v1/hosts/{id}/runners to bind
the session to a runner, matching the fork-resume pattern.

Co-authored-by: Isaac

* fix(ci): prettier formatting + Uint8Array TS compat for CI

- Run prettier on all modified files
- Fix Uint8Array<ArrayBufferLike> not assignable to BlobPart/BufferSource
  in stricter CI TypeScript (wrap in Blob for File, cast for writer)

Co-authored-by: Isaac

* fix(ci): use ArrayBuffer instead of Uint8Array for BlobPart compat

CI's stricter TS lib (ES2023) doesn't accept Uint8Array as BlobPart.
Use .buffer (ArrayBuffer) which is universally accepted by File and
CompressionStream.

Co-authored-by: Isaac

* fix(ci): cast .buffer to ArrayBuffer to exclude SharedArrayBuffer

ArrayBufferLike includes SharedArrayBuffer which isn't assignable to
BlobPart/BufferSource. Explicit `as ArrayBuffer` narrows the type.

Co-authored-by: Isaac

* fix(ui): pass workspace in bundled session metadata

The multipart create was sending empty metadata {}, so the session had
no workspace — the runner started in a deleted/missing directory.
Pass workspace in the metadata so the session row has it, and
launchRunner binds the runner to the correct working directory.

Co-authored-by: Isaac

* fix(ci): fix e2e test count, remove unused apiKey/baseUrl, clear default model

- Update fork_of_fork_shadows test: expect 3 menu items (added
  "Create custom agent" action item)
- Remove unused apiKey/baseUrl state and bundle fields (auth comes
  from omni setup, not the bundle)
- Remove default model value — user must explicitly choose
- Fix build: remove unused variable declarations

Co-authored-by: Isaac

* fix(e2e): fill model field in create-agent tests

Model is now required (no default), so the e2e tests must fill it
before submitting the dialog.

Co-authored-by: Isaac

* test(ui): add unit tests for agentBundle.ts

8 tests covering config.yaml generation: minimal input, description,
YAML quoting, instructions → AGENTS.md, MCP servers (stdio + http),
and different harness/model values. Uses a CompressionStream mock
(passthrough) since jsdom doesn't support it.

Co-authored-by: Isaac
2026-06-24 10:40:37 +00:00
Serena Ruan 508487bf34 chore: remove @hzub from ap-web reviewers (#1123)
* chore: remove @hzub from ap-web reviewers

Co-authored-by: Isaac

* test: drop hzub from reviewer-assignment full-pool test

Co-authored-by: Isaac
2026-06-24 18:15:21 +08:00
Serena Ruan 69abda741b feat(ap-web): add Settings surface in the sidebar (#1110)
* feat(ap-web): add Settings surface in the sidebar

Adds a persistent "Settings" entry at the bottom of the conversations
sidebar that opens a settings view. Entering settings keeps the same
sidebar card and only swaps its content to a section nav (URL-driven via
/settings/<section>), with the main area showing the selected section.

Sections:
- Appearance: theme picker (System / Light / Dark), moved out of the
  sidebar header.
- Keyboard shortcuts: the full reference shown inline (extracted a shared
  KeyboardShortcutsList reused by the existing dialog).
- Account (accounts auth only): absorbs the old AccountMenu — identity,
  admin Members/Policies links, change password, sign out. Leads the
  group and is the default landing for bare /settings when auth is on.
- Archived sessions: moved out of the sidebar list; rows aren't clickable
  and reveal Delete / Unarchive on hover.

Also: archiving a session now shows a top-center toast pointing to
Settings (new lightweight, dependency-free toast system), and the
removed ThemeModeMenu / AccountMenu components are deleted.

Co-authored-by: Isaac

* style(ap-web): prettier-format Sidebar.tsx

Re-indent the settings/conversations body branch added in the prior
commit so the ap-web prettier pre-commit hook passes.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): retarget theme-toggle test at Settings → Appearance

The sidebar header cycle-button (ThemeModeMenu) was removed; the theme
control now lives on the Settings page as System/Light/Dark radio cards.
Rewrite both cases to drive the radiogroup at /settings/appearance,
asserting the same <html> dark-class flips and ap-web-theme persistence.

Co-authored-by: Isaac

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-24 17:36:48 +08:00
Zeyi (Rice) Fan c11c6a38d1 Native Windows support (core / degraded mode) (#1109)
* feat(platform): add cross-platform process + platform primitives

Introduce two dependency-light foundation modules for native Windows
support:

- omnigent/_platform.py: IS_WINDOWS/IS_POSIX/IS_LINUX/IS_DARWIN flags,
  default_shell_argv() (cmd.exe on Windows, bash/sh on POSIX), and
  stable_user_id() (uid on POSIX, hashed login name on Windows).
- omnigent/inner/_proc.py: spawn_kwargs() (start_new_session on POSIX,
  CREATE_NEW_PROCESS_GROUP on Windows), terminate_tree()/kill_tree()
  (process-group fast path on POSIX, psutil descendant walk everywhere),
  and process_alive() replacing os.kill(pid, 0).

psutil is already a core dependency, so no new packages. POSIX-only
symbols (os.killpg/getpgid, signal.SIGKILL) are resolved via getattr so
the module imports and type-checks on Windows. No call sites switched
yet; later phases migrate to these helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): stop POSIX import-time crashes so the package loads

On Windows several modules crashed at import before anything could run,
blocking `import omnigent`, `omnigent --help`, and `omnigent server`.

- server/performance_metrics.py: make `import resource` optional and fall
  back to psutil (a core dep) for RSS on Windows; load average already
  degrades to None.
- terminals/ws_bridge.py, claude_native.py: guard the POSIX-only
  fcntl/pty/termios/tty imports behind `sys.platform != win32` (mypy
  special-cases this and still type-checks them on the Linux CI). These
  drive the tmux/PTY terminals, which are disabled on Windows.
- Replace module-level / core-path `os.getuid()` namespacing with
  _platform.stable_user_id() and `/tmp`/`TMPDIR` with tempfile.gettempdir()
  in claude_sdk_executor (core SDK path) and the cursor/goose/claude
  native bridges; guard the POSIX ownership check in claude_native_bridge.

Verified: a full walk of every omnigent submodule reports zero POSIX
import failures; `import omnigent`, `omnigent --help`, and importing
server.app / runner.app / the harness manager all succeed on Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(windows): route process spawn/kill/liveness through _proc

Replace POSIX-only process management with the cross-platform _proc
helpers so child agent/server/runner processes spawn, tear down, and are
probed correctly on Windows.

- Spawning: swap `start_new_session=True` (and the os.name-conditional
  variant) for `**_proc.spawn_kwargs()`, which yields start_new_session
  on POSIX and CREATE_NEW_PROCESS_GROUP on Windows. Sites: cli.py (×2),
  chat.py, host/local_server.py, codex_executor, codex_native_app_server,
  runner transports tcp/uds, update_check.
- Teardown: replace os.killpg-based `_terminate/_kill_process_tree` and
  the transport `_kill()` paths with _proc.terminate_tree/kill_tree
  (process-group fast path on POSIX, psutil descendant walk everywhere).
- Liveness: replace `os.kill(pid, 0)` probes with _proc.process_alive.
  This was an outright bug on Windows, where os.kill(pid, 0) maps to
  TerminateProcess and would KILL the probed process — including the
  parent-death watchdogs in runner/_entry and runtime/harnesses/_runner,
  and process_manager's orphan sweep.
- Guard the remaining force-kill signal refs with
  getattr(signal, SIGKILL, signal.SIGTERM) for the bare-pid kill paths
  in cli.py and host/local_server.py.

Remaining live SIGKILL/os.kill(pid,0) sites are POSIX-gated only (the
tmux PTY ws_bridge and the Linux-only prctl). Verified: process_alive
probes a live process without killing it; all touched modules import on
Windows; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(windows): TCP-loopback server<->harness IPC; disable egress proxy

The harness process manager talked to each conversation subprocess over a
Unix-domain socket, which asyncio's Proactor loop cannot provide on
Windows. Introduce a transport abstraction so the same manager works on
both platforms.

- process_manager.py: add `_HarnessEndpoint` encapsulating UDS (POSIX) vs
  TCP-loopback (Windows) — spawn flags, readiness probe, httpx wiring, and
  cleanup. `_HarnessEndpoint.create` picks UDS on POSIX and a free 127.0.0.1
  port on Windows. `_wait_for_socket_bind` -> `_wait_for_bind` probes the
  endpoint generically; `_SubprocessEntry` now carries the endpoint.
- _runner.py (child): accept `--bind host:port` alongside `--socket`, and
  configure uvicorn with host/port or uds accordingly.
- egress/controller.py: fail loud when an agent requests L7 egress rules on
  Windows (the proxy is a Unix-socket MITM listener with no Windows analog).

POSIX is unchanged (still UDS; the public socket_path() returns the same
path the endpoint binds). Verified end-to-end on Windows: a real _runner
child binds TCP loopback, _wait_for_bind detects readiness, and an httpx
request over the TCP transport returns 200. process_manager unit tests
pass (3/3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(windows): windows_jobobject sandbox backend (process containment)

Add a Windows platform-default sandbox backend that contains the helper
process tree via a kernel Job Object, since Windows has no bwrap/seatbelt
equivalent.

- New SandboxBackend.post_spawn(policy, pid) hook (default no-op): acts on
  an already-running pid, the model Job Objects require (a process is
  assigned to a job only after it exists). Returns a ContainmentHandle the
  parent holds and closes on teardown.
- New windows_jobobject_sandbox.py: CreateJobObject +
  JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + AssignProcessToJobObject via
  ctypes/kernel32 (no new dependency). resolve() returns an active policy
  and warns once that this backend does NOT isolate filesystem/network
  (read/write/allow_network are advisory on Windows); activate() is a no-op.
  Degrades gracefully (logs, returns None) if the Win32 calls fail (e.g.
  a non-nestable parent job in CI).
- sandbox.py: register windows_jobobject and make it the Windows platform
  default; an explicit linux_bwrap/darwin_seatbelt still errors loudly on
  Windows. The backend module is imported only on Windows (it touches
  ctypes.windll) to keep the POSIX import graph untouched.
- os_env.py: after Popen, call post_spawn for active policies and store the
  handle; close it in _stop_locked so kill-on-close reaps any descendants
  that outlive proc.terminate().

Verified on Windows: default resolves to windows_jobobject; an explicit
linux_bwrap errors; and assigning a live process to the job then closing
the handle terminates it (kill-on-close). POSIX is unchanged (the launcher
backends keep the no-op post_spawn default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(windows): disable native terminals, cross-platform shell, packaging

Phase 5-7 of native Windows support.

- Native terminals: gate create_terminal_instance (the tmux/PTY chokepoint)
  and the `omnigent claude`/`codex`/`cursor` CLI commands behind a clear,
  actionable Windows error pointing to the SDK harnesses / web UI, instead
  of letting them crash on tmux/PTY.
- Shell: make os_env._shell_argv and the shell_path fallback Windows-aware
  (cmd.exe uses /c, PowerShell uses -NoProfile -Command; POSIX bash/sh
  unchanged), and route model_catalog's provider auth_command (a core auth
  path) through _platform.default_shell_argv instead of a hardcoded /bin/sh.
- Packaging: mark pexpect/pyte (POSIX PTY libs, never imported on the core
  path) as `platform_system != 'Windows'`, and document the native Windows
  install path (uv) plus its degraded-mode caveats in the README.

Verified on Windows: _shell_argv emits correct argv per shell; the native
terminal entrypoint and create_terminal_instance both reject with the
actionable message; all touched modules import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(windows): platform skip markers, primitives tests, Windows CI

- Add posix_only / windows_only pytest markers and auto-skip wrong-OS
  tests in tests/conftest.py (keys off os.name). Keeps the Linux suite
  unchanged and lets a Windows run skip POSIX-only tests cleanly.
- New tests/inner/test_proc_and_platform.py covering _platform flags +
  shell argv, _proc spawn/terminate/liveness (incl. the non-destructive
  probe regression), the UDS/TCP harness endpoint, and the
  windows_jobobject backend (default selection + kill-on-close +
  fail-loud bwrap), gated by platform markers.
- New non-blocking .github/workflows/windows.yml: installs via uv,
  asserts import omnigent and omnigent --help, runs the Windows-support
  unit tests as a hard gate, and a broader not-posix_only sweep as
  continue-on-error. Not wired into merge-ready, so it does not block.
- Regenerate uv.lock for the pexpect/pyte platform markers (normalizer
  check passes); needed so the existing locked uv sync CI stays green.

Verified on Windows: the hard CI test set passes (16 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: ruff-format windows_jobobject_sandbox.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): force web-ui asset MIME types so the SPA loads

Starlette StaticFiles derives Content-Type from mimetypes.guess_type,
which on Windows reads the registry, where .js is commonly mapped to
text/plain. Browsers then refuse to execute the bundled SPA ES modules
(disallowed MIME type), so omnigent server served a blank web UI on
Windows.

Register the web asset types .js/.mjs/.css/.json/.map/.wasm/.svg
explicitly at server import via mimetypes.add_type. Harmless and
deterministic cross-platform; removes the dependency on the host MIME
registry.

Verified on Windows: a real built assets/*.js now serves as
text/javascript through the actual _SPAStaticFiles path (was text/plain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): dereference Git-symlink example bundles on no-symlink checkout

The bundled polly/debby example agents are Git symlinks under
omnigent/resources/examples pointing at the top-level examples dir. On a
Windows checkout with core.symlinks false (Developer Mode off / Git not
elevated), Git materializes each symlink as a regular text file whose
content is the link target. The spec loader then read the stub instead
of the agent directory and failed to parse it as a YAML mapping.

Re-checking out with symlink support needs Developer Mode or admin, so
fix it at runtime: add _platform.resolve_repo_symlink, which on Windows
detects a small single-line regular file whose content resolves to an
existing path (the Git-symlink stub shape) and returns the real target;
a no-op for real dirs/files, multi-line or unresolvable content, and off
Windows. Apply it in cli._bundled_example_path and the server polly/debby
bundle sources.

Verified on Windows: the polly example now resolves to the real
examples/polly directory with config.yaml. Added windows_only unit tests
for the stub dereference and the leave-real-specs-untouched guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): pass Windows system env vars to sandboxed helpers

A sandboxed os_env helper is spawned with a deny-by-default env allowlist
(build_helper_env). The allowlist was POSIX-only (PATH/HOME/USER/...), so
on Windows the child got no SYSTEMROOT. Winsock loads its providers from
%SystemRoot%\system32\mswsock.dll, so the helper died at import asyncio
with WinError 10106 (WSAEPROVIDERFAILEDINIT). Because windows_jobobject
makes the sandbox active by default, this hit every agent that runs an
os_env helper on Windows.

Add the non-sensitive Windows system constants to the passthrough
allowlist: SYSTEMROOT (mandatory for Winsock), plus SYSTEMDRIVE, WINDIR,
COMSPEC, PATHEXT, NUMBER_OF_PROCESSORS, and PROCESSOR_*. Python uppercases
env keys on Windows, so the names match os.environ as stored; they are
absent on POSIX, so listing them is a no-op there (only present vars pass
through). The security posture is unchanged - these are system constants,
not credential-bearing.

Verified on Windows: build_helper_env for an active sandbox now contains
SYSTEMROOT, and a child spawned with that env imports asyncio cleanly
(was WinError 10106). Added a windows_only regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): pass USERPROFILE/home + appdata to spawned subprocesses

The host->runner spawn (and the os_env helper spawn) filter the
environment through a POSIX-centric allowlist. After SYSTEMROOT was added,
the runner got past import asyncio but then crashed at Path.home with
Could-not-determine-home-directory, because on Windows that needs
USERPROFILE (or HOMEDRIVE+HOMEPATH), the analog of POSIX HOME which is
already allowed.

Consolidate the Windows passthrough set into
_platform.WINDOWS_ENV_PASSTHROUGH (system constants plus
USERPROFILE/HOMEDRIVE/HOMEPATH plus APPDATA/LOCALAPPDATA) and reference it
from both os_env._DEFAULT_ENV_PASSTHROUGH and
host.connect._RUNNER_ENV_ALLOWLIST, so the two allowlists can no longer
diverge. All are non-sensitive path/identity constants, consistent with
HOME/PATH already being allowed; absent on POSIX so a no-op there.

Verified on Windows: the host runner env now carries SYSTEMROOT and
USERPROFILE, and a child spawned with it imports asyncio, resolves
Path.home, and imports ClaudeSDKExecutor. Extended the windows_only
regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): use the real temp dir for the harness instance dir

The harness process manager pinned its instance/socket parent to the
literal /tmp/omnigent, which on Windows resolves to \tmp\omnigent on the
current drive (the symptom: instance_dir=\tmp\omnigent\ap-... in the logs).

Keep /tmp/omnigent on POSIX (Unix socket paths have a tight length limit
and gettempdir can be a long /var/folders path on macOS), but on Windows
use tempfile.gettempdir()/omnigent. Windows uses TCP loopback for the
harness IPC, so there is no socket-path length concern there.

Verified: _default_tmp_parent() now resolves under
%LOCALAPPDATA%\Temp\omnigent on Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): stop parent-death watchdog from killing the runner instantly

The runner spawned by the host daemon exited cleanly (code 0) the moment
it finished startup. Cause: the parent-death watchdogs treat a getppid()
mismatch as the parent having died. On POSIX that is a reliable,
PID-reuse-proof signal (orphans reparent to init). On Windows there is no
reparenting AND os.getppid() is unreliable: the venv interpreter launcher
breaks the parent link, so a spawned child reports a getppid that does not
match its spawner (measured: child 15880 vs spawner 19852). So the
getppid check fired immediately, the killer requested graceful shutdown,
and the runner tore itself down right after HarnessProcessManager started.

On Windows, skip the getppid heuristic and rely solely on an explicit
liveness probe of the passed-in parent_pid (_proc.process_alive, psutil).
Fixes both watchdogs: runner._entry._parent_is_orphaned and
runtime.harnesses._runner parent watchdog.

Verified on Windows: _parent_is_orphaned(<live pid>) is False (runner
stays up) and True for a dead pid. Added a windows_only regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(windows): actionable client error when a native terminal is used

A claude/codex/cursor-native (tmux/PTY) agent run on Windows hits the
create_terminal_instance guard and surfaces a generic see-runner-logs
banner in the web UI. Make the client-facing message Windows-aware: tell
the user native terminals are not supported on Windows and to use an SDK
harness (claude-sdk/cursor/copilot/codex) or run on Linux/macOS. The full
cause is still logged for operators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): use SHA-256 (not SHA-1) for the user-id namespacing digest

CodeQL flagged stable_user_id() for hashing the login name with SHA-1.
The digest is only used to namespace per-user scratch directories (a
filesystem-safe token), not for security, but switch to SHA-256 with
usedforsecurity=False to document intent and clear the weak-algorithm
finding. Output is still a 12-char hex token; behavior is otherwise
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: address code-quality review comments

- _proc._ProcessLike and sandbox.ContainmentHandle: give the Protocol
  methods pass bodies instead of bare ellipsis (clears the
  statement-has-no-effect finding).
- windows_jobobject_sandbox: import ctypes.wintypes as a submodule import
  rather than mixing a plain ctypes import with a from-ctypes-import
  (clears the dual-import-style finding).
- windows_jobobject_sandbox: replace the module-level warned flag plus
  global statement with a functools.cache one-time warner (clears the
  unused-global-variable finding; behavior unchanged, the caveat is still
  logged exactly once per process).

ruff and mypy clean; tests/inner/test_proc_and_platform.py 18 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): restore _pid_alive POSIX semantics (zombie counts as present)

Phase 2 of this PR switched process_manager._pid_alive from os.kill(pid, 0)
to the psutil _proc.process_alive probe. Those differ for a killed-but-not-
yet-reaped process: os.kill(pid, 0) reports the zombie as present, psutil
reports it as dead. That broke test_get_client_respawns_after_crash (and
risked ~17 other call sites): the test SIGKILLs a harness and waits on
not _pid_alive(pid) as a proxy for fully-reaped, which is the moment the
asyncio child watcher sets the subprocess returncode and get_client
respawns. With zombie-as-dead the wait returned at the zombie stage, before
the reap, so get_client saw returncode None, did not respawn, and the first
request to the dead client raised httpx.ReadError every time.

_pid_alive answers is-this-PID-present-in-the-table (the os.kill idiom);
_proc.process_alive answers is-this-a-live-non-zombie-process (liveness,
used by the parent-death watchdogs). They are different predicates. Restore
os.kill(pid, 0) on POSIX for _pid_alive (exact pre-PR behavior; its only
production caller, the orphan sweep, checks non-child PIDs where zombies
never occur) and keep psutil only on Windows, where os.kill(pid, 0) would
map to TerminateProcess.

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-06-24 01:54:48 -07:00
Daniel Lok bba8912a69 feat(chat): pin pending elicitation cards above the composer (#1105)
* feat(chat): pin pending elicitation cards above the composer

Elicitation cards rendered inline in the scrolling transcript, so when
the agent streamed text after one, stick-to-bottom scrolled the card up
off the top of the viewport and out of reach.

Lift every PENDING elicitation card out of the transcript into a sticky
tray pinned directly above the composer (outside the scroll container),
stacking all pending cards with the newest nearest the composer. Once
answered, a card drops from the tray and flows back inline at its natural
spot showing the responded state.

- ApprovalCard: extract a shared `ElicitationCard` wrapper so the
  RenderItem -> ApprovalCard prop mapping lives in one place, reused by
  the inline BlockRenderer path and the new tray.
- ChatPage: `collectPendingElicitations` gathers pending cards in
  document order; `stripPinnedElicitations` removes them from the
  transcript (cloning only affected bubbles so the BubbleView memo holds;
  emptied standalone bubbles collapse to null while their gating user
  message stays put). The tray mirrors the composer column width and caps
  its height with internal scroll so a tall stack can't crowd out the
  transcript.

Co-authored-by: Isaac

* fix(chat): render plan-review card body in normal text color

The ExitPlanMode plan-review card renders its plan markdown inside
ApprovalCard's AlertDescription, which applies text-muted-foreground to
all children. The plan body (via MessageResponse) inherited that muted
color, so the whole plan read washed-out/secondary.

Override the plan body to text-foreground so it renders in normal text
color like a regular assistant message, matching the Codex command
card's pattern (content in foreground, short lead-in caption muted for
hierarchy).

Co-authored-by: Isaac

* refactor(chat): float pending elicitations to the bottom of the chat

The pinned tray above the composer read as a detached floating panel.
Instead, render pending elicitation cards as the last items in the chat
scroll flow, wrapped in an assistant Message so each looks like a normal
inline card. Stick-to-bottom keeps an outstanding question in view —
trailing text the agent streams renders above the card rather than
pushing it off the top — without the welded-to-composer look.

- Remove the above-composer tray (outside the scroll container).
- Render `pendingElicitations` at the end of ConversationContent.
- Rename `stripPinnedElicitations` -> `stripPendingElicitations` and
  `pinnedElicitations` -> `pendingElicitations` (no longer pinned), and
  refresh the comments/tests to match.

Co-authored-by: Isaac

* fix(chat): render floated elicitations above the Working indicator

Move the floated pending elicitation cards to render right after the
transcript bubbles, above the Working… shimmer (and the terminal-first
spin-up cue), instead of after them. The card now sits closest to the
prompt it gates while the shimmer stays the last thing in the flow.

Co-authored-by: Isaac

* test(chat): add e2e coverage for floated elicitation + fix formatting

CI was red on three checks, all from the float-to-bottom change:

- npm test / Pre-commit (Prettier): reformat the `textItem` helper in
  ChatPage.test.ts to satisfy `prettier --check`.
- E2E UI Required: the judge flagged that the change moves pending
  elicitation cards in the chat UI with no Playwright coverage. Add
  `test_elicitation_floats_to_bottom.py`, modeled on the AskUserQuestion
  synthetic-hook test: it asserts the pending card renders INSIDE the
  floated `bottom-elicitation` wrapper, then returns inline (wrapper gone,
  state `responded`) once answered.

Verified locally: the new test plus the full PR-eligible approvals/ suite
(7 tests) pass against a freshly built SPA.

Co-authored-by: Isaac
2026-06-24 16:47:27 +08:00
Hubert 60e083911c ci(ui-snapshot): gate the render on a changed-paths detect job (#1107)
Skip the expensive visual-snapshot render on PRs that touch none of its render
inputs, so unrelated PRs neither burn CI nor flake against the gate -- while
keeping it safe to register as a required check.

- Add a cheap `detect` job (no container/build) that lists the PR's changed
  files via the API and sets ui=true/false; the render job runs only `if`
  ui=true. A job skipped by `if` reports SUCCESS, so a non-UI PR satisfies the
  check instead of sitting "pending" (which an `on: paths:` filter would cause,
  blocking required-check merges). Fails open: render if the list can't be read
  or on workflow_dispatch.
- Watch exactly the render inputs: ap-web, the visual tests + shared fixtures,
  the npm pin, this workflow (which pins the image digest), and the lockfile so
  a playwright/plugin bump re-runs the gate.
- README: note it's now safe to mark required, and that non-UI PRs skip-pass.

Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-06-24 10:17:47 +02:00
Sabhya Chhabria a11e07636c feat(repl): make REPL commands more discoverable (#1106)
* feat(repl): make REPL commands more discoverable

Reword the welcome line from "Type a message to chat · /help help" to
"Type a message, or /help for commands", advertise /quit (in both the
welcome panel and the bottom toolbar via WELCOME_HINTS), and replace the
flat alphabetical /help wall with grouped, column-aligned sections
(Chat / Context / Display / Diagnostics / Help). Newly registered
commands still render under "Other" so none are silently hidden.

Addresses the REPL-discoverability items from the CLI-setup swarm
findings (OMNI-675).

* style(repl): satisfy ruff format on /help line

Join the split f-string back onto one line per ruff format (it fits
within the line length).

* fix(repl): keep bottom toolbar within e2e PTY width

Adding /quit to WELCOME_HINTS widened the bottom toolbar past the
e2e harness's 120-col PTY, wrapping it mid-"state: sleeping" — the
sync marker tests/e2e/.../test_run_omnigent_coding_supervisor.py waits
on — which timed out. Revert the toolbar hint list to its prior width;
/quit stays discoverable via the regrouped /help output and the
reworded welcome line.
2026-06-24 00:54:36 -07:00
Hubert dc8690d899 Fix chat UI-snapshot wrap-boundary flake (#1096)
* test(e2e-ui): shorten chat snapshot sample to fix wrap-boundary flake

The assistant code sample's longest line landed exactly on the code box's
overflow boundary, so subpixel rendering differences flipped the SPA between
"fits" (clipped, no wrap toggle) and "overflows" (wraps + shows a wrap toggle).
The extra wrapped row shifted the whole transcript below it, producing a large
diff with no UI change behind it. Shorten every line well clear of the box width
so nothing reflows at the edge. Baseline regenerated in the pinned image.

* test(e2e-ui): regenerate visual baselines

* test(e2e-ui): stop visual regen from writing a duplicate baseline

playwright-visual-snapshot already rewrites a drifting baseline IN PLACE under
snapshots/ when GITHUB_ACTIONS is set (and creates a missing one there), while it
writes actual/expected/diff into snapshot_failures/<test>[browser][platform]/ --
a DIFFERENT subdir scheme than the baseline's snapshots/<test>/. The old "adopt"
steps reconstructed a snapshots/ path from that failures subdir, so every regen
wrote a parallel snapshots/<test>[chromium][linux]/ baseline that nothing reads.

- ui-snapshot-update.yml: drop the redundant adopt step; the in-CI in-place
  update already leaves snapshots/ holding exactly the changed PNGs.
- regen_baseline_docker.sh: set GITHUB_ACTIONS=true so the local Docker render
  updates baselines in place like the gate does; drop the adopt path-munging.
- update_baseline_from_pr.sh: restore the artifact's snapshots/ tree verbatim
  instead of reconstructing paths from snapshot_failures/.
- Delete the stray duplicate chat baseline dir created by the old logic.
- README: document the in-place update + the simplified fork path.

---------

Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-24 09:45:40 +02:00
Tomu Hirata 112828fa6e refactor(compaction): move compaction ownership from runner to harnesses (#1082)
* refactor(compaction): move compaction ownership from runner to harnesses

All harnesses are stateful — they maintain their own context internally.
The runner's proactive compaction only compacted its in-memory mirror,
not the harness's real context, making it ineffective.

This change:
- Removes proactive compaction (_proactive_compact_if_needed) from the runner
- Removes reactive compaction (compact-and-retry on ContextWindowOverflow)
- Removes _compaction_contexts tracking dict and provider_tokens capture
- Adds CompactionComplete executor event for harnesses to emit when they
  compact their own context
- Adds handling in executor adapter to emit CompactionInProgressEvent +
  CompactionCompletedEvent (reusing existing SSE schemas)
- Adds summary/summary_model fields to CompactionCompletedEvent so the
  runner can persist compaction items for session resume
- Runner persists harness compaction to server and updates its history
  mirror so crashed sessions resume with pre-compacted history

Co-authored-by: Isaac

* feat(openai-agents-sdk): enable SDK-native compaction via OpenAIResponsesCompactionSession

Wraps the SQLiteSession with OpenAIResponsesCompactionSession so the
SDK automatically compacts conversation history using the Responses API
(`responses.compact`). When compaction occurs, emits CompactionComplete
so the runner persists it for session resume.

Co-authored-by: Isaac

* feat(openai-agents-sdk): enable SDK-native compaction via OpenAIResponsesCompactionSession

Wraps the SQLiteSession with OpenAIResponsesCompactionSession so the
SDK automatically compacts conversation history using the Responses API
(responses.compact). When compaction occurs (compaction_item in
result.new_items), emits CompactionComplete so the runner persists it
for session resume.

Only enabled for direct OpenAI endpoints — Databricks-hosted endpoints
don't support the responses.compact API.

Co-authored-by: Isaac

* feat(claude-sdk): detect compaction via PreCompact hook and emit CompactionComplete

Enable include_hook_events on the SDK options so the executor observes
hook lifecycle events in the message stream. When a PreCompact hook
event is seen, flag the turn and emit CompactionComplete after it
finishes so the runner persists the compaction boundary for session
resume.

Co-authored-by: Isaac

* test: add compaction event tests for openai-agents-sdk and claude-sdk executors

- openai-agents-sdk: compaction_item in new_items emits CompactionComplete,
  no compaction_item yields no event, Databricks clients skip compaction session
- claude-sdk: PreCompact hook event emits CompactionComplete,
  no hook yields no event

Co-authored-by: Isaac

* fix(e2e-ui): store runner proc for dead-process detection, fix codex model

Three fixes verified locally (all 7 previously-failing tests pass):

1. Store runner_proc in _server_state so _ensure_runner_online can check
   the actual runner process (not the server PID) when deciding whether
   to respawn. Fixes the post-stale-stream race where _online() returned
   True for a dead runner.

2. Codex CLI sends model=gpt-5.5 (its built-in default), not the
   provider config's models.default=gpt-4o. Changed _CODEX_MOCK_MODEL
   to gpt-5.5 so the per-turn fallback routes correctly.

3. Set an initial fallback before the CLI boots so startup LLM calls
   get a benign response.

Co-authored-by: Isaac

* Revert "fix(e2e-ui): store runner proc for dead-process detection, fix codex model"

This reverts commit f83c4d6ec2.

* feat(compaction): include compacted messages in CompactionComplete for DB persistence

Add compacted_messages field to CompactionComplete so the runner stores
the actual compacted session state (including opaque compaction tokens
for OpenAI) rather than a placeholder summary. On session resume, the
harness receives the real compacted messages instead of a synthetic pair.

- openai-agents-sdk: reads session items after compaction and includes
  them in the event
- claude-sdk: passes None (compaction is internal to the CLI)
- Runner handler: uses compacted_messages when available, falls back to
  synthetic summary pair

Co-authored-by: Isaac

* fix(e2e-ui): write session-scoped mock provider config in live_server

Forked sessions that boot a native CLI (sdk-to-claude-code,
sdk-to-codex) read ~/.omnigent/config.yaml at terminal-creation time,
but _temp_omnigent_mock_config is only called by the explicit
native_*_mock_session fixtures — not by fork tests. In CI (where the
gateway config step was removed), the forked native CLI had no provider
config and failed silently.

Fix: write a combined anthropic+openai mock provider config once in
live_server so ANY native boot sees it. Also add session-level
fallbacks for native CLI models (gpt-5.5, claude-3-5-sonnet) so
forks get benign responses without per-test config.

Co-authored-by: Isaac

* Revert "fix(e2e-ui): write session-scoped mock provider config in live_server"

This reverts commit 0279c99e38.

* fix(claude-sdk): don't emit CompactionComplete — SDK owns its own session persistence

The claude-sdk manages its own context and session store internally.
Emitting CompactionComplete with a placeholder summary would persist
a useless compaction item in the server. Keep the PreCompact hook
detection for logging only.

Co-authored-by: Isaac

* fix(e2e-ui): add fallbacks for all known native CLI model names + default

Codex CLI 0.139.0 uses gpt-4o (provider config default) while 0.140.0
uses gpt-5.5 (its built-in default). Add fallbacks for both plus a
catch-all "default" key so ANY model gets a mock response regardless
of CLI version.

Co-authored-by: Isaac

* Revert "fix(e2e-ui): add fallbacks for all known native CLI model names + default"

This reverts commit ac830a2c63.

* fix(ci): fix linter reverts, update openapi.json, remove obsolete reactive compaction tests

- Re-apply CompactionComplete event, executor adapter handler, and
  openai-agents-sdk compaction session wrapping that the linter reverted
- Regenerate openapi.json for new CompactionCompletedEvent fields
- Remove test_reactive_compaction_retries_after_overflow and
  test_compaction_retry_keeps_advisor_application (test removed behavior)
- Fix ruff formatting in test files

Co-authored-by: Isaac

* chore: regenerate openapi.json for CompactionCompletedEvent schema changes

Co-authored-by: Isaac

* fix(ci): resolve ruff errors, restore deleted test helpers, gate compaction on OpenAI endpoint

- Run ruff format/check --fix on all branch-changed files
- Restore _build_interrupt_app, _build_fwd_blocking_app, _ForwarderRun,
  and _drain_forwarder_runs helpers that were accidentally deleted from
  test_app_sessions_native.py
- Gate OpenAIResponsesCompactionSession wrapping on api.openai.com in
  the client base_url so mock/local servers don't 404 on responses.compact
- Skip pre-existing test_interrupted_session_rewinds_sdk_session_before_replay

Co-authored-by: Isaac

* fix(ci): delete pre-existing broken test instead of skipping

The no-skipped-tests pre-commit hook forbids unconditional
@pytest.mark.skip. Delete test_interrupted_session_rewinds instead.

Co-authored-by: Isaac

* fix(review): use parsed hostname check and log compaction setup failures

Address review comments:
- Replace substring check ("api.openai.com" in url) with parsed
  hostname equality (urlparse().hostname == "api.openai.com") to
  satisfy CodeQL's incomplete URL sanitization warning
- Log compaction session setup failures instead of silently passing

Co-authored-by: Isaac

* test(e2e-ui): mark native render-parity + native fork legs as nightly

Native CLI tests (claude-native, codex-native) require version-specific
mock routing that differs between CI and local CLI versions. Mark them
@nightly so the PR gate passes while we iterate on the native mock
separately. The sdk-to-sdk and sdk-to-pi fork legs remain in the gate.

Co-authored-by: Isaac

* Revert "test(e2e-ui): mark native render-parity + native fork legs as nightly"

This reverts commit 6e3b20fd04.

* fix(review): remove hostname gate for compaction session wrapping

Always wrap with OpenAIResponsesCompactionSession regardless of
endpoint. The 404s in integration tests were pre-existing and unrelated
to compaction. The SDK's default trigger (10+ candidates) prevents
compaction from firing in short tests.

Co-authored-by: Isaac

* fix: persist compacted_messages in server compaction item

compacted_messages was only stored in the runner's in-memory history
but not persisted to the server. On runner restart, the session would
resume with only the summary text, losing the actual compacted state
(including OpenAI's opaque compaction tokens).

Co-authored-by: Isaac

* fix: use compacted_messages on session resume instead of synthetic summary

_convert_raw_items_to_input now checks for compacted_messages in the
compaction item and uses them directly when available. This preserves
the full compacted state (including OpenAI's opaque compaction tokens)
across runner restarts, instead of falling back to the text summary.

Co-authored-by: Isaac

* feat(claude-sdk): re-add CompactionComplete with session messages for sandbox resume

Read post-compaction session messages via get_session_messages() so the
runner can persist them for session resume in ephemeral environments
where the CLI's own transcript files are lost (e.g. sandbox execution).

Co-authored-by: Isaac

* fix(ci): gate compaction session on non-Databricks HTTP endpoints

Databricks AI Gateway doesn't proxy responses.compact, and bare
object() clients in unit tests lack base_url. Gate on
`not self._databricks and base_url.startswith("http")`.

Co-authored-by: Isaac

* fix: remove Databricks gate, fix test to traverse compaction session wrapper

Enable compaction session for all HTTP endpoints including Databricks.
Fix test_empty_turn_retry_rewinds_sdk_session to unwrap through
OpenAIResponsesCompactionSession.underlying_session before accessing
_SanitizingSession._underlying.

Co-authored-by: Isaac

* fix: add compacted_messages to CompactionData so it actually persists

Pydantic's BaseModel silently drops unknown fields — CompactionData
didn't have compacted_messages, so the server was stripping it on
parse and never storing it to the DB. Add as Optional field with
None default for backward compatibility with existing items.

Co-authored-by: Isaac

* fix(ci): make compaction non-fatal via _SafeCompactionSession subclass

The SDK's Runner calls run_compaction() after each turn. When the
server doesn't support responses.compact (mock servers, some proxies),
the 404 kills the turn. Subclass OpenAIResponsesCompactionSession to
catch and log compaction failures instead of propagating them.

Co-authored-by: Isaac

* test: remove e2e proactive compaction test (tests removed behavior)

test_compaction_fires_and_agent_retains_context tested the runner's
proactive compaction (_proactive_compact_if_needed) which was removed.
Compaction is now harness-owned — the OpenAI SDK's
OpenAIResponsesCompactionSession handles it internally.

Co-authored-by: Isaac

* fix: make CompactionData.model optional and remove dead compaction helpers

CompactionData.model is now `str | None = None` so harnesses like
claude-sdk that omit summary_model no longer cause a silent 422 on
the server POST.

Also removes the unused `_should_skip_futile_recompaction` and
`_resolve_compaction_context` helpers plus their test files — both
became dead code after harness-owned compaction replaced the
runner-side compaction path.

Co-authored-by: Isaac
2026-06-24 07:11:56 +00:00
Daniel Lok c4365db0ea fix(elicitation): match terminal-resolved prompts by exact tool_input only (#1094)
* fix(elicitation): match terminal-resolved prompts by exact tool_input only

The claude-native terminal-resolved fast path resolves a parked web
permission prompt when the gated tool's result is mirrored back from the
transcript. Among same-tool-name prompts it preferred an exact
(tool_name, tool_input) match, but fell back to resolving the sole
same-named candidate when no input matched. That fallback cross-dismissed
siblings: approving Bash{ls} in the web UI un-parks it, then mirroring
ls's own output finds only the still-pending Bash{pwd} sibling and wrongly
clears it as "resolved elsewhere" (fail-ask). Any turn with multiple
same-named prompts hit this; auto-allowed same-name tools leaked the same
way.

Drop the `len(candidates) == 1` fallback so correlation is exact-only: a
mirrored result resolves a parked prompt only on an exact
(tool_name, tool_input) match; a non-matching or ambiguous result resolves
nothing and leaves each prompt to its own result / web verdict / timeout.
Claude Code's PermissionRequest payload carries no tool_use_id (the id is
minted only when the tool call is emitted, after the permission check), so
(tool_name, tool_input) is the only correlation signal -- and both sides
are unmodified JSON round-trips of the same input, so exact equality holds
whenever they describe the same call. The skipped no-match branch logs at
debug, not warning: it is hit routinely and benignly once a sibling is
web-approved and un-parked.

Add unit coverage for `_signal_terminal_resolved_harness_elicitation` and
the end-to-end mirrored call_id -> identity -> resolve path
(`_drive_terminal_resolved_elicitation`), including the reported
cross-dismissal scenario. Correct a stale test note that described a UI
"first pending" auto-clear heuristic that no longer exists (the web UI
clears strictly by elicitation_id on response.elicitation_resolved).

Co-authored-by: Isaac

* fix(elicitation): canonicalize None/{} tool_input so no-input prompts resolve

Polly review (blocking): the park side records an absent tool_input as
`None` (a hook payload with no `tool_input`) while the mirror side
normalizes parsed transcript arguments to `{}`. `None == {}` is `False`,
so a no-input prompt could never match its own mirrored result -- and with
the count-based fallback now removed, nothing would clear it; it would
orphan until the 24h hook timeout, the very failure this feature exists to
prevent.

Canonicalize both sides to `{}` via `_canonical_tool_input` before
comparing (both spellings mean "no input"). Add two regression tests: a
no-input prompt resolves on an empty mirrored output, and the
canonicalization does not over-match a same-named result that carried real
input.

Co-authored-by: Isaac
2026-06-24 15:06:46 +08:00
Serena Ruan becae2f832 feat(qwen,goose): delegate file I/O through Omnigent via ACP fs/* (#1100)
When an os_env is configured, the ACP harnesses (qwen, goose) now
advertise clientCapabilities.fs in initialize, so the agent routes its
file reads/writes back to us as fs/read_text_file / fs/write_text_file
requests instead of touching disk directly (the agent's
AcpFileSystemService swaps in only when the capability is set).

New handlers execute the I/O through the Omnigent OSEnvironment, so the
spec's sandbox read/write roots are enforced at the Python layer and the
bytes flow through Omnigent. Delegation is disabled (agent uses its own
tools) when there's no os_env or it's a fork env — a forked tree's path
would diverge from the subprocess cwd. Binary/non-UTF-8 reads are
refused; missing-file reads map to the ACP ENOENT code (-32002). The
OSEnvironment is created lazily on first delegated op and torn down in
close().

This is the byte-level execution hook; emitting the I/O into the event
stream (recording) and TOOL_RESULT-phase content policy build on top and
remain follow-ups (see docs/QWEN_FOLLOWUPS.md).

Tests: 10 new qwen + 8 new goose covering capability advertisement,
window mapping, ENOENT/binary/error mapping, write, and cleanup.

Co-authored-by: Isaac
2026-06-24 15:06:27 +08:00
Pat Sukprasert 5eb3c24df7 ci: run e2e / integration / e2e-ui on fork PRs directly; retire the fork-e2e mirror (#1004)
* ci(e2e): run e2e on pull_request for fork PRs, drop the fork-e2e mirror

The e2e suite is mock-LLM only and uses no secrets (#802 removed the
credential setup), so fork PRs can run it directly on `pull_request`
like CI does -- no need to route forks through the maintainer-approved
fork-e2e/** mirror push.

- e2e-shard-matrix.sh: add an `ALLOW_FORK_PR` opt-in. The shared script
  still skips fork PRs by default (e2e-ui needs the gateway secret), but
  runs them when the caller sets ALLOW_FORK_PR=true. Draft-skip unchanged.
- e2e.yml: set ALLOW_FORK_PR=true, drop the `push: fork-e2e/**` trigger,
  and restrict merge-ready-rerun to same-repo PRs (fork PRs have a
  read-only token and re-evaluate via merge-ready's workflow_run).
- compute-gate.sh / merge-ready.yml: the fork maintainer-approval gate
  now exists for the e2e-ui suite (still secret-bearing), not e2e;
  reword accordingly. Gate logic unchanged.
- fork-e2e-mirror.yml: header updated -- the mirror now serves e2e-ui
  (and integration), not e2e.

required.sh is left as-is: e2e shard names stay in ALLOW_SKIP for the
paths-ignore / draft cases where the checks are legitimately absent.

Co-authored-by: Isaac

* ci(e2e-ui): split mock-LLM suite from native-gateway suite

The e2e-ui suite mixes ~110 mock-LLM tests (openai-agents hello_world
against the in-process mock) with 5 native render-parity / approval
tests that drive a real Claude Code / Codex / Cursor CLI against the
live Databricks gateway. Only the latter need secrets, but the whole
suite was gated behind the fork-approval mirror because of them.

Split into two jobs in one workflow:

- `E2E UI Tests` (mock): runs `-m "not native_gateway"`, no secrets, no
  CLI installs / gateway config. ALLOW_FORK_PR=true, so it runs on fork
  PRs directly like CI/e2e. 3 shards (unchanged names).
- `E2E UI Native` (gateway): runs `-m native_gateway` with the secrets +
  Claude/Codex CLI installs + gateway provider config. Fork PRs skip it
  (empty matrix) and run it via the fork-e2e/** mirror after approval.
  2 shards.

A new `native_gateway` pytest marker (registered in pyproject.toml) tags
the 5 gateway tests. Shared setup and failure-artifact steps move into
the e2e-ui-setup / e2e-ui-artifacts composite actions so the two jobs
never drift (same pattern as e2e.yml's e2e-run composite).

required.sh adds the two `E2E UI Native (shard N/2)` checks to REQUIRED
and ALLOW_SKIP and maps them to the "E2E UI Tests" workflow. NOTE: this
file is normally generated -- the generator's source of truth must learn
about the `E2E UI Native` leg too. Branch protection is unaffected: the
only required check is "Merge Ready", which reads this list.

Verified: marker partitions the suite 5 native / 110 mock; native split
distributes 3+2 across its 2 shards; compute-gate tests pass.

Co-authored-by: Isaac

* ci: run integration on fork PRs too; invert fork-skip to REQUIRES_SECRETS

Integration is mock-LLM only and uses no secrets (its matrix even runs
just the openai-agents mock leg), so like e2e it can run on fork PRs
directly instead of via the fork-e2e/** mirror. Drop its `push:
fork-e2e/**` trigger and restrict merge-ready-rerun to same-repo PRs
(fork PRs re-evaluate via merge-ready's workflow_run).

With e2e, e2e-ui (mock), and integration all running forks, the shared
matrix scripts' fork-skip default was backwards -- three of four callers
opted in. Invert it: fork PRs now run by DEFAULT (like CI), and only a
secret-bearing leg opts OUT via REQUIRES_SECRETS=true. The single
remaining opt-out is the e2e-ui native render-parity job, which needs the
gateway secret. This makes the default the safe/common case and leaves
exactly one self-documenting flag at the one call site that needs it.

No required.sh change: integration check names are unchanged.

Co-authored-by: Isaac

* ci: trim now-redundant comments around the fork-skip logic

The REQUIRES_SECRETS flag name and the native_gateway marker are
self-documenting, so drop the inline comments that just restated them and
compress the matrix-script headers. Keep only the non-obvious rationale
(empty-matrix indirection, the mirror, read-only fork tokens). No
behavior change.

Co-authored-by: Isaac

* ci(e2e-ui): run native tests nightly-only; collapse back to one job

The native render-parity / approval tests (the `native_gateway` marker)
are the only e2e-ui tests that need the real gateway. Run them ONLY on
the nightly schedule / dispatch (on a trusted ref where secrets exist),
never on PRs. PRs then run mock-only and need no secrets and no fork
mirror.

- e2e-ui.yml: back to a single `E2E UI Tests` job. On PRs it runs
  `-m "not native_gateway and not visual and not nightly"`; the nightly
  run adds native (`-m "not visual"`). The Claude/Codex CLI install +
  gateway-config + LLM_API_KEY steps are gated to the nightly path.
- Drop the second job + the e2e-ui-setup / e2e-ui-artifacts composites
  (they only existed to keep two jobs in sync; with one job they're just
  indirection, so inline them back).
- e2e-shard-matrix.sh / integration-matrix.sh: REQUIRES_SECRETS has no
  caller now -> remove it; the only skip is draft PRs. Drop the unused
  IS_FORK env from all setup steps.
- required.sh: drop the `E2E UI Native` checks (nightly-only, not PR
  checks); back to the 3 mock e2e-ui shards.

Co-authored-by: Isaac

* ci: retire the fork-e2e mirror and the e2e fork-approval gate

With every secret-bearing CI suite now either running on forks directly
(mock) or moved to nightly-only (native e2e-ui), no CI needs secrets on a
fork PR -- so the fork-e2e mirror and the e2e-specific approval gate have
no remaining purpose.

Removed:
- fork-e2e-mirror.yml + scripts/fork-e2e/should-mirror.sh (+ its test):
  the mirror that pushed approved fork heads to fork-e2e/** so secret e2e
  could run there.
- merge-ready.yml: the `fork_needs_e2e_approval` block, the `check_suite`
  trigger + its ctx/`if` handling, and the workflow_run push-fork-e2e
  branch. Fork PRs now re-evaluate via the normal workflow_run on CI
  completion (ctx resolves the PR from the head SHA). The `Load
  maintainers` step is gone (only the dropped approval block used it).
- compute-gate.sh: the fork-approval blocker (+ its tests).
- maintainer-approval-rerun-run.yml: the fork-e2e-mirror dispatch step
  (the merge-approval re-run it also does is untouched).
- Stale fork-e2e comments in should-scan.sh / rerun-security-gate-run.yml
  / exfil-scan.py.

Fork PRs still require a maintainer's approving review to MERGE -- that is
the separate `Maintainer Approval` check, unchanged. Only the e2e-for-
secrets coupling is gone.

NOTE: needs a live CI run to confirm the merge-ready re-evaluation path;
the gate logic can't be fully exercised locally. Repo settings cleanup
(the FORK_E2E_APP_ID var / FORK_E2E_APP_PRIVATE_KEY secret) is a manual
follow-up.

Co-authored-by: Isaac

* ci: drop dangling fork-e2e mirror references in approval-dispatch comments

Follow-on to retiring the mirror: two comments still referenced the
deleted fork-e2e gate/mirror. No behavior change.

Co-authored-by: Isaac
2026-06-24 06:50:32 +00:00
Serena Ruan a483324c26 feat(qwen,goose): replay history on a fresh ACP session (#1095)
`/model` already switches models for the ACP harnesses (qwen, goose): the
model is baked into the subprocess env at spawn, so HarnessProcessManager
respawns the harness on a change. But respawning kills the `qwen --acp` /
`goose acp` process, and these executors only send the latest user turn —
relying on the persistent in-process session for context. So a model
switch (or a `Session not found` reset) silently dropped the conversation.

Fix: on a fresh session (first turn of a new/respawned process), fold the
prior transcript into the prompt as a labeled `Conversation so far:` block
(`_history_prefix`), mirroring `ClaudeSDKExecutor._build_prompt`. The
fresh-session latch now flips even when the system prompt is empty, so a
continuing session never re-replays or re-folds. Applied to both ACP
harnesses since they share the pattern.

Docs: mark in-session model selection done, document history replay.

Co-authored-by: Isaac
2026-06-24 14:00:20 +08:00
Sabhya Chhabria 71ebe5f80c fix(ap-web): keep the Files rail "Working folder" header a button (#1092)
* fix(ap-web): keep the Files rail "Working folder" header a button

The desktop Workspace rail renders <FilesPanel frameless />, and
`frameless` was folded into the `fullScreen` flag. That flag does two
unrelated jobs: (1) fill the parent height / drop the card chrome, and
(2) swap the collapsible "Working folder" *button* header for a static
<span> label (the drawer's header, which carries its own X close
button). Coupling them meant the inline rail lost the button header
entirely, rendering "Working folder" as a non-interactive label — so the
e2e UI suite, which targets the rail header by `role=button`
name="Working folder", timed out waiting for an element that no longer
existed (consistently red across PRs).

Split the flag into `isDrawer` (static label + close button, drawer only)
and `fillHeight` (rail + drawer). The inline rail and the standalone card
now both keep the collapsible button header (accessible name +
aria-expanded); only the drawer uses the static label. Drawer and card
behavior are unchanged.

Adds vitest coverage pinning the header role in card, frameless, and
drawer modes.

* test(e2e_ui): cover the Files rail "Working folder" header toggle

Adds a Playwright test that drives the inline desktop Workspace rail and
asserts the working-folder header is a real button: it carries
aria-expanded, collapsing it hides the file-scope content and flips the
attribute to "false", and re-clicking restores it. This is the
browser-level guard for the frameless-vs-drawer header split (the unit
tests pin the render contract; this pins the live interaction the CI
e2e_ui gate requires for ap-web behavior changes). LLM-free.
2026-06-23 22:59:27 -07:00
Tomu Hirata 5e6cce5b7c test(e2e-ui): mark native render-parity + native fork legs as nightly (#1090)
Replace skipif(LLM_API_KEY) with @nightly on native CLI tests that
need version-specific mock routing not yet reliable in CI. The PR gate
excludes -m nightly so these don't block merges.

Co-authored-by: Isaac
2026-06-24 14:35:54 +09:00
Serena Ruan 7d1eac3084 feat(qwen): size the context meter from a curated Qwen context-window table (#1089)
The UI context meter renders used/total for qwen now that token usage is
reported (#1084), but the denominator was wrong: qwen models are absent
from litellm's registry and the MLflow catalog, so get_model_context_window
fell back to the conservative 128K default — ~8x too small for the
coding-plan default qwen3-coder-plus (1M tokens), mis-sizing the meter.

Add `_QWEN_CONTEXT_WINDOWS` (published Alibaba Cloud Model Studio /
DashScope maxima) and consult it as a fallback in get_model_context_window,
after litellm/MLflow and before the 128K default. `_qwen_context_window`
normalizes the id (strips provider prefix + `:tag` suffix) so `qwen/...`,
`:free`, and bare ids all match. A spec's `executor.context_window` still
overrides, and unrecognized qwen models keep the 128K fallback (no
regression).

Qwen reports no context window over ACP (only token usage), and the
default DashScope `/v1/models` route exposes no `context_length`, so a
static table — the same approach qwen's own `tokenLimit()` uses — is the
pragmatic source.

Co-authored-by: Isaac
2026-06-24 13:29:07 +08:00
Sabhya Chhabria c3554abee7 feat(skills): add cli-setup-verify skill for isolated CLI setup/UX verification (#1085)
* feat(skills): add cli-setup-verify skill for isolated CLI setup/UX verification

Adds a skill that lets an agent drive the real `omnigent` CLI through a PTY
inside a throwaway OMNIGENT_CONFIG_HOME / OMNIGENT_DATA_DIR sandbox to verify
the setup/onboarding flow, terminal UI/UX, and critical user journeys — without
a browser, without real credentials, and without touching the developer's real
~/.omnigent.

The bundled `verify_cli.py` engine:
- isolates every write via the CLI's own knobs and fingerprints the real
  ~/.omnigent (stat-only) before/after, reporting `real_config_untouched`;
- simulates a fresh machine (`--isolate-home`, `--strip-path`) and captures
  ANSI-stripped frames at 80x24 for UX inspection;
- ships 5 scenarios (check-isolation, cold-start, setup-snapshot, help-snapshot,
  repl-commands) whose checks/notes flip between a before→after baseline diff,
  so a fix is provable rather than asserted; unreachable surfaces report
  `skipped`, never a false pass.

Builds on the existing pexpect/snapshot e2e infrastructure
(tests/e2e/omnigent/_pexpect_harness.py, _snapshot.py).

Co-authored-by: Isaac

* fix(skills): make HOME isolation the default + detect diagnostics-log writes

Addresses the Polly review's blocking issue: the "never touches the real
~/.omnigent" guarantee was false without --isolate-home, because the CLI's
diagnostics logger writes cli-*.log under state_dir() = Path.home()/.omnigent,
which ignores OMNIGENT_CONFIG_HOME / OMNIGENT_DATA_DIR.

- Redirect HOME into the sandbox BY DEFAULT (the only knob that contains
  diagnostics); replace opt-in --isolate-home with opt-out --inherit-home for
  the credentialed-REPL case, documented as the less-safe mode.
- Broaden fingerprint_real_config() to also tripwire new logs/cli-*.log
  basenames (stat-only, bounded by the log cap), so real_config_untouched can
  actually detect a real-home write. Verified: default run → untouched=True;
  --inherit-home running a non-help command → untouched=False (guard trips).
- repl-commands: drop the misleading `/help or /quit` check; assert the /help
  command list rendered and keep /quit as the quit_advertised note.
- _kill_tree: reap the full descendant tree (recursive pgrep -P walk), snapshot
  before close() so reparented grandchildren are still reachable — matching the
  "non-negotiable teardown" framing.
- SKILL.md: correct the safety prose to reflect default HOME isolation, the
  --inherit-home tradeoff, and the broadened fingerprint.

Co-authored-by: Isaac
2026-06-23 22:22:52 -07:00
xtra 63741963b7 fix: validate numeric policy factory params (#1019)
* fix: validate numeric policy factory params

* test: cover policy integer params in e2e UI

---------

Co-authored-by: wxrth <191876097+wxrth@users.noreply.github.com>
2026-06-24 13:15:03 +08:00
Manfred Calvo 49250c1c29 fix(runner): size compaction budget from declared context_window + guard futile re-compaction (#769)
* fix(runner): size compaction budget from declared context_window + guard futile re-compaction

The runner's proactive compaction budgeted against get_model_context_window(model),
ignoring a spec's declared executor.context_window. For a high-window agent (e.g.
Polly's 1M brain) the model often resolves to the 128K default, so the budget was
0.8*128K=102400 instead of 0.8*1M=800000 — compaction fired ~8x too early, on
nearly every turn.

Compounding it, for harness-owned-context harnesses (claude-sdk, codex, cursor)
runner-side compaction cannot shrink the harness's own session, so the
provider-reported fill never dropped and compaction re-fired every turn.

- Add resolve_effective_context_window(): prefer the declared window over the
  catalog lookup (mirrors what the server already does for its display ring).
- Use it at both runner compaction-context construction sites.
- Add _should_skip_futile_recompaction(): skip a provider-reported re-fire when
  the fill has not dropped since the last compaction; defer to the harness's own
  auto-compaction. The reactive _ContextWindowOverflow path passes force=True so
  a confirmed overflow always attempts compaction.

Tests: resolver (3), budget-honoring compaction (2), guard predicate (5).

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

* fix(runner): honor model override when sizing the compaction budget

resolve_effective_context_window ignored model overrides, so it diverged
from the server's display ring it cites: the ring only honors the declared
executor.context_window when no override is active, otherwise it sizes
against the override model's real catalog window. Overriding a 1M-window
agent down to a small-window model therefore budgeted compaction against 1M
and under-compacted past the real limit.

- resolve_effective_context_window: add an override-aware path that mirrors
  the ring (declared window only when no override; else the override model's
  catalog window).
- per-turn dispatch: thread msg_body model_override through, and recompute
  the cached budget when an active override no longer matches the cached
  entry's model — so a mid-session /model pin (or the create-time pre-seed,
  which can't know the override) takes effect instead of the stale value.
- store the effective model in _compaction_contexts so count_tokens
  tokenizes against the model the turn actually runs on.

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

* refactor(server): size the context ring via the shared resolver

The 'which context window applies' decision (declared executor.context_window
unless a model override is active, else the override model's catalog window)
was implemented twice: inline in the server's session snapshot (the UI context
ring) and as resolve_effective_context_window in the runner (the compaction
budget). Maintaining two hand-copied policies is exactly how the runner's copy
silently drifted out of step (this PR's review) — it stopped honoring overrides
while the server kept honoring them.

Make the server ring call the same resolve_effective_context_window the runner
uses, so a single function computes the value in both processes and they can't
drift again. Behavior is unchanged (the server was already override-correct);
this removes the duplication. The to_thread offload is preserved (the resolver
can do a cache-cold catalog fetch) and the forwarder-observed-window label
still wins last.

Adds a test asserting an active override bypasses a declared 1M window and
sizes the ring against the override model's window.

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

* style: apply ruff format + import-sort (pre-commit)

Pre-commit CI flagged two files from this PR's test additions:
- tests/llms/test_context_window.py: ruff-format collapsed a multi-line
  monkeypatch.setattr() onto one line.
- tests/runtime/test_compaction.py: ruff-check (isort) reordered the
  resolve_effective_context_window import into sorted position.

Mechanical, no behavior change.

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

* fix(runner): re-size compaction budget when a model override is CLEARED

The recompute guard only rebuilt the cached compaction context while an
override was active (`_turn_override is not None and cached.model != override`).
So after a user pinned `/model small-200k` and later cleared it, the cache kept
budgeting against the stale 200K override window indefinitely instead of
reverting to the spec's declared executor.context_window (e.g. 1M) — the exact
over-compaction this PR set out to fix, in the clear-override direction. The
server display ring recomputes from scratch each snapshot and self-corrects;
the runner cache did not.

Resolve the effective model (override, else spec model, else body model) and
recompute whenever it differs from the cached entry's model — covering both
pinning and clearing an override. Extract the decision into a pure module-level
helper `_resolve_compaction_context` so the clear-override path is unit-testable
(the guard previously lived inline in the dispatch handler against a
closure-local cache dict).

Adds tests/runner/test_app_compaction_context.py covering cache miss, override
set, override cleared (the regression), no-change identity, and no-spec body
fallback.

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

---------

Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
Co-authored-by: Serena Ruan <82044803+serena-ruan@users.noreply.github.com>
2026-06-24 13:07:23 +08:00
Serena Ruan c42b8eef11 test(server): scope stale-host list assertion to its own host (#1088)
test_list_hosts_stale_host_reported_offline asserted len(hosts) == 1 on
GET /v1/hosts, assuming a pristine host store. Host rows are not isolated
per-test within an xdist worker, so sibling tests (host_detail,
host_validate2, host_fs_test) leak into the list. CI saw `assert 4 == 1`.
Whether those siblings land on the same worker before this test varies
run-to-run, so it flakes; reruns can't help since leaked rows persist for
the worker session.

Scope both assertions to the host_stale row this test registers (online
before backdating, offline after) instead of the global count, matching
how the sibling tests already use host-specific endpoints.

Co-authored-by: Isaac
2026-06-24 13:05:08 +08:00
Sabhya Chhabria 96869abd93 feat(setup): offer to install the copilot extra in omnigent setup (#1087)
Bring the Copilot harness's setup drill-in to parity with cursor /
antigravity: when the optional `github-copilot-sdk` extra is missing, the
Copilot drill-in now offers to install it (`pip install "omnigent[copilot]"`),
and the harness picker surfaces a "not installed — open to install" sub-line.
Previously Copilot only managed the GitHub token and silently assumed the SDK
was present, so a user without the extra hit a runtime import error on first
use instead of being guided to install it.

- copilot_auth.py: add COPILOT_EXTRA / COPILOT_EXTRA_INSTALL_COMMAND,
  copilot_sdk_installed(), copilot_install_command(), install_copilot_sdk() —
  mirroring cursor_auth / antigravity_auth.
- cli.py: add _prompt_install_copilot(); offer the install on entry to
  _manage_copilot_harness when the SDK is absent; add the not-installed
  sub-line to the Copilot picker row.
- tests: 8 new test_copilot_auth.py cases mirroring the cursor SDK-install
  coverage (detection, install-command argv, install-then-recheck, spawn failure).

Co-authored-by: Isaac
2026-06-23 22:02:58 -07:00
Serena Ruan 87f7ea09f0 test(e2e_ui): rerun two harness-stall-prone chat tests on failure (#1086)
test_mobile_chat_send_and_response and
test_clone_dialog_offers_cross_family_native_target_and_forks both send
a turn and wait up to 60s for the assistant bubble. Server logs from a
failed shard show the user message reaches the server and a background
turn starts (gateway routing -> policies/evaluate 200 -> events 204),
but the in-process harness occasionally yields no assistant output and
the runner goes idle until the 60s wait expires.

This is a nondeterministic harness scheduling stall (mock LLM, not a
real-LLM artifact), so mark both with @pytest.mark.flaky(reruns=2) per
the repo taxonomy rather than widening a wait a stalled turn would never
satisfy.

Co-authored-by: Isaac
2026-06-24 12:09:42 +08:00
Yuan Tang 65e3a151e0 fix(web): persist file browser collapsed state across sessions (#1025)
* fix(web): persist file browser collapsed state across sessions

The FilesPanel collapsed/expanded toggle was initialized to `false` on
every mount, so collapsing the panel didn't survive a page refresh or
session switch. Store the collapsed flag in the existing
`omnigent:files-panel-preferences` localStorage key alongside `changedOnly`.

* fix: address CI failures — formatting, TS errors, and test updates

- Fix Prettier formatting (collapse short ternaries to single lines)
- Update AppShell to spread existing prefs before overwriting changedOnly
- Update test expectations to include the new collapsed field

* test(web): assert persisted files-panel pref includes collapsed field

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

* test(e2e_ui): cover files-panel collapsed-state persistence across reload

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 21:01:31 -07:00
Yuan Tang 01ea98727e fix(ui): expand collapsed sidebar sections during search (#974)
* fix(ui): expand collapsed sidebar sections during search

When a search query is active, archived sessions matching the query were
fetched from the server but hidden because the Archived section is
collapsed by default. Force all sections open while searching so results
in every group are visible.

* fix(ui): allow collapsing sections during search

Instead of unconditionally forcing all sections open while searching,
use a separate transient collapsed state that starts empty (all expanded)
when a search begins but lets the user manually collapse sections during
the search. The persisted collapsed state is restored when the search
is cleared.
2026-06-24 11:56:40 +08:00
Serena Ruan 3e2f2ea2a5 feat(qwen): track per-turn token usage from the ACP stream (#1084)
qwen reports token usage out-of-band on an `agent_message_chunk` whose
text is empty and whose `_meta.usage` carries inputTokens / outputTokens
/ totalTokens / cachedReadTokens (qwen-code `emitUsageMetadata`). The
executor ignored `_meta`, so `TurnComplete.usage` was never populated and
per-turn token reporting stayed blank.

Add `_accumulate_usage` to fold each update's `_meta.usage` into a
per-turn accumulator: sum across the turn's internal model calls (each
API call bills its own full input) and split `cachedReadTokens` out of
`input_tokens` (qwen's inputTokens is cache-inclusive; cost wants the
non-cached portion) — mirroring the codex executor. Emit the result on
`TurnComplete.usage` and feed `_notify_usage_from_dict`.

Verified end-to-end against a live `qwen --acp` turn. Also resolves the
per-turn context-consumed half of the context-status follow-up.

Co-authored-by: Isaac
2026-06-24 11:52:43 +08:00
ScubaSpinner c06f4be706 feat(ap-web): render Markdown task lists in chat messages (#721)
* feat(ap-web): render Markdown task lists in chat messages

Chat messages render via Streamdown + remark-gfm, which parsed task syntax into checkboxes but Tailwind list-disc left a redundant bullet next to each. Drop the list marker per task item (matching GitHub) so chat task lists render as clean checkboxes; plain list items keep their bullet. Covered by a Playwright e2e test.

Signed-off-by: ScubaSpinner <294648202+ScubaSpinner@users.noreply.github.com>

* test(e2e_ui): route clone-session seed turns on mock LLM by marker

Earlier tests in the same shard can leave exhausted mock queues that
match later requests first, so the clone-session e2e never gets an
assistant reply. Pin each seed turn to its unique marker instead.

---------

Signed-off-by: ScubaSpinner <294648202+ScubaSpinner@users.noreply.github.com>
Co-authored-by: ScubaSpinner <294648202+ScubaSpinner@users.noreply.github.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-24 03:25:46 +00:00
ckcuslife-source eefed1d7fa feat(attachments): enforce per-type upload size limits and block unsupported types (#1073)
* feat(attachments): enforce per-type upload size limits and block unsupported types

Uploaded attachments are inlined into the model context as base64 and
re-sent every turn, so a large or unreadable file either blows the
context budget (the ~65MB pptx that crash-looped a session) or is fed to
the model as garbled UTF-8. There was no size or type guard: the upload
route read the whole body unconditionally and accepted anything.

Server (authoritative):
- content_resolver.attachment_upload_limit(content_type) returns a
  per-type byte cap (image 5MB / PDF 20MB / text 10MB; 25MB global
  ceiling) or None for unsupported types (pptx, docx, zip, ...).
- upload_session_file resolves the type BEFORE reading the body and
  returns 415 for unsupported types; reads via _read_upload_capped,
  which aborts with 413 once the per-type cap is crossed (also fixes the
  unbounded read OOM risk).

Web (early UX block):
- lib/attachments.ts: classifyAttachment / validateAttachments mirror the
  server limits; code files whose browser MIME is empty/wrong are matched
  by extension.
- ChatPage.addFiles validates paste/drop/picker input, keeps only
  accepted files, and shows an inline error for rejected ones.

Tests: attachment_upload_limit matrix, upload endpoint 415/413/happy
paths, and lib/attachments unit tests.

* fix(attachments): accept text/code files mislabeled as binary (e.g. .csv as Excel)

Some browsers/OSes report a text/code file's MIME as a binary office type
(notably .csv → application/vnd.ms-excel on Windows). The server's type
check would then 415 it, even though the web client accepts it via its
extension allowlist — a frontend/backend mismatch.

Add attachment_text_type_for_extension(): when the declared MIME isn't an
allowed attachment, fall back to a text-like type by extension (mirroring
the web allowlist), but only for known text/code extensions so real
binaries (.xls, .pptx) stay rejected. The upload route normalizes the
content_type to the resolved text type so the resolver inlines it as text.

* test(attachments): add e2e_ui reject-type coverage; prettier-format web files

- Format lib/attachments.ts + attachments.test.ts to the project's prettier
  style (fixes the ap-web-prettier pre-commit hook and npm test's format check).
- tests/e2e_ui/chat/test_composer_attachments.py: add test_reject_unsupported_type
  — drives a .pptx through the composer's hidden input and asserts no chip plus
  the inline rejection error (Playwright coverage the E2E UI gate requires for
  the new addFiles validation). Update the stale "no client-side filtering"
  comment now that addFiles validates type + size.

* test(attachments): guard client/server extension parity and the cap boundary

Polly review follow-up. The client gate (TEXT_CODE_EXTENSIONS in
attachments.ts) and the server's extension fallback must agree on what's
attachable, or a file passes the client and then 415s. Add:

- test_client_server_attachment_extension_parity: parses the client's
  TEXT_CODE_EXTENSIONS and asserts every one is accepted server-side across
  worst-case browser MIMEs (.ts→video/mp2t, .xml→application/xml,
  .rb→application/x-ruby, octet-stream, empty) — the divergence Polly flagged,
  now covered.
- test_text_code_extensions_resolve_to_allowed_text: every declared extension
  resolves to a limited text type.
- _read_upload_capped boundary tests: exactly-at-limit passes, one-over 413s.
2026-06-23 20:07:40 -07:00
Sabhya Chhabria ae774b8f79 feat(harness): add GitHub Copilot SDK harness (#330)
* feat(harness): add GitHub Copilot SDK harness

Add a first-party `harness: copilot` that drives the GitHub Copilot SDK
(`github-copilot-sdk`), mirroring how the cursor and antigravity SDK
harnesses are wired. The Python SDK bundles the Copilot CLI binary it
drives as a backing server, so the harness needs only the pip dependency
(optional `copilot` extra, lazy-imported) — no separate CLI install.

- `omnigent/inner/copilot_executor.py`: `CopilotExecutor` — one persistent
  `CopilotClient` + `CopilotSession` per conversation, streaming
  `SessionEvent`s into ExecutorEvents (text/reasoning deltas, tool
  execution, usage). Omnigent `sys_*` tools bridge in-process via SDK
  `Tool`s whose async handler routes to `_tool_executor` (awaited in the
  SDK's own loop — no thread hop). PHASE_LLM_REQUEST/RESPONSE policy parity.
- `omnigent/inner/copilot_harness.py`: the `create_app()` wrap reading
  `HARNESS_COPILOT_*` env vars.
- `omnigent/onboarding/copilot_auth.py`: a GitHub token store (dedicated
  `copilot:` config block + secret store), resolved like the cursor key.
- Wiring: harness registry, spec allowlist + `github-copilot` alias,
  spawn-env builder, runner dispatch + model-env map, model-override set,
  readiness check, `omnigent setup` management, ap-web label, docs.
- Auth: a GitHub token with Copilot access (fine-grained PAT w/ "Copilot
  Requests", or a gh/Copilot-CLI OAuth token). No Databricks gateway path.
- `pyproject.toml` / `uv.lock`: `copilot` extra (`github-copilot-sdk>=1,<2`).
- Tests: executor (fake-SDK), harness wrap, spawn-env, auth; readiness
  test updated for the new spellings.

Verified end-to-end against a local server: a standalone copilot agent,
an agentic file create/read tool loop, and polly + debby running their
orchestrator brain on `--harness copilot`.

Co-authored-by: Isaac

* fix(copilot): reap CLI on start failure + don't mask mid-turn errors; add e2e skill

Fixes found by a live multi-agent bug-bash of the copilot harness:

- HIGH: `client.start()` ran outside the cleanup try/except, so a start
  failure (bad token, version skew) dropped the only reference to the
  client without stopping it — orphaning the bundled Copilot CLI subprocess
  (the SDK only reaps it in `stop()`, never on a start error path). Moved
  `start()` inside the try so `_safe_stop(client)` covers it.
- LOW: a `SESSION_ERROR` / `MODEL_CALL_FAILURE` arriving after partial text
  streamed was masked — the turn was reported as a clean `TurnComplete`
  with the partial text. Now surface it as an `ExecutorError` whenever the
  SDK returned no successful final message, even if some text streamed.
- Document the known limitation (parity with cursor): Copilot's *native*
  tools (create/view/edit/bash) run inside the SDK, so they bypass
  `on:[tool_call]` policies and leave no transcript item; bridged `sys_*`
  tools are gated + recorded. Gate built-ins at the LLM phase or sandbox.
- Add the `copilot-sdk-e2e-dev` skill (parity with cursor/antigravity),
  capturing the test recipe and the bug-bash's known sharp edges.
- Tests: cover the start-failure teardown and the mid-turn-error-not-masked
  paths.

The bug-bash also surfaced two pre-existing, harness-agnostic issues left
out of scope (native-tool transcript items in the shared executor adapter;
top-level `policies:` silently dropped in the shared spec parser).

Co-authored-by: Isaac

* test(copilot): address review findings + prove polly-on-copilot brain e2e

Adversarial swarm review + live polly e2e of the Copilot SDK harness
surfaced small correctness fixes and coverage gaps; this addresses them
and adds durable e2e coverage for copilot as polly's orchestrator brain.

Code fixes:
- copilot_executor: unwrap the SDK's structured TOOL_EXECUTION_COMPLETE
  error ({"message","code"}) and result wrapper ({"content",...}) so the
  tool error/result carry the payload, not a Python dict repr.
- cli: list `copilot` in the --harness help text (parity with peers).

Tests (executor): policy-deny gates (PHASE_LLM_REQUEST/RESPONSE), session
restart on tool/model change, mid-turn send_and_wait failure (retryable +
recreate), tool-result unwrap + BLOCKED/CANCELLED classification, interrupt,
empty-prompt, no-tool-executor branch, paragraph break, cache_read accumulation.
Tests (harness wrap): assert real adapter routes + os_env/bundle_dir/ambient
token. Tests (auth): inline github_token + dangling keychain ref.

E2E:
- add gated real-network tests/e2e/test_polly_copilot_e2e.py (polly brain on
  --harness copilot; skipped without a Copilot token, like the CLI probes).
- document the polly-brain recipe in the copilot-sdk-e2e-dev skill.
- exclude copilot from the gateway-auth live-matrix coverage test (it auths
  via a GitHub token, no Databricks gateway — same as cursor/antigravity).

Also fix model_override.py formatting (ruff).

Co-authored-by: Isaac
2026-06-23 19:53:30 -07:00
ckcuslife-source b5e2d4446b fix(server): derive omnigent.ui terminal label from native agent identity (#1079)
The web UI gates the Chat/Terminal pill on the omnigent.ui="terminal" label.
For native-terminal-wrapper sessions (claude-native-ui / codex-native-ui) that
flag is fully determined by the agent identity, yet it was only read back from
the stored conversation labels. Derive it in _build_session_response from
agent_name as well, so the pill stays correct even if the stored label is
missing or stale. Idempotent: a no-op when the label is already present.

Co-authored-by: Isaac
2026-06-23 19:42:28 -07:00
Corey Zumar b301b2cfe6 fix(login): default URL scheme to https and accept the /omnigent web URL (#1047)
* fix(login): default URL scheme to https and accept the /omnigent web URL

The internal user guide hands out workspace URLs without a scheme, and the
web-UI URL ends in /omnigent (e.g. dbc-xxxx.cloud.databricks.com/omnigent).
Pasting that into `omnigent login` or the desktop setup failed: the CLI
required an explicit scheme and probed /omnigent as an opaque path, and the
desktop defaulted bare hosts to http://.

- omni login: a schemeless URL now defaults to https (http for loopback
  hosts); a pasted <ws>/omnigent web URL expands to the /api/2.0/omnigent
  API mount when its root answers as a Databricks workspace, and is left
  untouched otherwise so a non-workspace server under /omnigent still works.
- desktop: normalizeUrl defaults to https (http for loopback); the setup
  page's plain-http warning mirrors the new default so bare remote hosts
  (now https) no longer trip it.

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

* fix(host): accept schemeless /omnigent workspace URL; DRY + test desktop URL helpers

omni host:
- `omnigent host --server` and the host subcommands now default a schemeless
  URL to https and accept the guide's web-UI URL (<ws>/omnigent), matching
  `omnigent login` (wraps _workspace_api_server_url with _with_default_scheme
  in the host command and _resolve_host_server).

desktop:
- extract the duplicated URL helpers (LOCAL_HOSTS, normalizeUrl,
  isPlainHttpRemote, expandDatabricksWorkspaceUrl) into a single shared module
  ap-web/electron/src/url.js (UMD: required by the main process, loaded as
  window.omnigentUrl by the setup page) so the two copies can no longer drift.
- add a node --test suite (test/url.test.js, `npm test`) covering scheme
  defaulting, the plain-http warning, and the workspace probe/expansion.

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

* fix(cli): default --server scheme to https across run/attach/resume too

Apply the same normalization as `omnigent login` / `omnigent host` to every
remaining --server entry point so they all behave identically: a schemeless
URL defaults to https (http for loopback) and the guide's /omnigent web URL is
accepted. Wraps _workspace_api_server_url with _with_default_scheme in
_ensure_backend (run/claude/codex/chat), _resolve_attach_server (attach), and
the resume command. Adds a wiring test per resolver.

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

* refactor(cli): DRY --server normalization into one _resolve_server_url helper

The scheme-default + workspace/omnigent expansion combo was duplicated across
six --server entry points (login, host, run/claude/codex/chat, attach, resume,
host subcommands). Collapse it into a single _resolve_server_url() that all of
them route through, removing the repeated _workspace_api_server_url(
_with_default_scheme(...)) calls and their duplicated comments. Behavior is
unchanged; add a direct composition test for the helper.

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

* test(ap-web): scope vitest discovery to src/ so it skips the electron package

The new ap-web/electron/test/url.test.js uses node:test, but ap-web's vitest
default glob swept it up and failed with 'No test suite found'. Restrict
test.include to src/ (where the whole ap-web suite lives).

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

* test(login): use a single omnigent.cli import style

Address code-quality review: the module was imported both as
`from omnigent.cli import cli as cli_group` and `import omnigent.cli as
cli_mod`. Import the module once at the top (cli_mod) and derive
cli_group from it; drop the per-test local imports.

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

* docs(electron): mark desktop /ml/omnigents mount as an intentional divergence

Keep WORKSPACE_UI_PATH = /ml/omnigents on the desktop (the path the live
workspace serves the embedded SPA on) and document that it intentionally
differs from Python's /omnigent for now, with a guard against 'fixing' it
blindly. Addresses Polly's blocking review note.

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

* test(e2e_ui): cover desktop setup-page connect flow with the scheme default

Adds a Playwright e2e_ui test for the Electron setup page
(ap-web/electron/setup/index.html): a schemeless bare/`/omnigent` workspace
URL now connects on the first click instead of tripping the unencrypted-http
warning, explicit http:// to a remote host still warns then proceeds, loopback
stays http, and the shared url.js module (also used by the main process)
defaults the scheme in-browser. Satisfies the e2e-ui-required gate for the
desktop login/connect behavior change.

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 19:22:49 -07:00
Juhong Park 1409f81dad fix: pass session workspace to pi harness (#66)
* fix: pass session workspace to pi harness

Signed-off-by: Juhong Park <juhongp@mit.edu>

* test: cover pi cwd workspace fallback

Signed-off-by: Juhong Park <juhongp@mit.edu>

---------

Signed-off-by: Juhong Park <juhongp@mit.edu>
Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-06-23 19:11:40 -07:00
Dhruv Gupta bf2e1e9454 feat(harness): add OpenCode (native-server: serve + SSE forwarder + TUI takeover) (#576)
* docs(design): opencode harness + unified harness-interface (draft)

* docs(design): full opencode-native + unified harness-interface design

Covers: harness core (HTTP+SSE), opencode TUI attach takeover, ap-web
integration, opencode optional+runtime-selectable for polly & debby,
and the unified HarnessDescriptor/NativeServerHarness interface.
Supersedes the v1 draft.

* feat(opencode): harness core + unified native-server interface (fronts A, E)

Add the opencode-native harness and the HarnessDescriptor single-registration
that the scattered registries now derive from.

Front A (opencode core):
- opencode_native_bridge/state: per-session bridge dir, XDG roots, auth
  secret, durable launch state.
- opencode_native_client: typed HTTP+SSE client shaped from the pinned
  opencode 1.17.x OpenAPI (sessions/prompt/abort/fork/permission + /event).
- opencode_native_app_server: opencode serve process manager (loopback,
  version-check, readiness) + attach argv/env builders.
- opencode_native_forwarder: SSE -> Omnigent event translation per the
  design table (session.next.* text/tool/step, permission.v2.asked), dedupe,
  reconnect.
- opencode_native_permissions: normalize + once/always/reject mapping.
- inner/opencode_native_executor + harness: thin create_app wrapper built on
  the shared NativeServerHarness base.

Front E (unified interface):
- runtime/harness_descriptors: HarnessDescriptor + HARNESS_DESCRIPTORS, the
  single source of truth; _HARNESS_MODULES / OMNIGENT_HARNESSES /
  HARNESS_ALIASES / NATIVE_HARNESSES now derive from it.
- native_server_transport: NativeServerTransport protocol + dataclasses.
- native_server_harness: shared Executor base for native-server harnesses.
- opencode_http_transport + codex_ws_transport: two concrete transports
  proving the abstraction.

Registries wired for opencode-native: spec allowlist, runtime modules,
aliases, native set, model-override (via native), install metadata,
readiness gating, wrapper label, native_coding_agents, built-in agent
seeding, and runner harness spawn-env.

Co-authored-by: Isaac

* feat(opencode): runner-owned serve + attach terminal takeover (front B)

Add the runner-side native terminal auto-create for opencode-native,
mirroring _auto_create_codex_terminal:
- _opencode_native_launch_config: fetch + validate the session snapshot.
- _auto_create_opencode_terminal: boot opencode serve, resume-or-create the
  OpenCode session, persist external_session_id + bridge state, start the
  SSE forwarder (supervised so the server is closed on teardown), and
  register the `opencode attach` TUI as a streamable terminal resource.
- ensure_native_terminal dispatch branch for terminal_name == "opencode".
- OPENCODE_NATIVE_TERMINAL_ROLE constant.

The forwarder stays live independent of TUI process lifetime, so human
TUI actions keep mirroring into the web transcript.

Co-authored-by: Isaac

* feat(opencode): optional worker for polly/debby + allowlisted args.harness (front D)

Short-term (declared optional worker):
- examples/polly/agents/opencode and examples/debby/agents/opencode: optional
  opencode-native workers, default-off (gated by `opencode` CLI presence).
- polly config: roster up to FOUR sub-agents, preflight probes `opencode`,
  cross-review tracks harness AND model provider (opencode = 4th vendor, not
  independent of same-provider implementers).
- debby config: optional third "OpenCode perspective", default fanout stays
  Claude + GPT; three-way debate only on explicit request.

Long-term (runtime harness override):
- sys_session_send args gains an optional `harness` field.
- tool_dispatch validates it against the sub-agent's
  executor.config.allowed_harnesses allowlist + OMNIGENT_HARNESSES and threads
  it as harness_override into the child create (rejected on by-session-id mode).
- examples/polly/agents/codex opts in via allowed_harnesses:
  [codex-native, opencode-native].
- conversation.harness_override docstring: a sub-agent may carry its OWN
  create-time override (it still never inherits the parent brain's).

The server create route already validates + persists harness_override and the
runner already honors it, so the long-term path works end to end.

Co-authored-by: Isaac

* test(opencode): harness test matrix + conformance suite + scaffold generator (front E)

- tests/harness_conformance/: drift tests asserting every scattered registry
  derives from HARNESS_DESCRIPTORS, plus the NativeServerTransport contract
  driving NativeServerHarness over a fake transport AND both real transports
  (OpenCodeHttpTransport via a fake HTTP server, CodexWsTransport via a fake
  app-server client) — two implementations proving the abstraction.
- opencode unit tests mirroring the codex matrix: bridge state, launch state,
  permissions mapping, HTTP/SSE client (httpx.MockTransport fake server, SSE
  framing), app-server arg/env/version/start, forwarder translation table
  (text/tool/step/permission/dedupe/filter/reconnect), executor turn lifecycle
  (inject/abort/enqueue/image-block/mismatch).
- omnigent/scaffold_harness.py: dev generator for new-harness boilerplate +
  the extension-point checklist.

104 new tests, all green.

Co-authored-by: Isaac

* feat(opencode): wire OpenCode into ap-web native UI (front C)

Mirror codex/pi native-agent wiring for OpenCode:
- OpenCodeIcon (@lobehub/icons/es/OpenCode); "opencode" added to the
  NativeCodingAgentIconKind / ConversationIconKind unions.
- nativeCodingAgents.ts: OpenCode entry (opencode-native-ui / opencode-native,
  sortRank 25, approvalMode) — derived lookup maps pick it up.
- NewChatDialog (display order + builtin set), SubagentsPanel (child icon +
  subagent wrapper label), AgentCard (icon), sidebarNav (icon kind),
  useTerminals (terminal_opencode_main excluded from the shell inventory).
- test-setup.ts: global OpenCodeIcon mock paralleling the Claude/Codex mocks
  (the @lobehub icon import chain breaks under vitest otherwise).
- Tests extended across nativeCodingAgents / AgentCard / useAvailableAgents /
  SubagentsPanel / sidebarNav / useTerminals.

tsc -b clean; vitest 2838 passed / 3 expected-fail / 2 skipped.

Co-authored-by: Isaac

* test(opencode): front D worker discovery + args.harness dispatch + readiness map

- test_opencode_polly_debby_worker: polly/debby specs declare the opencode
  worker; codex worker allowlists the opencode-native override; preflight
  probes opencode; debby keeps it optional.
- test_subagent_harness_override: args.harness extraction + allowlist
  canonicalization helpers.
- harness_readiness test: opencode-native / native-opencode spellings added to
  the configured-harness-map coverage assertion.

Co-authored-by: Isaac

* fix(opencode): eliminate mypy no-any-return at the transport/forwarder JSON boundary

Wrap the opaque JSON-RPC / SSE return values so the typed return contracts
hold (bool / str / Mapping), leaving only the explicit-any annotations the
repo sanctions for opaque JSON payloads (matching the existing codex modules).

Co-authored-by: Isaac

* test: update polly/debby worker-set expectations for the opencode worker

The optional opencode worker joins polly (4 workers, 4 vendors, 7 function
policies) and debby (3 workers, 3 vendors; default fanout still claude+gpt).
Update the brain-harness-override test and the example-bundle parse tests
accordingly.

Co-authored-by: Isaac

* fix(opencode): allowlist-gate args.harness schema + reconcile CI

Front D advertised args.harness unconditionally in the sys_session_send
schema, which broke two tests pinning the base args object to
{input, purpose, model} and diverged from design D.4 (the runtime harness
override is allowlist-gated, opt-in only).

- spawn.py: advertise `harness` in the args object only when at least one
  declared sub-agent opts in via executor.config.allowed_harnesses (mirrors
  the per-child dispatch guard in tool_dispatch.py). Specs without the
  opt-in keep the base {input, purpose, model} contract, so the two pinned
  schema tests stay correct as-is.
- test_sys_session.py: add a test asserting `harness` is present for an
  opted-in sub-agent and absent otherwise (and that a mix opts the tool in).
- test_run_harness_without_agent_e2e.py: exclude opencode-native from the
  live `omnigent run --harness` matrix. It is a terminal-takeover
  native-server harness (same shape as claude/codex-native), so it cannot
  round-trip through this gateway-backed no-AGENT matrix. Fixes E2E shard 1/4.
- test_start_session.py: add a hermetic e2e_ui Playwright test covering the
  OpenCode agent in the new-chat picker (harness-derived "OpenCode" label,
  not the raw "opencode-native-ui") and the terminal-first wrapper labels on
  create.

Co-authored-by: Isaac

* fix(opencode): wire permission policy gate + per-prompt model pin

Addresses blocking cross-vendor review findings on the OpenCode harness.

BLOCKING #1 — security: OpenCode permissions no longer silently auto-approve.
- opencode_native_forwarder.py: the permission ``default_decision`` flips
  from ``allow_once`` to ``reject``. An unconfigured or unreachable policy
  now FAILS CLOSED — a headless OpenCode turn can never silently approve a
  sensitive op. Only an explicit policy ``allow`` reaches ``once``/``always``.
- runner/app.py: wire a real ``policy_evaluator`` at forwarder
  instantiation. ``_build_opencode_policy_evaluator`` POSTs each
  ``permission.v2.asked`` to the session's ``/v1/sessions/{id}/policies/evaluate``
  endpoint as a ``PHASE_TOOL_CALL`` event — the SAME server-side gate
  codex-native's policy hook uses, where an ``ask`` verdict is parked as a
  human approval card and blocks until resolved. Unreachable / non-200 /
  malformed / unresolved-ask all fail closed to deny.
- tests: assert no auto-approve absent policy, explicit allow → once,
  allow_always → always, deny/ask → reject, the evaluator receives the
  normalized policy input, and the runner evaluator's request shape +
  verdict mapping + fail-closed paths.

BLOCKING #2 — OpenCode model override now governs the run from turn one.
- Verified against the OpenCode SDK that ``POST /session`` does NOT accept a
  model (the stale client docstring is corrected); the model is a per-prompt
  field ``{providerID, modelID}``. OpenCodeNativeExecutor now threads the
  session's ``model_override`` (from bridge state) onto every injected
  prompt. OpenCode persists the last-used model as the session default, so
  pinning the first turn also governs later TUI-typed turns — the override
  controls the run from the start, not only a later web turn.
- test asserts the resolved model reaches the prompt body as
  ``{"providerID","modelID"}`` (and is absent when no override is set).

NON-BLOCKING — tighten OpenCode server env isolation.
- opencode_native_app_server.py: drop ``OPENCODE_CONFIG`` /
  ``OPENCODE_CONFIG_CONTENT`` from the env passthrough so the parent shell's
  GLOBAL OpenCode config can't defeat the per-session XDG isolation. Other
  ``OPENCODE_*`` vars (and the server password we set) are unaffected.

BLOCKING #3 (NativeServerHarness migration of codex-native) is NOT included:
a behavior-preserving migration is not safely landable here — see the PR
discussion. codex-native is unchanged; its executor tests stay green.

Co-authored-by: Isaac

* fix(opencode): address AI-review static-analysis nits + add deferral note

Resolve all 11 github-code-quality[bot]/CodeQL findings on PR #576,
all low-severity static-analysis nits with no behavior change:

- opencode_native_executor.py: rename subclass methods so they no longer
  shadow the base NativeServerHarness instance attributes set from the
  injected callbacks (_build_prompt -> _build_prompt_with_model_override,
  _resolve_session_id -> _resolve_opencode_session_id). Bodies unchanged.
- native_server_transport.py: replace every `...` Protocol-method body
  with `raise NotImplementedError` so CodeQL's "statement has no effect"
  doesn't re-flag the stragglers. Interface semantics unchanged.
- opencode_native_bridge.py: document the two intentionally-ignored read
  errors in ensure_auth_secret (missing/unreadable secret => regenerate).

Also append a "Deferred to a follow-up PR" section to the design doc
documenting that codex-native is not yet migrated onto NativeServerHarness
and CodexWsTransport is defined but not wired into any production path.

* fix(opencode): address CodeQL static-analysis nits

- test_opencode_native_forwarder: import the forwarder module one way only
  (consolidate to `import ... as fwd_mod`, drop the duplicate import-from),
  clearing CodeQL "module imported with import and import-from".
- codex_ws_transport / opencode_http_transport: export the client-factory
  type aliases (`CodexClientFactory`, `ClientFactory`) via `__all__`. They are
  the documented annotation for each transport's `client_factory` param, but
  PEP 563 stringifies that use so CodeQL saw them as unused globals.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* docs: drop opencode design doc from the PR (kept locally)

The 2k-line design doc inflated the PR diff without being code under
review. Untracked from the PR tree; it stays on disk locally for reference.

Co-authored-by: Isaac

* feat(opencode): web-UI terminal auto-create + Databricks-gateway provider wiring

Two gaps surfaced by a full-stack host e2e (isolated $HOME, real opencode serve):

1. Web-UI terminal auto-create: opencode-native was MISSING from the runner's
   session-creation terminal dispatch (claude/codex/pi/cursor each have a
   branch; opencode only had the on-demand ensure_native_terminal path). A
   host/web-UI opencode session therefore never booted its opencode serve + SSE
   forwarder + opencode attach terminal, so the UI had no terminal+chat view to
   embed. Add the opencode-native branch alongside the other natives (idempotent
   with the on-demand path via the existing per-session lock).

2. Databricks-gateway provider config: unlike codex/claude/pi (which consume
   HARNESS_*_GATEWAY_* env their CLI translates), opencode reads provider/auth
   from its own config file. Add omnigent/opencode_native_provider.py to resolve
   a gateway from the spec's Databricks profile (via databricks-sdk) and
   synthesize an opencode.json (custom @ai-sdk/openai-compatible provider at
   {host}/serving-endpoints) into the per-session XDG config dir at spawn, with
   the per-prompt model pinned to provider/endpoint. Best-effort: no profile or
   no SDK -> opencode falls back to its ambient provider config.

Tests:
- tests/test_opencode_native_provider.py (13): synthesis shape, 0600 write,
  model normalization, SDK-absent/no-token/success resolution.
- tests/e2e/test_host_opencode_native_e2e.py (opt-in OMNIGENT_E2E_OPENCODE_NATIVE):
  built-in agent registered + host session auto-creates terminal_opencode_main.

Validated against the real Databricks AI gateway (databricks-claude-sonnet-4-6):
resolve -> synthesized opencode.json -> prompt round-trip returns assistant text.

Co-authored-by: Isaac

* fix(opencode): mirror assistant output to the web chat view + add `opencode` alias

#2 (chat view): the SSE forwarder was keyed on a `session.next.*` /
`permission.v2.asked` event vocabulary that opencode 1.17.x never emits, so every
real assistant-text/tool event hit `_HANDLERS.get(...) -> None` and was silently
dropped — the TUI showed the turn but nothing reached the web chat view (the
durable items the chat reads). The old unit tests passed only because they fed
the same fake event names.

Rewrite the handlers against opencode's real PART-based model (verified by
capturing a live `opencode serve` turn):
- text: `message.part.updated`(type=text, role-filtered to assistant) finalized
  into a durable conversation item on `step-finish`/`session.idle`, plus
  `message.part.delta`(field=text) streamed live (ephemeral);
- tools: `message.part.updated`(type=tool) — call posted once its `state.input`
  is populated, output once `state.status` is completed/error (deduped by callID);
- lifecycle: `message.updated`(info.role), `session.status`(busy), `session.idle`;
- permissions: register both `permission.asked` (1.17.x) and `permission.v2.asked`.
Resume-dedupe is made type-aware so a reconnect never re-posts finalized parts.

Validated against a real Databricks-gateway turn: assistant text + bash tool
call/output now post as durable chat items; 17 forwarder unit tests rewritten to
the real event shapes (incl. user-text-not-mirrored + tool-snapshot dedup).

#3 (alias): accept `opencode` as a friendly alias for `opencode-native` (no
separate SDK `opencode` harness exists, so the bare name is free); added to the
descriptor `aliases` + `runtime_aliases`.

Co-authored-by: Isaac

* feat(opencode): show OpenCode in the `omni setup` harness picker

#1 (setup picker): OpenCode was absent from the `omni setup` harness overview, so
there was no obvious place to set it up. Add an OpenCode row (readiness = is the
`opencode` CLI installed) plus a `_manage_opencode_harness` drill-in that installs
the CLI when missing and explains where its credential actually lives — OpenCode
is a native-server harness with no Omnigent-stored key of its own; it routes
through the bound agent's Databricks gateway profile (synthesized into opencode's
per-session config) or ambient OpenAI-/Anthropic-compatible env vars.

Co-authored-by: Isaac

* feat(opencode): `omni opencode` CLI launcher + pin the setup install to 1.17.x

#4 (CLI launcher): `omni --harness opencode-native` errored "No native terminal
launcher wired" because opencode had no `run_*_native` launcher (every native
harness ships its own). Add one, mirroring `omnigent codex` / `omnigent pi`:

- `run_opencode_native` (omnigent/opencode_native.py): ensure a local daemon +
  runner, create-or-resume the `opencode-native-ui` session (whose runner
  auto-creates the `opencode serve` + `opencode attach` terminal — the branch
  added that dispatch), then attach this TTY directly to the runner-owned tmux
  pane. Reuses the shared `native_terminal` / `host.daemon_launch` helpers and
  the same direct-tmux attach codex/pi use.
- An `omnigent opencode` command (resume/--model/passthrough args), and the
  missing `native_agent.key == "opencode"` dispatch arm so
  `omni run --harness opencode-native` routes here too.

Install version pin: `omni setup` → install OpenCode ran `npm install -g
opencode-ai`, but that package's npm `latest` is a broken `0.0.0-beta-*`
pre-release — so it installed a version the runtime version-check rejects. Pin
the install spec to `opencode-ai@~1.17.7` (mirrors the runtime
>=1.17.7,<1.18.0 range), so setup installs a working opencode.

Validated on an isolated-home daemon: the host-created opencode session
auto-creates `terminal_opencode_main` with the `tmux_socket`/`tmux_target`
metadata the launcher attaches to.

Co-authored-by: Isaac

* fix(opencode): stop emitting unreconciled live text deltas to the web chat

Follow-up to the forwarder rewrite. Posting `external_output_text_delta` for
opencode's `message.part.delta` left the web chat view broken: the UI builds a
`live:<message_id>` streaming-preview block from text deltas and only retires it
via a finalize/retire handshake (a `final=True` delta / authoritative done +
itemId reconciliation). The forwarder never completed that handshake and the
committed item carried no correlating id, so the live preview lingered alongside
the separate committed message — duplicated / garbled assistant text in chat
(the terminal/TUI was unaffected).

Drop the live-delta path: forward only the durable `external_conversation_item`
(role=assistant, full text), exactly the codex-native finalized-message path
that renders correctly today. The assistant message now appears cleanly when
each step completes. Removed the now-dead `_on_part_delta` / `_post_text_delta`
/ `next_text_index` / `_EXTERNAL_TEXT_DELTA`.

Live token-by-token streaming is deferred to a follow-up: it must match the web
UI's live-preview retire protocol (claude-native style) and be verified against
the real chat renderer, which can't be checked from a headless harness.

Reproduced via a real gateway turn: before, the forwarder posted a delta
(message_id `opencode:ses:text:prt`) AND a committed item (response_id `ses`)
with no correlation; after, only `running` → assistant item → `idle`.

Co-authored-by: Isaac

* fix(opencode): per-turn response_id so chat messages keep conversation order

Reported symptom: in the web chat, all assistant messages clustered together,
separated from the user messages, instead of interleaving per turn.

Cause: the forwarder stamped EVERY mirrored item with
``response_id = opencode_session_id`` — a single constant for the whole
session. The chat view groups items into a "response" by ``response_id``, so a
constant id collapsed every turn's assistant text/tool items into one response
block, which the renderer placed at the first item's position — pulling all
assistant output above the later user messages. (codex-native avoids this by
stamping a per-turn response id.)

Fix: stamp each item with opencode's per-assistant-message ``messageID`` as the
``response_id`` (falling back to the session id only when unknown), so each
turn is its own response group and items order by position as a normal
conversation. Threaded the messageID through `_post_assistant_text` /
`_post_tool_call` / `_post_tool_output` and the text/tool handlers.

Verified on a real 2-turn gateway conversation: the two assistant messages now
carry two DISTINCT response_ids (were one shared id before). Added a unit test
asserting per-turn response_ids + response_id assertions on the existing
text/tool tests.

Co-authored-by: Isaac

* fix(opencode): mirror user messages in the forwarder so chat keeps turn order

Reported: the web chat showed every assistant message clustered first, then the
user messages out of order (and one missing) — while the TUI was correct.

Root cause: for native-server harnesses the forwarder is the SOLE source of the
conversation transcript — omnigent does NOT separately persist a user item for
these sessions (the runner mirrors the native transcript; cf. runner/app.py's
`is_native_harness` history gate, and codex-native's `_post_user_message` /
`_ensure_user_message_posted`, which exist precisely because omnigent doesn't
record it). The opencode forwarder SKIPPED user-role text, so user messages were
never durably recorded; the chat only showed transient optimistic echoes —
inconsistent and unordered. (The earlier per-turn response_id fix was necessary
but not sufficient: the user items weren't being persisted at all.)

Fix: mirror the user message in the forwarder. On a user-role `message.part.updated`
text part, post a `role=user` conversation item EAGERLY (deduped by part id) so it
takes an earlier position than its assistant reply — matching codex-native. User +
assistant now interleave by turn. Resume dedupe pre-marks user-text parts too.

Unit-tested (forwarder now posts user-before-assistant, deduped, with a per-turn
response_id). The full multi-turn render is covered by the opt-in host e2e
(`test_opencode_native_multiturn_item_order`, asserts strict user/assistant
interleaving) for CI + manual QA.

Co-authored-by: Isaac

* chore(opencode): drop the 35k-line vendored OpenAPI dump from the PR

The vendored `omnigent/opencode/openapi-1.17.7.json` (34,576 lines) was ~80% of
the PR diff and made it unreviewable (goose's comparable harness PR is ~5k). It
was added to make the descriptor's `openapi_schema` reference real, but the
typed client is hand-maintained and the live wire-contract e2e
(`test_opencode_native_wire_contract_e2e`, opt-in) validates it against a real
`opencode serve` — a far better drift guard than a checked-in schema dump.

Remove the file and the descriptor's `openapi_schema` field (defaults to None).
The conformance check that vendored schemas exist still guards any future
descriptor that sets the field; it just skips when none do.

Co-authored-by: Isaac

* feat(opencode): make the `omni setup` OpenCode section manage providers

Before, the OpenCode setup drill-in just printed a static note — it did nothing
useful. Now it mirrors the Goose/Qwen pattern.

New read-only reporter `omnigent/onboarding/opencode_auth.py`
(`opencode_auth_summary`): reads OpenCode's own credential state — stored
providers from `~/.local/share/opencode/auth.json` (XDG_DATA_HOME-aware, JSON
keyed by provider id per the OpenCode source) + detected provider env keys
(OPENAI_API_KEY / ANTHROPIC_API_KEY / …). Robust: reads auth.json directly
rather than scraping `opencode auth list` output.

The drill-in now reports which providers OpenCode can reach and offers
`opencode auth login`, `opencode auth list`, and a help note — never storing a
key through Omnigent (OpenCode owns its auth; the Databricks-gateway path stays
the agent profile synthesized into opencode's per-session config). The setup
overview row's ✓/✗ now reflects real readiness (CLI installed AND a provider
reachable), not just the binary being present.

+ unit tests for the reporter (auth.json parsing, env detection, readiness).

Co-authored-by: Isaac

* refactor(opencode): ship the harness the scattered way; defer the unified interface

Splits PR #576 in two. This PR adds OpenCode as a harness exactly like
goose/qwen/cursor-native were added — scattered registration across the
hand-maintained registries — and DEFERS the unified-interface refactor
(the single-source ``HarnessDescriptor`` registry, the descriptor-parity
conformance suite, and the harness scaffold generator) to a follow-up so this
PR can be reviewed as a focused harness addition.

Removed (moves to the follow-up):
- omnigent/runtime/harness_descriptors.py — the HarnessDescriptor registry.
- omnigent/scaffold_harness.py — the new-harness scaffold generator.
- omnigent/codex_ws_transport.py — the (unused) codex WS transport that
  generalized the native-server transport for a future codex migration.
- tests/harness_conformance/ — the descriptor-parity / transport-contract /
  scaffold conformance suite.

Re-scattered the registration that Front E had made descriptor-derived, adding
OpenCode the old way alongside the existing harnesses:
- runtime/harnesses/__init__.py: ``_HARNESS_MODULES`` back to a literal dict
  (+ ``opencode-native`` and its ``opencode`` runtime alias).
- harness_aliases.py: ``HARNESS_ALIASES`` / ``NATIVE_HARNESSES`` back to
  literals (+ ``opencode`` / ``native-opencode`` → ``opencode-native``).
- spec/_omnigent_compat.py: ``OMNIGENT_HARNESSES`` / ``OMNIGENT_HARNESS_ALIASES``
  back to literals (+ opencode id and aliases).
- onboarding/harness_install.py: ``_HARNESS_NAME_TO_KEY`` back to the
  alias-keyed map (+ opencode), ``required_cli_for_harness`` back to the direct
  lookup (no ``descriptor_for``).

Decoupled the kept OpenCode runtime from the descriptor registry:
- native_server_harness.py: take ``harness_id`` + ``supports_enqueue`` directly
  instead of a ``HarnessDescriptor``.
- inner/opencode_native_executor.py: pass those literals.
- native_server_transport.py / opencode_http_transport.py: drop the
  CodexWsTransport docstring references.

The OpenCode harness itself (executor, forwarder, typed client, app-server,
bridge, permissions, provider, ``omni opencode`` launcher, ap-web wiring,
``omni setup`` section, examples, and its test matrix) is unchanged. ruff
clean; opencode + registry + spec + dispatch suites green.

Co-authored-by: Isaac

* style(opencode): apply ruff format + prettier

Green the pre-commit (`ruff format`) and npm-test (`prettier --check`) CI gates:
- ruff format: opencode_native.py, opencode_native_provider.py,
  test_host_opencode_native_e2e.py, test_opencode_auth.py (line-wrapping only).
- prettier: ap-web/src/lib/nativeCodingAgents.ts.

Formatting only — no behavior change.

Co-authored-by: Isaac

* fix(opencode): recover native-server coverage + fix enqueue harness-id

The split removed tests/harness_conformance/, which had been the coverage for
the *kept* native-server runtime (native_server_harness.py +
opencode_http_transport.py), dropping total coverage below the CI gate. Add
focused, Front-E-free unit tests:
- tests/test_native_server_harness.py — drives the transport-agnostic base over
  an in-memory fake transport (run-turn boot-poll / model pin / error branches,
  interrupt, enqueue, capabilities).
- tests/test_opencode_http_transport.py — the prompt-payload builder + every
  transport method over an injected fake OpenCodeClient.

The base test caught a real regression from the descriptor de-coupling: the
enqueue-failure path still referenced the removed ``self.descriptor.id`` (an
AttributeError on that error branch) — now ``self._harness_id``.

Co-authored-by: Isaac

* feat(opencode): pick a default model from `omni setup`

`omni opencode` spawns `opencode serve` with a per-session XDG config (the
user's global ~/.config/opencode is intentionally ignored), so with no model
configured opencode falls back to its built-in default (opencode/big-pickle)
even after `opencode auth login` adds a provider. Add a way to choose the
launch model:

- `omni setup` → OpenCode → "Set default model": lists `opencode models`,
  persists the pick as the `opencode_model` global-config key (+ a Clear
  option). New helpers `_list_opencode_models` / `_set_opencode_default_model`.
- `omni opencode` (no --model) now prefers `opencode_model`, falling back to the
  shared `model` key for back-compat.
- Runner: write the resolved model into the per-session opencode.json at spawn
  (build_opencode_model_default_config) so the TUI and the first turn launch on
  it, not big-pickle — for both the user-provider and Databricks-gateway paths.
- Register `opencode_model` in `_GLOBAL_CONFIG_KEYS` so `omni config` accepts it.

Also registers the `opencode` command in `_CLICK_SUBCOMMANDS` (it was registered
on the CLI group but unreachable from main(), which failed
test_click_subcommands_allowlist_covers_registered_commands).

+ unit tests (provider helper, model picker persist/clear/cancel/empty).

Co-authored-by: Isaac

* test(opencode): cover the `omni opencode` launcher helpers

opencode_native.py (the `omni opencode` launcher) had no direct unit tests —
556 lines of spec-materialization, payload parsing, tmux-attach gating, and
httpx session/terminal helpers sitting uncovered (the biggest single coverage
sink in the harness, and part of why dropping the well-covered Front E modules
pushed total coverage under the gate).

Add tests/test_opencode_native.py covering the unit-testable surface over a
fake AsyncClient: `_materialize_opencode_agent_spec` (model on/off),
`_launched_opencode_terminal_from_payload`, `_direct_tmux_unavailable_reason`,
`_resolve_session_id_for_resume`, and the session/terminal helpers
(`_create_opencode_session`, `_fetch_opencode_session`,
`_ensure_opencode_terminal_on_runner`, `_find_running_opencode_terminal` incl.
404 / not-running / offline-runner branches). Launcher coverage 0% → 56%; the
daemon/tmux attach plumbing stays for the live host e2e.

Co-authored-by: Isaac

* test(opencode): smoke-test the opencode-native harness create_app/factory

inner/opencode_native_harness.py (the `harness: opencode-native` entry point)
was at 0% — add a create_app() FastAPI smoke test + an executor-factory test
(builds OpenCodeNativeExecutor from the spawn env). 0% -> 100%.

Co-authored-by: Isaac

* fix(opencode): seed user auth into the session server so the chosen model works

The runner spawns `opencode serve` with a per-session XDG_DATA_HOME (isolating
session state), which also hid the user's `opencode auth login` credentials
(~/.local/share/opencode/auth.json). Without them the server could only reach
OpenCode's no-auth default (opencode/big-pickle), so `omni opencode` ignored
the selected provider/model — even with the model pinned into opencode.json.

- bridge: `seed_opencode_auth()` copies the user's auth.json into the
  per-session XDG_DATA_HOME at spawn (0600, refreshed each launch); the runner
  calls it before `opencode serve` starts. No-op on a remote runner / the
  Databricks-gateway path (no local auth.json).
- setup: the "Set default model" picker listed every models.dev model
  (hundreds) — overflowing the menu viewport and flickering. Filter to models
  whose provider the user can authenticate (stored auth.json + env keys) via
  the new `reachable_provider_ids()`; fall back to the full list only if that
  filter would hide everything.

+ tests (auth-seed copy/no-op, reachable provider ids).

Co-authored-by: Isaac

* fix(setup): scrolling viewport for the OpenCode model picker (no more flicker)

The model picker still flickered when the reachable-provider model list was
longer than the terminal: select() rendered every row and redrew in place, so a
frame taller than the screen overflowed and flickered.

Add an opt-in scrolling viewport to select(max_visible=...): when set and the
list is longer, it renders only a window of rows that follows the cursor (with
"↑ N more" / "↓ N more" markers), bounding the frame to one screen. Default
(None) renders every row, so all other menus are unchanged. The OpenCode "Set
default model" picker sizes the viewport to the terminal height.

+ tests for the windowed vs full render.

Co-authored-by: Isaac

* test(opencode): raise coverage — test tractable gaps + pragma e2e-only orchestration

The split dropped Front E's well-covered code, dipping total coverage past the
code-coverage ratchet's 0.5% tolerance. Recover it honestly — real unit tests
for the testable surface, and `# pragma: no cover` only on integration-only
orchestration that the live host e2e exercises but unit tests can't.

Unit tests:
- launcher: _preflight_local_tools, _update_startup_progress,
  _direct_tmux_unavailable_reason (tmux-missing / all-present),
  _wait_for_opencode_terminal_ready (found / timeout).
- app-server: find_opencode_cli (absolute exe) + resolve_opencode_version
  (parse / run-error / unparseable).
- client: error + edge branches (non-object bodies, HTTP errors).
- forwarder: seed_dedupe_from_history (resume seeding + best-effort failure).

pragma (e2e-covered, not unit-testable — see tests/e2e/test_host_opencode_native_e2e.py):
- launcher daemon/tmux flow: run_opencode_native, _run_with_remote_server,
  _prepare_opencode_terminal_via_daemon, _attach_terminal_resource,
  _attach_direct_tmux, and the SDK resume picker.
- OpenCodeNativeServer.close().

Co-authored-by: Isaac

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-23 19:04:37 -07:00
487 changed files with 88314 additions and 6197 deletions
+210
View File
@@ -0,0 +1,210 @@
---
name: cli-setup-verify
description: Verify the Omnigent CLI's setup/onboarding flow, terminal UI/UX, and critical user journeys in a completely isolated, reproducible loop. Drives the real `omnigent` binary through a PTY (pexpect) inside a throwaway OMNIGENT_CONFIG_HOME / OMNIGENT_DATA_DIR sandbox that never touches the user's real ~/.omnigent, captures ANSI-stripped frames for UX inspection, and proves a change is verifiable via a before→fix→after baseline diff. Load when developing or reviewing a CLI setup/onboarding/REPL/picker change (omnigent/cli.py, omnigent/onboarding/*, omnigent/repl/*, scripts/install_oss.sh), reproducing a cold-start/first-run UX bug, or confirming a fix actually lands. Several agents can run it concurrently on separate worktrees.
---
# Verifying the Omnigent CLI setup & UX in a closed loop
The Omnigent CLI's first impression is: `curl | sh` → run `omnigent` → pick a
model credential → start a session. This skill lets an agent **enter that flow,
examine the UI/UX, and prove whether a change is verifiable** — without a
browser, without real credentials, and **without ever touching the developer's
real `~/.omnigent`**.
The engine is `verify_cli.py` (next to this file). It drives the real
`omnigent` binary through a pseudo-terminal (`pexpect`) inside a throwaway
sandbox, captures what renders, runs assertions, and prints one machine-readable
`SUMMARY {json}` line.
> **The whole point is a verifiable loop**, not a one-shot check:
> 1. Run a scenario on the **unfixed** code → baseline (`--label before`).
> 2. Make the change.
> 3. Run the **same** scenario → `--label after`.
> 4. Diff the two `SUMMARY` lines. A fix is "verifiable" only if a concrete
> check or note **flips** between the two runs. If it doesn't flip, you
> can't prove the fix did anything — go back to step 2.
## Why this is safe (read first)
The real `~/.omnigent` here can be **many GB** (chat DB, runner logs, native
harness state). The sandbox isolates every write three ways:
- **`HOME` is redirected into the sandbox by default.** This is the load-bearing
one. `OMNIGENT_CONFIG_HOME` / `OMNIGENT_DATA_DIR` (`omnigent/cli.py`
`_CONFIG_HOME_ENV_VAR` / `_DATA_DIR_ENV_VAR`) redirect config + data — but the
CLI's **diagnostics logger ignores them**: it writes a per-invocation
`cli-*.log` under `state_dir()`, hardcoded to `Path.home()/.omnigent/logs`
(`omnigent_ui_sdk/terminal/_config.py`). So only redirecting `HOME` keeps a
non-help command (`config list`, the setup PTY spawns, `server stop`) from
writing into the real home. The driver does this for you.
- `--strip-path` reduces `PATH` so `node`/`tmux`/`claude`/`codex` read as "not
installed" → the true fresh-machine cold start.
- Ambient model keys (`ANTHROPIC_API_KEY`, …) are stripped from the child env
unless you pass `--keep-env-creds`.
**`--inherit-home` opts out** of `HOME` isolation — use it only to reach a real
credentialed REPL via ambient `~/.claude` / `~/.databrickscfg` auth. It is
**less safe**: a non-help command then writes `cli-*.log` into the real
`~/.omnigent/logs`.
Every run **fingerprints the real `~/.omnigent` before and after** (stat-only,
no content reads): the top-level config files **and** the set of
`logs/cli-*.log` diagnostic files. A new config file/mtime *or* a new `cli-*.log`
basename trips `real_config_untouched: false`. With the default isolation that
never happens; under `--inherit-home` it correctly does — which is exactly the
violation the guard is meant to catch. If that check is ever `false`, stop and
investigate. Run `check-isolation` first to confirm the loop is safe on your
machine.
## Prerequisites
- You're in the **worktree whose code you want to test** (each parallel agent
on its own worktree). The driver runs `omnigent` from `--repo`'s checkout.
- A Python with `pexpect` — the project's `.venv/bin/python` bundles it
(`pexpect>=4.9` in `pyproject.toml`). Run the driver with that interpreter.
- An `omnigent` binary: the driver auto-finds `<repo>/.venv/bin/omnigent`, or
pass `--omnigent <path>`.
- The setup / picker / help / cold-start scenarios need **no credentials and no
harness**. Only `repl-commands` needs a working harness + credential: pass
`--inherit-home` (ambient `~/.claude` auth) and/or `--keep-env-creds` (env API
key) with `--agent`. It reports `skipped`, never a false pass, when the prompt
isn't reachable.
## Quick start
```bash
REPO=/path/to/your/worktree
PY=$REPO/.venv/bin/python
DRV=$REPO/.claude/skills/cli-setup-verify/verify_cli.py
# 0. Prove the sandbox is safe on this machine (do this once).
# HOME is isolated by default — no flag needed.
$PY $DRV --scenario check-isolation --repo "$REPO"
# 1. See exactly what a brand-new user sees on a fresh machine.
$PY $DRV --scenario cold-start --strip-path --keep-sandbox --repo "$REPO"
# → reads the printed `artifacts` path, then `cat <that>/cold_start.txt`
# 2. Lint the top-level help (and any subcommand's).
$PY $DRV --scenario help-snapshot --repo "$REPO"
$PY $DRV --scenario help-snapshot --subcommand server --repo "$REPO"
```
Each run prints `SUMMARY {…}` and exits non-zero if any check failed (a
`skipped` scenario exits 0). Pipe to `… | grep '^SUMMARY' | python -m json.tool`
to read it.
## Scenario catalog
| Scenario | What it drives | Key checks / notes | Maps to findings |
|---|---|---|---|
| `check-isolation` | `omnigent config list` in the sandbox (no PTY) | `config_list_ran`, `sandbox_config_home_used`, `real_config_untouched` | safety gate for everything |
| `cold-start` | `omnigent setup` via PTY on a simulated fresh machine | `onboarding_rendered`, `harness_menu_present`; note `guided_default_affordance` | cold-start dead-end; missing "recommended start here" |
| `setup-snapshot` | `omnigent setup`, optional `--nav-down N` arrow steps | `menu_rendered`; saves a frame per step | picker markers/footer/alignment; narrow-terminal at 80×24 |
| `help-snapshot` | `omnigent [--subcommand] --help` (no PTY) | `help_rendered`, `no_param_leak`, `no_update_dup`; note `top_level_command_count` | `:param` leak, duplicate `update`/`upgrade`, command sprawl |
| `repl-commands` | `omnigent run <agent>` REPL, sends `/help` + `/quit` | `help_lists_commands`; note `quit_advertised` | REPL discoverability (`/help`, `/quit`) |
`--list-scenarios` prints them too. Captured frames land in the printed
`artifacts` dir as both `<name>.txt` (ANSI-stripped, for reading/asserting) and
`<name>.ansi.txt` (raw, to see real colors with `less -R`).
## The verifiable loop — a worked example
Finding: *"`server --help` leaks Sphinx `:param`/`:returns` into user help."*
```bash
# BEFORE the fix (on the unfixed code):
$PY $DRV --scenario help-snapshot --subcommand server --label before --repo "$REPO"
# → "no_param_leak": {"ok": false, ...} ← bug reproduced (the baseline)
# ... make the change (move :param docs into # comments) ...
# AFTER the fix:
$PY $DRV --scenario help-snapshot --subcommand server --label after --repo "$REPO"
# → "no_param_leak": {"ok": true, "detail": "clean"} ← flipped → fix is verifiable
```
The same shape proves the `update`/`upgrade` duplicate (`no_update_dup`), the
cold-start dead-end (`guided_default_affordance` note flips `absent``present`),
or REPL `/quit` discoverability (`quit_advertised` note flips `no``yes`). **If
the check/note doesn't flip, the fix isn't proven** — that is the signal to keep
working, and it's exactly the judgment the loop exists to force.
If a finding has no machine check yet, add one (see "Adding a scenario") so the
fix becomes provable instead of asserted.
## Examining UI/UX deliberately
- **Narrow terminal is the default.** The driver uses **80×24** — the size a
new user's window actually is, and where banner overflow and picker
redraw-past-the-bottom bugs appear. Re-run with `--cols 120 --rows 40` to
compare the roomy layout; diff the two frames.
- **Read the frame, don't just trust the check.** `cat <artifacts>/cold_start.txt`
shows the literal screen — the all-`✗` menu, the footer hint (`Esc back` at
the root), the marker (``), alignment of the status gutter. The frame *is*
the UX evidence.
- **Compare pickers for consistency.** `setup-snapshot --nav-down 3` captures
the harness menu as you move; eyeball marker/footer/highlight drift against
the theme and resume pickers (different engines render differently).
## Covering all critical user journeys
This skill owns the **setup / onboarding / first-run / TUI** journeys. The repo
already has complementary CUJ coverage — use both:
- **Live setup/UX journeys → this skill's scenarios** (cold-start, setup,
pickers, help, REPL discoverability).
- **Deeper end-to-end journeys → `tests/e2e/test_journey_*.py`** (first session
to code, resume/disconnect, fork/explore, file upload, collaboration, …).
Run a slice with the project's gated runner, e.g.
`uv run --frozen --extra dev python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
- **Reusable PTY helpers** live in `tests/e2e/omnigent/_pexpect_harness.py`
(`spawn_omnigent_run`, `wait_for_ready`, `submit_prompt`, `await_turn_complete`,
`clean_exit`) and the snapshot comparator in `tests/e2e/omnigent/_snapshot.py`
— prefer extending those over re-inventing.
To drive a surface this skill doesn't script yet, spawn it by hand with the
sandbox env and the keys the driver exports (`KEY_UP`/`KEY_DOWN`/`KEY_ENTER`/
`KEY_ESC`), then `drain()` and `save_frame()` the result.
## Teardown — non-negotiable
- The driver force-kills the PTY child and its descendants, and runs
`omnigent server stop` against the sandbox to reap any spawned background
server. After a run, confirm nothing leaked:
`pgrep -af "omnigent.*(server|runner|host._daemon)"` — anything bound to your
sandbox's data dir is yours to kill.
- The sandbox temp dir is deleted unless `--keep-sandbox`. If you keep one for
inspection, `rm -rf` it when done.
- Always drive the CLI through the driver (which redirects `HOME` + the
config/data knobs), never a bare `omnigent setup` — that would write to the
real `~/.omnigent`. If you pass `--inherit-home`, expect `cli-*.log` writes to
the real `~/.omnigent/logs` and a `real_config_untouched: false` — that's the
guard working, not a bug.
## Honesty
If you can't reach the surface under test (no harness, no credential, headless
limit), the scenario must report `skipped`**do not claim a CUJ passed**. The
strongest evidence for a fix is a reproduced baseline (`before`) plus the flipped
`after`; report both `SUMMARY` lines, not a summary of a summary.
## Adding a scenario
Write `scenario_<name>(args, sandbox, result)` in `verify_cli.py`: drive the CLI
(reuse `pexpect.spawn(... env=sandbox.env, dimensions=(args.rows, args.cols))`,
`drain()`, `save_frame()`, the `KEY_*` constants), record findings with
`result.add(name, ok, detail)` (fails the run) or `result.notes.append(...)`
(informational, for before/after flips), register it in `SCENARIOS`, and add a
row to the catalog above. Keep one assertion per real, observable behavior so a
fix is provable as a single check flip.
## Code under test
- First-run dispatch / no-arg routing: `omnigent/cli.py` (`run`, the first-run
plan, `_run_configure_harnesses_interactive`).
- Onboarding: `omnigent/onboarding/*` (`setup.py`, `interactive.py`,
`configure_models.py`, `provider_selection.py`, `detected.py`).
- TUI / REPL & pickers: `omnigent/repl/*` (`_repl.py`, `_theme_picker.py`,
`_resume_picker.py`), `omnigent/_terminal_picker_theme.py`.
- Installer: `scripts/install_oss.sh`.
+715
View File
@@ -0,0 +1,715 @@
#!/usr/bin/env python3
"""Drive the Omnigent CLI through a PTY in a throwaway sandbox and verify it.
This is the reusable engine behind the ``cli-setup-verify`` skill (see
``SKILL.md`` next to this file for the playbook and CUJ catalog). One run:
1. Builds an **isolated config/data sandbox** so nothing the CLI writes ever
lands in the real ``~/.omnigent`` — it sets the purpose-built
``OMNIGENT_CONFIG_HOME`` / ``OMNIGENT_DATA_DIR`` knobs (``omnigent/cli.py``
``_CONFIG_HOME_ENV_VAR`` / ``_DATA_DIR_ENV_VAR``), strips leaked model
credentials from the child env, and (optionally) points ``HOME`` and a
minimal ``PATH`` at the sandbox to simulate a brand-new machine.
2. Drives the real ``omnigent`` binary through ``pexpect`` (a real PTY with a
sane ``TERM`` so prompt-toolkit / the raw-termios pickers actually render).
3. Captures ANSI-stripped frames into an artifacts dir for UX inspection.
4. Runs the named scenario's assertions and prints a single machine-readable
``SUMMARY {json}`` line; exits non-zero on failure.
5. Proves it left the real ``~/.omnigent`` byte-for-byte unchanged.
The point is a **verifiable loop**: run a scenario on the *unfixed* code
(``--label before``) to capture the baseline, make the change, run the same
scenario again (``--label after``), and diff the two SUMMARY lines. If you
cannot reach the surface under test (missing harness, no credential), the
scenario reports ``skipped`` — never a false ``pass``.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import mkdtemp
try:
import pexpect
except ImportError: # pragma: no cover - guidance, not logic
sys.stderr.write(
"verify_cli.py needs `pexpect`. Run it with the omnigent project's "
"venv python (it bundles pexpect), e.g.\n"
" <repo>/.venv/bin/python verify_cli.py ...\n"
)
raise
# --- PTY constants (mirrors tests/e2e/omnigent/_pexpect_harness.py) ---------
# prompt-toolkit refuses to draw on TERM=dumb; this is what the REPL tests use.
TERM = "xterm-256color"
# 80x24 is the default new-user window — exactly where narrow-terminal bugs
# (banner overflow, picker redraw past the bottom row) show up. Override with
# --cols/--rows to also exercise the roomy 120x40 layout.
DEFAULT_COLS = 80
DEFAULT_ROWS = 24
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
# Stable onboarding anchors (omnigent/cli.py:520, :10064, :10300).
ANCHOR_SEARCHING = "Searching for existing credentials"
ANCHOR_CONFIGURE = "Configure harnesses"
ANCHOR_NO_HARNESS = "Found no harnesses configured"
# REPL readiness signals (the toolbar state line, with the input prompt as a
# fallback for PTY combos that suppress the bottom toolbar).
REPL_READY = [r"state: sleeping", r" "]
# Keys for driving the raw-termios + prompt-toolkit pickers.
KEY_UP = "\x1b[A"
KEY_DOWN = "\x1b[B"
KEY_ENTER = "\r"
KEY_ESC = "\x1b"
# Model-provider credentials we strip from the child env so a "cold" sandbox
# is genuinely credential-free (the CLI auto-adopts ambient keys otherwise).
LEAKED_CRED_VARS = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY",
"CLAUDE_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"CURSOR_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"DATABRICKS_TOKEN",
"DATABRICKS_HOST",
"DATABRICKS_CONFIG_PROFILE",
)
def strip_ansi(text: str) -> str:
"""Remove ANSI control sequences so frames can be asserted as plain text."""
return ANSI_RE.sub("", text)
# --- sandbox ----------------------------------------------------------------
@dataclass
class Sandbox:
"""A throwaway config/data/home for one verification run.
:param root: Temp directory holding ``config/``, ``data/`` and (unless
``--inherit-home``) ``home/``. Removed on cleanup unless ``--keep-sandbox``.
:param env: The child-process environment with the isolation knobs set.
:param home_isolated: Whether ``HOME`` was redirected into the sandbox.
"""
root: Path
env: dict[str, str]
home_isolated: bool
def build_sandbox(
*,
keep_env_creds: bool,
inherit_home: bool,
strip_path: bool,
omnigent_bin: Path,
) -> Sandbox:
"""Create an isolated sandbox env that cannot touch the real ``~/.omnigent``.
``HOME`` is redirected into the sandbox **by default**. This is load-bearing,
not cosmetic: the CLI's diagnostics logger writes a per-invocation
``cli-*.log`` under ``state_dir()`` which is hardcoded to ``Path.home() /
".omnigent"`` (``omnigent_ui_sdk/terminal/_config.py``) and ignores
``OMNIGENT_CONFIG_HOME`` / ``OMNIGENT_DATA_DIR``. So redirecting ``HOME`` is
the *only* thing that keeps non-help commands (``config list``, the setup
PTY spawns, ``server stop`` teardown) from writing into the real home.
:param keep_env_creds: Keep ambient model keys (e.g. ``ANTHROPIC_API_KEY``)
in the child env. Default False → a genuinely cold, credential-free run.
:param inherit_home: Opt OUT of home isolation — use the real ``HOME`` (and
thus its ambient ``~/.claude`` / ``~/.databrickscfg`` auth). Needed to
reach a real credentialed REPL, but **relaxes the safety guarantee**:
non-help commands will then write ``cli-*.log`` into the real
``~/.omnigent/logs`` (the broadened fingerprint catches this).
:param strip_path: Reduce ``PATH`` to just the omnigent binary's dir + an
empty dir, so node/npm/tmux/claude/codex read as "not installed" — i.e.
a brand-new machine.
:param omnigent_bin: Path to the ``omnigent`` console script being driven.
:returns: A :class:`Sandbox`.
"""
root = Path(mkdtemp(prefix="omnigent-verify-"))
(root / "config").mkdir()
(root / "data").mkdir()
env = dict(os.environ)
if not keep_env_creds:
for var in LEAKED_CRED_VARS:
env.pop(var, None)
env["OMNIGENT_CONFIG_HOME"] = str(root / "config")
env["OMNIGENT_DATA_DIR"] = str(root / "data")
env["OMNIGENT_NO_UPDATE_CHECK"] = "1" # keep the update nag out of frames
env["TERM"] = TERM
env["COLUMNS"] = str(DEFAULT_COLS)
env["LINES"] = str(DEFAULT_ROWS)
if not inherit_home:
home = root / "home"
home.mkdir()
env["HOME"] = str(home)
if strip_path:
empty = root / "emptybin"
empty.mkdir()
env["PATH"] = f"{omnigent_bin.parent}:{empty}"
return Sandbox(root=root, env=env, home_isolated=not inherit_home)
def fingerprint_real_config() -> dict[str, str]:
"""Fingerprint the real ``~/.omnigent`` so we can prove we never wrote to it.
Stat-only (size + mtime, no content reads). It captures two things, both
cheap:
* the top-level config files (``*.yaml`` / ``*.json`` / ``*.toml`` plus the
known names) — what onboarding writes; and
* the set of ``logs/cli-*.log`` diagnostic files — what *any* non-help CLI
invocation writes via the hardcoded ``Path.home()/.omnigent`` state dir.
A new ``cli-*.log`` basename after the run means we wrote into the real
home (the precise violation that slips through ``OMNIGENT_CONFIG_HOME`` /
``OMNIGENT_DATA_DIR``). With home isolation on (the default) none appear;
under ``--inherit-home`` they do — and this is what trips the guard.
It deliberately does **not** read the multi-GB ``logs/*.log`` bodies,
``db-backups/`` or native-state dirs (reading them would hang, and other
running omnigent daemons churn them → false alarms). The single ``logs/``
glob is bounded by the diagnostics log cap.
:returns: Mapping of relative path → ``"<size>:<mtime_ns>"`` (config files)
or ``"<mtime_ns>"`` (cli logs). Empty if the directory does not exist.
"""
base = Path.home() / ".omnigent"
out: dict[str, str] = {}
if not base.exists():
return out
candidates: set[Path] = set()
for pattern in ("*.yaml", "*.yml", "*.json", "*.toml"):
candidates.update(base.glob(pattern))
for name in ("config.yaml", "secrets.json", "auth_tokens.json", "providers.yaml"):
candidates.add(base / name)
for p in sorted(candidates):
if p.is_file():
st = p.stat()
out[p.name] = f"{st.st_size}:{st.st_mtime_ns}"
logs = base / "logs"
if logs.is_dir():
for p in sorted(logs.glob("cli-*.log")):
with contextlib.suppress(OSError):
out[f"logs/{p.name}"] = str(p.stat().st_mtime_ns)
return out
# --- result model -----------------------------------------------------------
@dataclass
class Check:
name: str
ok: bool
detail: str = ""
@dataclass
class Result:
scenario: str
label: str
status: str = "pass" # pass | fail | skipped
checks: list[Check] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
artifacts: list[str] = field(default_factory=list)
def add(self, name: str, ok: bool, detail: str = "") -> None:
self.checks.append(Check(name, ok, detail))
if not ok and self.status == "pass":
self.status = "fail"
def skip(self, reason: str) -> None:
self.status = "skipped"
self.notes.append(reason)
def to_dict(self) -> dict[str, object]:
return {
"scenario": self.scenario,
"label": self.label,
"status": self.status,
"checks": [{"name": c.name, "ok": c.ok, "detail": c.detail} for c in self.checks],
"notes": self.notes,
"artifacts": self.artifacts,
}
# --- frame capture ----------------------------------------------------------
def drain(child: pexpect.spawn, *, seconds: float) -> str:
"""Read everything the child renders for ``seconds`` and return it raw.
Used to capture a settled screen (a menu, a help body) without depending on
a specific completion marker.
"""
buf: list[str] = []
deadline = time.time() + seconds
while time.time() < deadline:
try:
chunk = child.read_nonblocking(size=4096, timeout=0.3)
except pexpect.TIMEOUT:
continue
except pexpect.EOF:
break
if chunk:
buf.append(chunk)
return "".join(buf)
def save_frame(result: Result, artifacts: Path, name: str, raw: str) -> None:
"""Persist a raw + ANSI-stripped frame and register it on the result."""
artifacts.mkdir(parents=True, exist_ok=True)
stripped = strip_ansi(raw)
(artifacts / f"{name}.ansi.txt").write_text(raw, encoding="utf-8")
(artifacts / f"{name}.txt").write_text(stripped, encoding="utf-8")
result.artifacts.append(str(artifacts / f"{name}.txt"))
# --- scenarios --------------------------------------------------------------
def scenario_check_isolation(args, sandbox: Sandbox, result: Result) -> None:
"""Smoke-test the sandbox: a read-only CLI call must not touch real config.
Runs ``omnigent config list`` inside the sandbox (no PTY needed) and
asserts (a) it executed, (b) the sandbox config home is now used, (c) the
real ``~/.omnigent`` fingerprint is unchanged. This is the first thing to
run to trust every other scenario.
"""
proc = subprocess.run(
[str(args.omnigent), "config", "list"],
env=sandbox.env,
cwd=str(args.repo),
capture_output=True,
text=True,
timeout=args.timeout,
)
save_frame(result, Path(args.artifacts), "config_list", proc.stdout + proc.stderr)
result.add("config_list_ran", proc.returncode == 0, f"exit={proc.returncode}")
# The sandbox config home should exist; the real one is checked globally in
# main() via the before/after fingerprint.
result.add(
"sandbox_config_home_used",
Path(sandbox.env["OMNIGENT_CONFIG_HOME"]).exists(),
sandbox.env["OMNIGENT_CONFIG_HOME"],
)
def scenario_cold_start(args, sandbox: Sandbox, result: Result) -> None:
"""Spawn the first-time setup surface a brand-new user sees and capture it.
Home isolation is on by default (so this is already a fresh machine for
credentials); add ``--strip-path`` to also make node/tmux/claude read as
not installed. Asserts the onboarding surface renders (the credential search
banner or the ``Configure harnesses`` menu), saves the frame for UX review,
then aborts cleanly.
"""
child = pexpect.spawn(
str(args.omnigent),
["setup"],
env=sandbox.env,
cwd=str(args.repo),
encoding="utf-8",
timeout=args.timeout,
dimensions=(args.rows, args.cols),
)
try:
idx = child.expect(
[ANCHOR_CONFIGURE, ANCHOR_SEARCHING, ANCHOR_NO_HARNESS, pexpect.EOF],
timeout=args.timeout,
)
except pexpect.TIMEOUT:
save_frame(result, Path(args.artifacts), "cold_start_timeout", child.before or "")
result.add("onboarding_rendered", False, "no onboarding anchor within timeout")
_kill_tree(child)
return
pre = child.before or ""
# Let the menu settle so the captured frame holds the whole harness list.
settle = drain(child, seconds=2.0)
frame = pre + (child.after or "") + settle
save_frame(result, Path(args.artifacts), "cold_start", frame)
result.add("onboarding_rendered", idx in (0, 1, 2), f"anchor_index={idx}")
stripped = strip_ansi(frame)
menu_present = ANCHOR_CONFIGURE in stripped
result.add(
"harness_menu_present",
menu_present,
"'Configure harnesses' title shown" if menu_present else "menu title missing",
)
# Informational UX probe (does NOT fail the run): is there any guided
# "recommended / start here" affordance, or just a wall of options? This is
# the cold-start dead-end finding — a fix should flip this note.
has_recommendation = bool(
re.search(r"recommend|start here|new here|get started", stripped, re.I)
)
result.notes.append(
f"guided_default_affordance={'present' if has_recommendation else 'absent'}"
)
_abort_picker(child)
_kill_tree(child)
def scenario_setup_snapshot(args, sandbox: Sandbox, result: Result) -> None:
"""Capture the setup menu, then optionally arrow-navigate and snapshot each
frame, for picker UX review (markers, footer hints, alignment, width).
Use ``--nav-down N`` to step down N rows capturing a frame each time.
"""
child = pexpect.spawn(
str(args.omnigent),
["setup"],
env=sandbox.env,
cwd=str(args.repo),
encoding="utf-8",
timeout=args.timeout,
dimensions=(args.rows, args.cols),
)
try:
child.expect([ANCHOR_CONFIGURE, ANCHOR_SEARCHING], timeout=args.timeout)
except pexpect.TIMEOUT:
result.add("menu_rendered", False, "setup menu did not render")
_kill_tree(child)
return
frame = (child.before or "") + (child.after or "") + drain(child, seconds=1.5)
save_frame(result, Path(args.artifacts), "setup_menu_0", frame)
result.add("menu_rendered", ANCHOR_CONFIGURE in strip_ansi(frame))
for i in range(1, args.nav_down + 1):
child.send(KEY_DOWN)
frame = drain(child, seconds=1.0)
save_frame(result, Path(args.artifacts), f"setup_menu_{i}", frame)
_abort_picker(child)
_kill_tree(child)
def scenario_help_snapshot(args, sandbox: Sandbox, result: Result) -> None:
"""Render ``omnigent [SUBCOMMAND] --help`` and lint it for known UX issues.
No PTY needed. The lint checks map directly to top-20 findings, so a fix is
verifiable as a before/after flip:
* ``no_param_leak`` — no ``:param``/``:returns`` Sphinx dump (finding X3)
* ``no_update_dup`` — top-level help doesn't list both update & upgrade (X2)
Use ``--subcommand server`` (etc.) to lint a specific command's help.
"""
cmd = [str(args.omnigent)]
if args.subcommand:
cmd.append(args.subcommand)
cmd.append("--help")
proc = subprocess.run(
cmd,
env={**sandbox.env, "COLUMNS": str(args.cols)},
cwd=str(args.repo),
capture_output=True,
text=True,
timeout=args.timeout,
)
out = proc.stdout + proc.stderr
label = args.subcommand or "root"
save_frame(result, Path(args.artifacts), f"help_{label}", out)
result.add(
"help_rendered",
proc.returncode == 0 and "Usage:" in out,
f"exit={proc.returncode}",
)
param_leak = ":param" in out or ":returns:" in out
result.add(
"no_param_leak",
not param_leak,
"Sphinx :param/:returns leaked into --help" if param_leak else "clean",
)
if not args.subcommand:
both = "\n update" in out and "\n upgrade" in out
result.add(
"no_update_dup",
not both,
"both `update` and `upgrade` listed (duplicate)"
if both
else "single canonical upgrade",
)
cmd_count = len(re.findall(r"^ [a-z][\w-]+\s{2,}", out, re.M))
result.notes.append(f"top_level_command_count={cmd_count}")
def scenario_repl_commands(args, sandbox: Sandbox, result: Result) -> None:
"""Boot the REPL and check command discoverability.
Asserts the ``/help`` command list renders; separately records whether
``/quit`` is advertised (the ``quit_advertised`` note — finding U2).
Requires a working harness + credential to reach the prompt: pass
``--inherit-home`` (for ambient ``~/.claude`` auth) and/or
``--keep-env-creds`` (for an env API key) plus an ``--agent``/``--harness``.
If the prompt is not reachable the scenario reports ``skipped`` (never a
false pass).
"""
if not args.agent:
result.skip("repl-commands needs --agent <dir/yaml> (and a working harness/credential)")
return
spawn_args = ["run", args.agent, "--harness", args.harness]
if args.model:
spawn_args += ["--model", args.model]
child = pexpect.spawn(
str(args.omnigent),
spawn_args,
env=sandbox.env,
cwd=str(args.repo),
encoding="utf-8",
timeout=args.timeout,
dimensions=(args.rows, args.cols),
)
try:
child.expect(REPL_READY, timeout=args.timeout)
except (pexpect.TIMEOUT, pexpect.EOF):
save_frame(result, Path(args.artifacts), "repl_boot_fail", child.before or "")
result.skip("REPL prompt not reachable (missing harness/credential?) — see repl_boot_fail")
_kill_tree(child)
return
child.send("/help")
child.send(KEY_ENTER)
frame = drain(child, seconds=2.5)
save_frame(result, Path(args.artifacts), "repl_help", frame)
stripped = strip_ansi(frame)
# The /help command list rendered (the `/help` row is always present). Note
# `/quit` discoverability separately — finding U2 is that it is NOT
# advertised, so a fix flips quit_advertised no→yes.
result.add("help_lists_commands", "/help" in stripped, "/help output")
result.notes.append(
f"quit_advertised={'yes' if '/quit' in stripped else 'no'}" # discoverability finding U2
)
child.send("/quit")
child.send(KEY_ENTER)
with contextlib.suppress(Exception):
child.expect(pexpect.EOF, timeout=10)
_kill_tree(child)
SCENARIOS = {
"check-isolation": scenario_check_isolation,
"cold-start": scenario_cold_start,
"setup-snapshot": scenario_setup_snapshot,
"help-snapshot": scenario_help_snapshot,
"repl-commands": scenario_repl_commands,
}
# --- teardown helpers -------------------------------------------------------
def _abort_picker(child: pexpect.spawn) -> None:
"""Send the menu's abort gestures (q, then Esc) so it exits cleanly."""
with contextlib.suppress(Exception):
child.send("q")
time.sleep(0.2)
child.send(KEY_ESC)
time.sleep(0.2)
def _descendant_pids(root_pid: int) -> list[int]:
"""Collect the full descendant tree of ``root_pid`` via repeated ``pgrep -P``.
Walks children, grandchildren, etc. — a spawned server/runner can re-parent
its own children, so a single ``pgrep -P`` only reaches one level.
"""
found: list[int] = []
frontier = [root_pid]
seen = {root_pid}
while frontier:
parent = frontier.pop()
with contextlib.suppress(Exception):
out = subprocess.run(
["pgrep", "-P", str(parent)], capture_output=True, text=True
).stdout
for tok in out.split():
with contextlib.suppress(ValueError):
pid = int(tok)
if pid not in seen:
seen.add(pid)
found.append(pid)
frontier.append(pid)
return found
def _kill_tree(child: pexpect.spawn) -> None:
"""Force-kill the child and its whole descendant tree; never raise."""
pid = child.pid
# Snapshot descendants BEFORE close() — closing the PTY can reparent them to
# init, after which pgrep -P can no longer find them via the child.
descendants = _descendant_pids(pid) if pid else []
with contextlib.suppress(Exception):
child.close(force=True)
for dpid in descendants:
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(dpid, signal.SIGKILL)
def stop_sandbox_server(args, sandbox: Sandbox) -> None:
"""Best-effort: stop any background server bound to the sandbox data dir."""
with contextlib.suppress(Exception):
subprocess.run(
[str(args.omnigent), "server", "stop"],
env=sandbox.env,
cwd=str(args.repo),
capture_output=True,
text=True,
timeout=30,
)
# --- main -------------------------------------------------------------------
def resolve_omnigent(repo: Path, explicit: str | None) -> Path:
"""Find the ``omnigent`` console script to drive."""
if explicit:
return Path(explicit).resolve()
venv = repo / ".venv" / "bin" / "omnigent"
if venv.exists():
return venv.resolve()
found = shutil.which("omnigent")
if found:
return Path(found).resolve()
sys.exit("Could not find an `omnigent` binary; pass --omnigent <path>.")
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--scenario", choices=sorted(SCENARIOS), help="Scenario to run.")
p.add_argument("--list-scenarios", action="store_true", help="List scenarios and exit.")
p.add_argument(
"--repo",
default=os.getcwd(),
type=lambda s: Path(s).resolve(),
help="Repo root (child cwd).",
)
p.add_argument(
"--omnigent",
help="Path to the omnigent binary (default: <repo>/.venv/bin/omnigent or PATH).",
)
p.add_argument(
"--label",
default="run",
help="Label for this run, e.g. before/after, in the SUMMARY line.",
)
p.add_argument("--artifacts", help="Dir for captured frames (default: <sandbox>/artifacts).")
p.add_argument("--cols", type=int, default=DEFAULT_COLS, help="PTY columns (default 80).")
p.add_argument("--rows", type=int, default=DEFAULT_ROWS, help="PTY rows (default 24).")
p.add_argument("--timeout", type=float, default=60.0, help="Per-expect timeout seconds.")
p.add_argument(
"--inherit-home",
action="store_true",
help="Opt out of HOME isolation (use real HOME + ambient auth). "
"Less safe: non-help commands then write cli-*.log into the real "
"~/.omnigent/logs. Use only to reach a real credentialed REPL.",
)
p.add_argument(
"--strip-path",
action="store_true",
help="Minimal PATH so node/tmux/claude read as not installed.",
)
p.add_argument(
"--keep-env-creds",
action="store_true",
help="Keep ambient model API keys in the child env.",
)
p.add_argument(
"--keep-sandbox",
action="store_true",
help="Do not delete the sandbox (for inspection).",
)
p.add_argument(
"--nav-down",
type=int,
default=0,
help="(setup-snapshot) arrow-down N times, capturing each frame.",
)
p.add_argument(
"--subcommand",
help="(help-snapshot) subcommand to lint, e.g. server. Omit for top-level.",
)
p.add_argument("--agent", help="(repl-commands) agent dir/yaml to run.")
p.add_argument("--harness", default="claude-sdk", help="(repl-commands) harness.")
p.add_argument("--model", help="(repl-commands) model override.")
return p.parse_args(argv)
def main(argv: Sequence[str]) -> int:
args = parse_args(argv)
if args.list_scenarios:
for name, fn in sorted(SCENARIOS.items()):
print(f"{name:16} {(fn.__doc__ or '').strip().splitlines()[0]}")
return 0
if not args.scenario:
sys.exit("Pass --scenario <name> (or --list-scenarios).")
args.omnigent = resolve_omnigent(args.repo, args.omnigent)
sandbox = build_sandbox(
keep_env_creds=args.keep_env_creds,
inherit_home=args.inherit_home,
strip_path=args.strip_path,
omnigent_bin=args.omnigent,
)
if not args.artifacts:
args.artifacts = str(sandbox.root / "artifacts")
before = fingerprint_real_config()
result = Result(scenario=args.scenario, label=args.label)
try:
SCENARIOS[args.scenario](args, sandbox, result)
except Exception as exc: # noqa: BLE001 - report any scenario error as a failed check, never crash the loop
result.add("scenario_exception", False, f"{type(exc).__name__}: {exc}")
finally:
stop_sandbox_server(args, sandbox)
after = fingerprint_real_config()
untouched = before == after
result.add(
"real_config_untouched",
untouched,
"~/.omnigent unchanged" if untouched else "REAL CONFIG MUTATED — investigate",
)
if not args.keep_sandbox:
shutil.rmtree(sandbox.root, ignore_errors=True)
else:
result.notes.append(f"sandbox_kept={sandbox.root}")
print("SUMMARY " + json.dumps(result.to_dict()))
return 0 if result.status in ("pass", "skipped") else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+214
View File
@@ -0,0 +1,214 @@
---
name: copilot-sdk-e2e-dev
description: Spin up a live local Omnigent server and exercise the GitHub Copilot SDK harness end-to-end — build copilot agents, run real turns, smoke-test, and bug-bash. Load when developing, testing, or debugging the copilot harness (omnigent/inner/copilot_executor.py, copilot_harness.py, omnigent/onboarding/copilot_auth.py) or its auth / model / tool-bridge behavior.
---
# Copilot SDK harness: end-to-end dev & testing
The `copilot` harness drives the **GitHub Copilot SDK** (`github-copilot-sdk`,
imported as `copilot`) — a persistent `CopilotClient` + `CopilotSession` per
Omnigent conversation — and bridges Omnigent's `sys_*` tools into Copilot as SDK
`Tool`s. The Python SDK **bundles the Copilot CLI binary it drives** as a backing
server, so there is no separate `@github/copilot` install. This skill is the
proven recipe for running it **for real** against a live local server — not just
the unit tests.
> The harness runs as a **local runner** from your current checkout, so
> `omni run <bundle> --server <url>` exercises exactly the code you're on.
## Prerequisites (check these first)
1. **You're on the branch you want to test.** The copilot harness is an
optional extra — install it (without disturbing other extras) with
`uv sync --frozen --extra dev --extra copilot`. NB: a bare
`uv run --frozen --extra dev` re-syncs the venv and **prunes** the copilot
SDK; for live testing call `.venv/bin/omni` / `.venv/bin/python` directly and
avoid `uv run` mid-session.
2. **The SDK is installed:**
`.venv/bin/python -c "import copilot; print(copilot.__file__)"`.
3. **A GitHub token with Copilot access is configured.** Copilot needs a
fine-grained PAT with the "Copilot Requests" permission, or an OAuth token
from the GitHub CLI / Copilot CLI app (classic `ghp_` PATs are rejected).
Verify (booleans only — never print the token):
```bash
.venv/bin/python -c "from omnigent.onboarding.copilot_auth import copilot_github_token_configured; import os; print('config:', copilot_github_token_configured(), 'env:', bool(os.environ.get('GH_TOKEN') or os.environ.get('COPILOT_GITHUB_TOKEN')))"
```
If both are `False`, run `omni setup` and register a Copilot token, or
`export GH_TOKEN=$(gh auth token)` (when `gh` is logged into an account with
Copilot). Check the account's entitlement with
`gh api /copilot_internal/user` (look for `chat_enabled`/`cli_enabled`).
4. **Network egress to GitHub's Copilot backend.** A turn that hangs or fails to
connect on a locked-down host is usually egress, not a harness bug.
## Step 1 — start a local server
```bash
cd /path/to/omnigent
.venv/bin/omni server --port 7788 --no-open # foreground; or `omni server start` for detached
curl -s http://127.0.0.1:7788/health # {"status":"ok"}
```
Use the URL below as `$SERVER`.
## Step 2 — build a copilot agent bundle
A spec with `spec_version` **must be a directory containing `config.yaml`** —
not a single `.yaml` file. Minimal copilot agent:
```bash
mkdir -p /tmp/copilot-dev
cat > /tmp/copilot-dev/config.yaml <<'YAML'
spec_version: 1
name: copilot-dev
description: Copilot SDK dev/test agent.
executor:
type: omnigent
config:
harness: copilot
# model: gpt-5-mini # optional; omit for Copilot auto-select
prompt: |
You are a terse test agent. Answer in as few words as possible.
YAML
```
For sub-agents, tools, guardrails/policies, copy the field shapes from
`examples/polly/config.yaml` and `examples/debby/config.yaml`. (Declare policies
under `guardrails.policies:` — a top-level `policies:` key is silently dropped on
the `spec_version` + `config.yaml` path.)
## Step 3 — run a turn (and smoke-test)
```bash
SERVER=http://127.0.0.1:7788
timeout 280 .venv/bin/omni run /tmp/copilot-dev \
-p "Reply with exactly the single word: PONG" \
--server "$SERVER" 2>&1
```
A healthy run prints connection lines then the reply (`PONG`). If that works,
the full stack is good: token, egress, bundled CLI, harness.
- **Shell / file tools:** add `--tools coding`.
- **Specific model:** add `--model gpt-5-mini` (or `claude-haiku-4.5`, `auto`).
## Targeted scenarios
| Goal | How |
|------|-----|
| Native tools (shell/edit/read) | `--tools coding`, prompt to create→read→edit a file; confirm it actually touches disk |
| Bridged `sys_*` / sub-agent dispatch | declare a sub-agent (harness `copilot` so auth is satisfied), prompt the parent to delegate — exercises the SDK `Tool` async-handler bridge into `_tool_executor` |
| Model routing | run the same bundle with several `--model` values; an unknown id fails **loud**, a `databricks-*` id is dropped to auto with a warning |
| LLM-phase policy | add a guardrail that denies a keyword; confirm `PHASE_LLM_REQUEST`/`PHASE_LLM_RESPONSE` blocks it |
| Concurrency / leaks | fire several `omni run … &` at once; then `pgrep -af "copilot/bin/copilot"` to check for orphaned bundled-CLI subprocesses |
## Running polly (or any orchestrator) on a copilot brain
The copilot harness can serve as an **async orchestrator** brain (polly / debby),
not just a standalone agent — it dispatches to sub-agents via the bridged
`sys_*` tools and synthesizes their results. Two ways to exercise it:
**1. Committed regression guard (brain smoke).**
`tests/e2e/test_polly_copilot_e2e.py` boots a local server from your checkout and
runs `examples/polly` with `--harness copilot --model auto`, asserting the brain
boots and replies. It is **skipped** unless a Copilot token is configured (so CI
without one skips it). Run it with:
```bash
.venv/bin/python -m pytest -o addopts="" tests/e2e/test_polly_copilot_e2e.py -v
```
**2. Full orchestration (dispatch → collect → synthesize).** Use the
`polly-e2e-dev` driver (in the internal `agent-framework` clone) — it boots a
local server, polls the AP API, auto-answers elicitations, and asserts the
fan-out. Drive the brain on copilot with `--brain-harness copilot`, and **always
pass a Copilot-catalog `--brain-model`** (`auto`, `claude-haiku-4.5`,
`gpt-5-mini`): the driver's default `--brain-model` is a Claude id that Copilot
(no Databricks gateway) can't route. From the agent-framework clone:
```bash
.venv/bin/python .claude/skills/polly-e2e-dev/polly_driver.py \
--local --code-dir <this-worktree> \
--cuj smoke --brain-harness copilot --brain-model auto # brain only
# --cuj fanout … and --cuj review-pr --repo omnigent-ai/omnigent --pr <n> …
# exercise real sub-agent dispatch (claude_code + codex) under a copilot brain.
```
All three CUJs (smoke / fanout / review-pr) pass on a copilot brain (verified
live: fanout dispatched 8 sub-agents, 8/8 OK + a synthesis). Note `omni run -p`
exits after the dispatch turn (the brain parks until woken), so a sub-agent's
final answer lands server-side — read it over the AP API
(`GET /v1/sessions/{id}/items`, child sessions), not just stdout.
## Gotchas (these cost real time)
1. **`config.yaml`'s `server:` defaults to a *remote* server.** Omitting
`--server` sends your turn to that remote deploy — which may be **stale** and
reject the copilot harness with `executor.config.harness: must be one of […]`.
**Always pass `--server http://127.0.0.1:<port>`.** (If a *local* server
rejects `copilot`, it's running stale code — restart it from your checkout.)
2. **A spec with `spec_version` must be a directory + `config.yaml`**, never a
single `.yaml` file.
3. **Copilot needs a GitHub token** (fine-grained PAT w/ Copilot Requests, or a
gh/Copilot-CLI OAuth token). Resolution precedence: spec `executor.auth`
(api_key) > stored `copilot:` config block (`omni setup`) > ambient
`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`. Classic `ghp_` rejected.
4. **No Databricks gateway.** Copilot talks only to GitHub's backend, so a
`databricks-*` model is silently resolved to Copilot's auto-select — it will
*not* route through the AI Gateway like claude-sdk/codex/pi.
5. **Use a model id from the account's catalog.** free_limited offers `auto`,
`claude-haiku-4.5`, `gpt-5-mini`. Run `.venv/bin/python` + `client.list_models()`
to discover the live set; an unknown id fails loud (server-side failed session).
6. **Turns take 3090s** — always wrap in `timeout 280`.
7. **Never print/echo the GitHub token** in logs or commands.
## Code & tests
- **Executor (SDK bridge):** `omnigent/inner/copilot_executor.py`
- **Wrap (HARNESS_COPILOT_* env → executor):** `omnigent/inner/copilot_harness.py`
- **Auth / token resolution:** `omnigent/onboarding/copilot_auth.py`
- **Spawn env:** `_build_copilot_spawn_env` in `omnigent/runtime/workflow.py`
```bash
uv run --frozen --extra dev python -m pytest \
tests/inner/test_copilot_executor.py \
tests/inner/test_copilot_harness.py \
tests/runtime/test_copilot_spawn_env.py \
tests/onboarding/test_copilot_auth.py -q
```
## Bug-bash (fan out)
To stress the harness, run several scenario probes in parallel — each builds a
bundle and runs real turns against the same `$SERVER`, then reports what broke.
Highest-value targets: the `Tool` async-handler bridge (hangs / lost tool
results / errors reported as success), model routing, policy enforcement,
streamed-output rendering, and orphaned bundled-CLI processes after teardown.
Cross-check the AP API (`GET /v1/sessions/{id}/items`) — a start failure can exit
0 with empty stdout while the server records a `failed` session.
## Known sharp edges (found via live bug-bash — "as of this writing")
- **Native tools bypass `on:[tool_call]` policies and aren't recorded.** Copilot's
built-in `create`/`view`/`edit`/`bash` run inside the SDK, so an
`on:[tool_call]` DENY guardrail (e.g. `blast_radius`) never sees them, and they
leave no `function_call` item in the transcript (only streamed narration).
**Bridged `sys_*` tools ARE gated and recorded.** Gate Copilot's built-ins at
the LLM phase (`PHASE_LLM_REQUEST`/`RESPONSE`, which fire) or via the OS-env
sandbox — not `on:[tool_call]`. (Same shape as the cursor harness.)
- **Copilot fails loud (unlike cursor's swallowed start failures).** Bad token,
empty/invalid model, and unknown model ids all exit non-zero with a clear error
AND a server-side failed session + error item — verified, not swallowed.
- **`omni run -p` against an async orchestrator exits after the dispatch turn**,
so a delegated sub-agent's final answer is persisted server-side but may not
reach stdout in one-shot mode. Read the session over the AP API to see it.
- **Non-graceful exit can orphan the bundled CLI.** Graceful teardown reaps it
(`client.stop()`); after a `SIGKILL`/hard-exit, sweep
`pgrep -af "copilot/bin/copilot"`.
## Cleanup
```bash
.venv/bin/omni server stop # or kill the foreground `omni server`
rm -rf /tmp/copilot-dev # remove scratch bundles
pgrep -af "copilot/bin/copilot" # confirm no orphaned bundled-CLI subprocesses linger
```
BIN
View File
Binary file not shown.
+9 -6
View File
@@ -1,6 +1,6 @@
<!--
For AI-written descriptions:
- Follow this template (Related issue, Summary, Type of change, Test coverage, Coverage rationale).
- Follow this template (Related issue, Summary, Test Plan, Type of change, Test coverage, Coverage notes).
- Keep it concise; reviewers skim long descriptions.
- For non-trivial changes, include an ELI5 and a diagram (ASCII or mermaid).
- Leave every checkbox in place. The PR Template check fails if required sections
@@ -23,6 +23,10 @@ Closes #
<!-- What changed and why, in 1-3 bullets or a short paragraph. -->
## Test Plan
<!-- How was this change tested? Describe the steps, commands, or scenarios used to verify it. Include a screenshot or recording where helpful. -->
## Type of change
- [ ] Bug fix
@@ -43,11 +47,10 @@ Closes #
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage rationale
## Coverage notes
<!--
Describe the exact commands run and the coverage added/updated. If you did not
add or run tests, explain why the existing coverage is enough or why tests are
not applicable. For E2E-relevant changes, call out the E2E scenario exercised or
why no E2E coverage was added.
Optional — but required if you checked "Manual verification completed" or
"Not applicable" above. Describe what you verified manually, or why automated
test coverage is not needed for this change.
-->
+1 -1
View File
@@ -23,7 +23,7 @@
/.github/ @PattaraS @serena-ruan @dhruv0811 @TomeHirata
# Web UI
/ap-web/ @SabhyaC26 @serena-ruan @daniellok-db @hzub
/ap-web/ @SabhyaC26 @serena-ruan @daniellok-db
# Core agent runtime & harnesses
/omnigent/inner/ @SabhyaC26 @TomeHirata @dhruv0811 @dbczumar
+9 -17
View File
@@ -1,19 +1,14 @@
#!/usr/bin/env bash
# Emits the e2e shard matrix as `matrix=<json>` on $GITHUB_OUTPUT. Shared by
# e2e.yml and e2e-ui.yml (they differ only in NUM_SHARDS).
# Emits the e2e shard matrix as `matrix=<json>` on $GITHUB_OUTPUT, or an EMPTY
# matrix ({"include":[]}) to skip. Empty yields zero jobs and thus NO check-runs
# -- the point of the indirection: a job-level `if:` skip would instead leave a
# check-run with an unexpanded `E2E Tests (shard ${{ matrix.shard_id }}/...)` name.
#
# Returns an EMPTY matrix ({"include":[]}) when the run should be skipped:
# - draft PRs, or
# - a fork's pull_request (no secrets there; forks run via the fork-e2e/**
# mirror push instead).
# An empty matrix yields zero jobs and therefore NO check-runs. This is the
# whole reason for the indirection: a job-level `if:` skip of a matrixed job
# would instead leave one check-run with an unexpanded
# `E2E Tests (shard ${{ matrix.shard_id }}/...)` name.
# Skips only draft PRs. These suites are mock-LLM (no secrets), so fork PRs run
# directly, like CI.
#
# Env in: EVENT_NAME (github.event_name), IS_DRAFT, IS_FORK (both may be empty
# on non-PR events), NUM_SHARDS.
# Out: matrix={"include":[{"shard_id":0,"num_shards":N}, ...]} (or [] empty)
# Env in: EVENT_NAME, IS_DRAFT, NUM_SHARDS.
# Shared by e2e.yml and e2e-ui.yml (differ in NUM_SHARDS).
set -euo pipefail
@@ -21,13 +16,10 @@ skip=false
if [[ "${IS_DRAFT:-false}" == "true" ]]; then
skip=true
fi
if [[ "$EVENT_NAME" == "pull_request" && "${IS_FORK:-false}" == "true" ]]; then
skip=true
fi
if [[ "$skip" == "true" ]]; then
echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT"
echo "skip: empty matrix (event=$EVENT_NAME draft=${IS_DRAFT:-} fork=${IS_FORK:-})"
echo "skip: empty matrix (event=$EVENT_NAME draft=${IS_DRAFT:-})"
exit 0
fi
+9 -14
View File
@@ -1,14 +1,13 @@
#!/usr/bin/env bash
# Emits the integration-test harness matrix as `matrix=<json>` on $GITHUB_OUTPUT.
#
# Returns an EMPTY matrix ({"include":[]}) when the run should be skipped:
# - draft PRs, or
# - a fork's pull_request (no secrets there; forks run via the fork-e2e/**
# mirror push instead).
# An empty matrix yields zero jobs and therefore NO check-runs. This is the
# whole reason for the indirection (mirrors e2e-shard-matrix.sh): a job-level
# `if:` skip of a matrixed job would instead leave one check-run with an
# unexpanded `Integration (${{ matrix.name }})` name.
# Returns an EMPTY matrix ({"include":[]}) to skip: zero jobs, NO check-runs.
# This is the whole reason for the indirection (mirrors e2e-shard-matrix.sh): a
# job-level `if:` skip would instead leave one check-run with an unexpanded
# `Integration (${{ matrix.name }})` name.
#
# Skips only draft PRs. Integration is mock-LLM (no secrets), so fork PRs run
# directly, like CI -- no fork-e2e/** mirror needed.
#
# Single openai-agents leg: all tests now run against the mock LLM server.
# claude-sdk and codex reject "mock-model" as an unknown model (they validate
@@ -16,8 +15,7 @@
# only openai-agents works without real credentials. The model name is unused
# in mock mode (model_name fixture returns "mock-model" regardless).
#
# Env in: EVENT_NAME (github.event_name), IS_DRAFT, IS_FORK (both may be empty
# on non-PR events).
# Env in: EVENT_NAME (github.event_name), IS_DRAFT.
# Out: matrix={"include":[{"name":..,"harness":..,"model":..,"workers":..}, ...]}
# (or {"include":[]} when skipped).
@@ -27,13 +25,10 @@ skip=false
if [[ "${IS_DRAFT:-false}" == "true" ]]; then
skip=true
fi
if [[ "$EVENT_NAME" == "pull_request" && "${IS_FORK:-false}" == "true" ]]; then
skip=true
fi
if [[ "$skip" == "true" ]]; then
echo 'matrix={"include":[]}' >> "$GITHUB_OUTPUT"
echo "skip: empty matrix (event=$EVENT_NAME draft=${IS_DRAFT:-} fork=${IS_FORK:-})"
echo "skip: empty matrix (event=$EVENT_NAME draft=${IS_DRAFT:-})"
exit 0
fi
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# Decides whether a fork PR's head commit should be mirrored onto the trusted
# fork-e2e/pr-N branch (which lets e2e run as a `push` with the test-gateway
# secrets). Called by .github/workflows/fork-e2e-mirror.yml.
#
# Gate (either condition opens it):
# 1. The PR has an approving review from a maintainer (in
# .github/MAINTAINER@main), OR
# 2. The PR carries the `e2e-approved` label applied by a maintainer.
#
# Path 1 (approval) is the primary flow: approving the PR both satisfies the
# merge gate and triggers e2e. Path 2 (label) is a manual escape hatch for
# running e2e without approving for merge (e.g. early CI validation).
#
# New commits while the gate is open re-mirror automatically (this script
# re-runs on `synchronize`); the security scan plus the maintainer's review
# are the safety net for post-approval pushes. Revoking approval AND removing
# the label (or closing the PR) stops future mirrors and cleans up the mirror
# branch -- see the workflow.
#
# Fail closed: any error or unexpected state leaves the gate shut, so secrets
# never run on an unverified PR.
#
# Env in: GH_TOKEN, REPO, PR,
# MAINTAINERS (space-separated, from merge-ready/load-maintainers.sh).
# Out: `mirror=true|false` and `reason=<text>` on $GITHUB_OUTPUT.
set -euo pipefail
emit() {
echo "mirror=$1" >> "$GITHUB_OUTPUT"
echo "reason=$2" >> "$GITHUB_OUTPUT"
echo "mirror=$1 ($2)"
}
MAINTAINERS_LC=$(echo "${MAINTAINERS:-}" | tr '[:upper:]' '[:lower:]')
if [[ -z "${MAINTAINERS_LC// /}" ]]; then
emit false "no maintainers loaded (.github/MAINTAINER@main empty/missing)"
exit 0
fi
# --- Path 1: maintainer approval via PR review ---
APPROVERS=$(gh api "repos/$REPO/pulls/$PR/reviews" --paginate \
--jq '[.[] | select(.state != "COMMENTED")] | group_by(.user.login) | map(max_by(.submitted_at)) | .[] | select(.state == "APPROVED") | .user.login')
for u in $APPROVERS; do
u_lc=$(echo "$u" | tr '[:upper:]' '[:lower:]')
for m in $MAINTAINERS_LC; do
if [[ "$m" == "$u_lc" ]]; then
emit true "approved by maintainer @$u"
exit 0
fi
done
done
# --- Path 2: e2e-approved label applied by a maintainer ---
LABEL="e2e-approved"
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
if grep -qxF "$LABEL" <<<"$LABELS"; then
LABELER=$(gh api "repos/$REPO/issues/$PR/events" --paginate \
--jq "[.[] | select(.event == \"labeled\" and .label.name == \"$LABEL\")] | last | .actor.login // empty")
if [[ -n "$LABELER" ]]; then
LABELER_LC=$(echo "$LABELER" | tr '[:upper:]' '[:lower:]')
for m in $MAINTAINERS_LC; do
if [[ "$m" == "$LABELER_LC" ]]; then
emit true "'$LABEL' applied by maintainer @$LABELER"
exit 0
fi
done
fi
fi
# Neither path opened the gate.
emit false "awaiting approval from a maintainer or '$LABEL' label"
+12 -22
View File
@@ -2,19 +2,20 @@
# Single source of truth for the Merge Ready outcome. Downstream steps
# just consume `state`, `short_desc`, and `long_desc`.
#
# The gate is green iff every required check is green on its own merits
# AND (for fork PRs) a maintainer has approved. There is no CI bypass: to
# land despite red required checks, fix or delete the failing test, or
# have a repo admin use GitHub's native "merge without waiting for
# requirements" affordance.
# The gate is green iff every required check is green on its own merits. There is
# no CI bypass: to land despite red required checks, fix or delete the failing
# test, or have a repo admin use GitHub's native "merge without waiting for
# requirements" affordance. (Fork PRs still need a maintainer's approving review
# to merge -- that is enforced by the separate `Maintainer Approval` check, not
# here. No CI suite needs secrets on a fork PR anymore, so there is no
# e2e-specific approval gate.)
#
# CI eval | fork approval | state | meaning
# ---------+---------------+----------+---------------------------------
# success | n/a or true | success | CI green on its own merits
# success | false | failure | fork PR awaiting maintainer approval
# failure | any | failure | CI red
# CI eval | state | meaning
# ---------+----------+----------------------------
# success | success | all required checks green
# failure | failure | a required check is red
#
# Env in: EVAL, FAILED, FORK_NEEDS_E2E_APPROVAL (optional, default false)
# Env in: EVAL, FAILED
# Out: state, short_desc, long_desc on $GITHUB_OUTPUT
set -euo pipefail
@@ -29,17 +30,6 @@ else
LONG=$':hourglass: gate not green yet. Required checks not satisfied:\n\n'"$FAILED"$'\nThe merge will fire once these turn green.'
fi
# Fork PRs never run e2e on their own: the fork `pull_request` run resolves to
# an empty shard matrix, so the suite only runs once a maintainer approves the
# PR (which mirrors the head to a trusted fork-e2e/** branch). Without approval
# the e2e checks are satisfied-via-skip and the PR would go green with e2e never
# having executed -- so block merge until a maintainer approves.
if [[ "${FORK_NEEDS_E2E_APPROVAL:-false}" == "true" ]]; then
STATE=failure
SHORT="Awaiting maintainer approval for e2e"
LONG="$LONG"$'\n\n:no_entry: **E2e tests are required for fork PRs.** A maintainer must approve this PR or apply the `e2e-approved` label to trigger the e2e suite. The merge gate will stay red until e2e passes.'
fi
# GitHub commit-status descriptions max out at 140 chars.
if [[ ${#SHORT} -gt 140 ]]; then
SHORT="${SHORT:0:137}..."
+11 -10
View File
@@ -1,10 +1,9 @@
# Sourced by evaluate-checks.sh. The unit/lint/type-check checks gate every PR.
# The e2e + e2e-ui suites also gate PRs, but only run with secrets on same-repo
# PRs (maintainer branches); fork PRs cannot read the LLM_API_KEY /
# GATEWAY_BASE_URL secrets, so their e2e jobs skip via a workflow fork guard.
# The e2e and integration check names are therefore in BOTH REQUIRED (a
# same-repo PR must pass them) and ALLOW_SKIP (a fork PR's skipped check still
# satisfies the gate).
# Sourced by evaluate-checks.sh. These checks gate every PR. e2e, e2e-ui, and
# integration are mock-LLM (no secrets) and run on ALL PRs -- same-repo and fork
# -- directly, like CI. They are in ALLOW_SKIP too because they are legitimately
# absent in some runs: draft PRs (empty matrix) and path-ignored PRs (the
# workflow doesn't run). The real-gateway e2e-ui tests run nightly only and are
# NOT PR checks, so they are not listed here.
# Generated file -- do not hand-edit; it is replaced wholesale on every sync.
REQUIRED=(
@@ -22,6 +21,7 @@ REQUIRED=(
"Pytest (server-rest)"
"Pytest (spec-llms)"
"Pytest (misc)"
"Pytest (databricks)"
"E2E Tests (shard 0/4)"
"E2E Tests (shard 1/4)"
"E2E Tests (shard 2/4)"
@@ -48,6 +48,7 @@ ALLOW_SKIP=(
"Pytest (server-rest)"
"Pytest (spec-llms)"
"Pytest (misc)"
"Pytest (databricks)"
"E2E Tests (shard 0/4)"
"E2E Tests (shard 1/4)"
"E2E Tests (shard 2/4)"
@@ -63,9 +64,9 @@ ALLOW_SKIP=(
is_allow_skip() { printf '%s\n' "${ALLOW_SKIP[@]}" | grep -qxF "$1"; }
# Maps an ALLOW_SKIP check to the workflow that produces it, so
# evaluate-checks.sh can tell a genuine skip (a CI Pytest shard path-skip, or
# the fork guard skipping an e2e job) from a check that is merely absent
# because its workflow is still queued or re-running.
# evaluate-checks.sh can tell a genuine skip (a CI Pytest shard path-skip, or a
# draft/path-ignored run) from a check that is merely absent because its
# workflow is still queued or re-running.
workflow_for() {
case "$1" in
"Pytest ("*) echo "CI" ;;
+9 -3
View File
@@ -40,6 +40,12 @@ def format_body(body: str) -> str:
elif not _has_heading(body, "Summary"):
body = f"## Summary\n\n{body}"
body = _append_section(
body,
"Test Plan",
"How was this change tested? Describe the steps, commands, or scenarios "
"used to verify it (autoformat added this section — please replace it).",
)
body = _append_section(
body,
"ELI5",
@@ -54,9 +60,9 @@ def format_body(body: str) -> str:
body = _append_section(body, "Test coverage", _checkbox_block(TEST_LABELS))
body = _append_section(
body,
"Coverage rationale",
"Autoformat added this section; please add commands run or explain why "
"coverage is sufficient.",
"Coverage notes",
"<!-- Optional; required if you checked 'Manual verification completed' "
"or 'Not applicable' above. -->",
)
return body.rstrip() + "\n"
+19 -27
View File
@@ -14,9 +14,9 @@ import sys
REQUIRED_HEADINGS = (
"Summary",
"Test Plan",
"Type of change",
"Test coverage",
"Coverage rationale",
)
TYPE_LABELS = (
@@ -40,10 +40,8 @@ TEST_LABELS = (
PLACEHOLDER_FRAGMENTS = (
"what changed and why",
"check all that apply",
"describe the exact commands",
"describe below",
"explain why",
"if you did not add or run tests",
"how was this change tested",
)
@@ -121,6 +119,12 @@ def validate_pr_body(body: str) -> ValidationResult:
elif _contains_placeholder(summary):
errors.append("Summary still contains template placeholder text.")
test_plan = _meaningful_text(_section(body, spans, "Test Plan"))
if not test_plan:
errors.append("Test Plan must describe how the change was tested.")
elif _contains_placeholder(test_plan):
errors.append("Test Plan still contains template placeholder text.")
type_section = _section(body, spans, "Type of change")
missing_type_labels = _missing_labels(type_section, TYPE_LABELS)
if missing_type_labels:
@@ -141,31 +145,19 @@ def validate_pr_body(body: str) -> ValidationResult:
if not checked_tests:
errors.append("Check at least one Test coverage checkbox.")
rationale = _meaningful_text(_section(body, spans, "Coverage rationale"))
if not rationale:
errors.append(
"Coverage rationale must explain tests run/added, or why more coverage is not needed."
)
elif _contains_placeholder(rationale):
errors.append("Coverage rationale still contains template placeholder text.")
automated_tests = {
"Unit tests added / updated",
"Integration tests added / updated",
"E2E tests added / updated",
"Existing tests cover this change",
}
if checked_tests and checked_tests.isdisjoint(automated_tests):
if len(rationale.split()) < 8:
# Coverage notes are optional in general, but required whenever "Manual
# verification completed" or "Not applicable" is checked — those choices
# need a written justification.
if checked_tests & {"Manual verification completed", "Not applicable"}:
coverage_notes = _meaningful_text(_section(body, spans, "Coverage notes"))
if not coverage_notes:
errors.append(
"When no automated test coverage checkbox is selected, "
"the rationale must explain why."
"Coverage notes are required when 'Manual verification completed' or "
"'Not applicable' is selected — describe what you verified or why "
"automated coverage is not needed."
)
if "Not applicable" in checked_tests and rationale and len(rationale.split()) < 8:
errors.append(
"Not applicable test coverage requires a concrete explanation in Coverage rationale."
)
elif _contains_placeholder(coverage_notes):
errors.append("Coverage notes still contains template placeholder text.")
return ValidationResult(ok=not errors, errors=errors)
+2 -3
View File
@@ -4,9 +4,8 @@
Part of the single contributor Security Scan (.github/workflows/security-scan.yml),
the companion to secret-scan.py: that one flags secrets a PR *commits*, this one
flags code a PR adds to *steal* the CI secrets it runs with (the test-gateway
token, GITHUB_TOKEN). It is the detector the fork-e2e mirror relied on before the
scan was unified -- the mirror runs contributor code with the gateway secret, so
an env-secret read piped to the network is the shape that matters there.
token, GITHUB_TOKEN) -- an env-secret read piped to the network is the shape that
matters.
It reads diff TEXT only -- it never checks out or executes the PR's code -- so it
is safe on any event. It is defense-in-depth + a reviewer aid, NOT a guarantee:
+4 -7
View File
@@ -12,10 +12,8 @@
# does not vouch for the contents of this one) and first-timers
# (FIRST_TIME_CONTRIBUTOR / NONE).
#
# This gate is independent of fork-e2e/should-mirror.sh: that one gates secret-
# bearing e2e on a maintainer's approving PR review, whereas this gate
# decides whether to inspect for attacks and so errs toward scanning more (it
# scans returning CONTRIBUTORs that the label gate would not by itself run).
# This gate decides whether to inspect a PR for attacks and errs toward scanning
# more (it scans returning CONTRIBUTORs, not just first-timers).
#
# author_association is computed by GitHub from the actor's relationship to the
# repo at event time; it is not attacker-settable from PR contents.
@@ -70,9 +68,8 @@ has_skip_label() {
}
# Only PRs carry untrusted contributor code through the gate. Every other
# trigger -- push to main / fork-e2e/** (the mirror branch only exists after a
# returning-contributor / maintainer-approval gate), schedule, dispatch -- is a
# trusted context, so proceed without scanning. pull_request_review is still
# trigger -- push to main, schedule, dispatch -- is a trusted context, so
# proceed without scanning. pull_request_review is still
# accepted (it carries the same pull_request + author_association fields, so the
# gate evaluates identically) in case a workflow_call caller is wired to it, but
# no workflow triggers a scan on review any more: the skip-security-scan waiver
@@ -71,10 +71,10 @@ function assert(name, cond, detail) {
r = await run({
files: ["README.md"],
load: { PattaraS: 9, "serena-ruan": 9, dhruv0811: 9, TomeHirata: 9, SabhyaC26: 9,
"daniellok-db": 9, hzub: 0, dbczumar: 1, fanzeyi: 9, "ckcuslife-source": 9,
"daniellok-db": 9, dbczumar: 0, fanzeyi: 9, "ckcuslife-source": 9,
bbqiu: 9, Edwinhe03: 9 },
});
assert("unowned -> lowest from full pool", JSON.stringify(r.added) === JSON.stringify(["hzub"]), JSON.stringify(r));
assert("unowned -> lowest from full pool", JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r));
// 3. db area (fanzeyi, SabhyaC26) -> the lower-load one selected.
r = await run({ files: ["omnigent/db/x.py"], load: { SabhyaC26: 1 } });
+12 -1
View File
@@ -112,6 +112,14 @@ jobs:
--ignore=tests/spec
--ignore=tests/llms
--ignore=tests/codex_parity
# Databricks-coupled tests (Lakebase token engine, psycopg). This is
# the only lane that installs the `databricks` extra; the
# @pytest.mark.databricks marker keeps these tests off the lean lanes
# (which run -m "not databricks") and selects them here.
- group: databricks
paths: tests/db tests/deploy
extra: databricks
markexpr: databricks
steps:
- name: Check out repo
@@ -144,7 +152,9 @@ jobs:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync --locked --extra all --extra dev
# matrix.extra (e.g. "databricks") adds an extra for lanes that need it;
# empty for the default lanes.
run: uv sync --locked --extra all --extra dev ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
- name: Run pytest
shell: bash
@@ -165,6 +175,7 @@ jobs:
# shellcheck disable=SC2086
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u DATABRICKS_TOKEN \
uv run pytest ${{ matrix.paths }} \
-m "${{ matrix.markexpr || 'not databricks' }}" \
-n ${{ matrix.workers || '8' }} \
--dist=${{ matrix.dist || 'loadfile' }} \
--timeout=${{ matrix.timeout || '300' }} \
+9 -50
View File
@@ -1,14 +1,14 @@
name: E2E UI Tests
# Runs the Playwright UI suite against a freshly built ap-web SPA + a
# hello_world test agent, split across a 3-shard matrix. Separate from
# nightly.yml because the Node + Playwright + SPA-build setup is disjoint
# from the inner-only legs.
# Runs the Playwright UI suite against a freshly built ap-web SPA, split across
# a 3-shard matrix. The whole suite (including the native Claude/Codex/Cursor
# render-parity tests) runs against the in-process mock LLM and needs NO
# secrets, so it runs on ALL PRs -- same-repo AND fork -- directly, like ci.yml.
# (The native_*_mock_session fixtures use the real gateway only when LLM_API_KEY
# is set, e.g. local dev; CI never sets it.)
#
# Triggers:
# pull_request SAME-REPO PRs only; draft / fork PRs skip the job
# (forks run via the fork-e2e/** push after approval).
# push (fork-e2e/**) UI suite for mirrored fork PRs (trusted, secrets flow).
# pull_request ALL PRs (same-repo + fork). Draft PRs skip.
# schedule 09:00 UTC daily, alongside nightly.yml.
# workflow_dispatch manual run. Input `branch` selects a non-main ref.
@@ -18,9 +18,6 @@ on:
# Security Gate via rerun-security-gate.yml, so label churn need not re-run
# the heavy Playwright suite. (#399 added these for the gate; superseded.)
types: [opened, synchronize, reopened, ready_for_review]
push:
branches:
- 'fork-e2e/**'
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
@@ -88,14 +85,13 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
NUM_SHARDS: "3"
run: bash .github/scripts/ci/e2e-shard-matrix.sh
e2e-ui:
name: E2E UI Tests (shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})
# Draft / fork pull_request events resolve to an EMPTY matrix in `setup`
# (forks run via the fork-e2e/** mirror push), so no shard runs for them.
# Draft PRs resolve to an EMPTY matrix in `setup`, so no shard runs for
# them. Fork PRs DO run (mock LLM, no secrets) -- same as ci.yml.
# `ready_for_review` re-fires when a draft is converted.
needs: setup
runs-on: ubuntu-latest
@@ -317,40 +313,3 @@ jobs:
echo "- 📜 server.log: _no artifact uploaded (glob matched nothing)_"
fi
} >> "$GITHUB_STEP_SUMMARY"
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
merge-ready-rerun:
name: Merge Ready rerun
needs: e2e-ui
if: >-
always()
&& needs.e2e-ui.result != 'skipped'
&& (
(github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository)
|| (github.event_name == 'push'
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Re-dispatch Merge Ready
run: |
set -euo pipefail
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
exit 0
fi
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
+15 -59
View File
@@ -8,13 +8,12 @@ name: E2E Tests
# schedule 09:00 UTC daily (alongside nightly.yml).
# workflow_dispatch manual run. Inputs: `branch` (non-main ref) and
# `parallelism` (pytest `-n` worker count).
# pull_request PR gate for SAME-REPO PRs only. Fork PRs skip
# here (no secrets) and run via the fork-e2e/**
# push after a maintainer approves the PR and
# fork-e2e-mirror.yml mirrors them. The four shard
# checks are required by merge-ready.yml.
# push (fork-e2e/**) e2e run for mirrored fork PRs (trusted branch,
# so secrets flow).
# pull_request PR gate for ALL PRs -- same-repo AND fork. This suite
# runs entirely against the in-process mock LLM (no
# secrets), so fork PRs run it directly here just like
# ci.yml, with no fork-e2e/** mirror (#802 removed the
# credential setup). The four shard checks are required by
# merge-ready.yml.
on:
schedule:
@@ -22,10 +21,6 @@ on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
paths-ignore: ['ap-web/**', 'tests/e2e_ui/**']
push:
branches:
- 'fork-e2e/**'
paths-ignore: ['ap-web/**', 'tests/e2e_ui/**']
workflow_dispatch:
inputs:
branch:
@@ -38,8 +33,8 @@ on:
default: "2"
concurrency:
# PRs key by number, dispatch by branch (so re-runs cancel); push /
# schedule key by SHA so each merge to `main` gets its own run.
# PRs key by number, dispatch by branch (so re-runs cancel); schedule keys
# by SHA so each merge to `main` gets its own run.
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}
cancel-in-progress: true
@@ -62,9 +57,8 @@ jobs:
gate:
uses: ./.github/workflows/security-gate.yml
# Compute the shard matrix once. A skipped run (draft / fork pull_request)
# yields an EMPTY matrix -> zero shard jobs -> no skipped placeholder check.
# Shared with e2e-ui.yml via e2e-shard-matrix.sh (only NUM_SHARDS differs).
# Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by
# default; draft PRs resolve to an empty matrix.
setup:
name: setup
needs: gate
@@ -85,7 +79,6 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
NUM_SHARDS: "4"
run: bash .github/scripts/ci/e2e-shard-matrix.sh
@@ -96,8 +89,8 @@ jobs:
# -n 2 per shard => 4 x 2 = 8 concurrent gateway calls, below the
# nightly's 429 pain point; drop -n before max-parallel if rate-limited.
name: E2E Tests (shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})
# Draft / fork pull_request events resolve to an EMPTY matrix in `setup`
# (forks run via the fork-e2e/** mirror push), so no shard runs for them.
# Draft PRs resolve to an EMPTY matrix in `setup`, so no shard runs for
# them. Fork PRs DO run (mock LLM, no secrets) -- same as ci.yml.
needs: setup
runs-on: ubuntu-latest
# Job-level cap (composite-action run steps can't set timeout-minutes):
@@ -114,9 +107,9 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Same-repo PRs test the merge result (refs/pull/N/merge -- absent
# when the PR conflicts, so a conflicted PR fails checkout by design).
# Push (fork-e2e/**) and dispatch fall back to the branch / ref.
# PRs (same-repo and fork) test the merge result (refs/pull/N/merge --
# absent when the PR conflicts, so a conflicted PR fails checkout by
# design). Schedule / dispatch fall back to the branch / ref.
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.event.inputs.branch || github.ref }}
# Steps below are shared verbatim with server-compat.yml's backcompat-e2e
@@ -129,40 +122,3 @@ jobs:
num_shards: ${{ matrix.num_shards }}
parallelism: ${{ github.event.inputs.parallelism || '2' }}
nightly_full: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
merge-ready-rerun:
name: Merge Ready rerun
needs: e2e
if: >-
always()
&& needs.e2e.result != 'skipped'
&& (
(github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository)
|| (github.event_name == 'push'
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Re-dispatch Merge Ready
run: |
set -euo pipefail
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
exit 0
fi
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
-227
View File
@@ -1,227 +0,0 @@
name: Fork e2e mirror
# Mirrors a gated fork PR's head onto a trusted fork-e2e/pr-N branch so e2e runs
# there as a `push` (with secrets). It's a pure git-ref update via a GitHub App
# token (refs pushed by the default GITHUB_TOKEN don't trigger workflows); it
# never checks out or runs fork code. Mirroring requires BOTH the contributor
# Security Scan to pass (the blocking `gate` job, via security-gate.yml) AND a
# maintainer's approving PR review (should-mirror.sh).
#
# Maintainer approval is the sole human gate for running secret-bearing e2e on a
# fork PR. Only users with write access can submit approving reviews, and the
# gate further verifies the approver is in .github/MAINTAINER, so an external
# fork author can never open it. It is intentionally tied to the merge gate
# (maintainer-approval.yml): approving the PR runs e2e AND approves for merge.
# Requesting changes or dismissing the review stops future mirrors; closing the
# PR tears down the mirror branch.
#
# Triggers:
# pull_request_target opened/synchronize/reopened/closed — handles new
# pushes and PR lifecycle. Reviews don't fire
# pull_request_target, so approval reaches here via
# workflow_dispatch (dispatched by
# maintainer-approval-rerun-run.yml on approval).
# workflow_dispatch re-evaluation of a single PR (used by the approval
# relay and for manual re-runs). Safe because
# should-mirror.sh always re-checks approval before
# any secret-bearing run; a spurious dispatch with an
# arbitrary PR number cannot trigger e2e.
#
# leak-scan-allow: pull_request_target
on:
pull_request_target:
# labeled/unlabeled so applying or removing `e2e-approved` opens or tears
# down the mirror immediately, not only on the PR's next push.
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
workflow_dispatch:
inputs:
pr:
description: PR number to evaluate for mirroring.
required: true
type: string
permissions:
contents: read
concurrency:
group: fork-e2e-mirror-${{ github.event.pull_request.number || inputs.pr }}
cancel-in-progress: false
jobs:
# Delete the trusted mirror branch when the PR closes or the gate label is
# removed. Ungated -- cleanup must always run so a closed PR (or one whose
# label was removed) never leaves a stale fork-e2e/pr-N branch behind.
# Note: approval revocation cleanup is handled by the mirror job's
# "Delete stale mirror branch on revocation" step (workflow_dispatch path).
cleanup:
name: cleanup
if: >-
github.event_name == 'pull_request_target'
&& github.event.pull_request.head.repo.fork
&& (
github.event.action == 'closed'
|| (github.event.action == 'unlabeled' && github.event.label.name == 'e2e-approved')
)
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 5
env:
REPO: ${{ github.repository }}
MIRROR_BRANCH: fork-e2e/pr-${{ github.event.pull_request.number }}
steps:
- name: Mint mirror App token
id: app-token
# App token, not GITHUB_TOKEN: its pushes DO trigger the downstream e2e.
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.FORK_E2E_APP_ID }}
private-key: ${{ secrets.FORK_E2E_APP_PRIVATE_KEY }}
- name: Delete mirror branch
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
gh api -X DELETE "repos/$REPO/git/refs/heads/$MIRROR_BRANCH" >/dev/null 2>&1 \
&& echo "Deleted $MIRROR_BRANCH" || echo "No $MIRROR_BRANCH to delete"
# The single contributor Security Scan, consulted as a BLOCKING gate before we
# mirror fork code onto a trusted branch where e2e runs WITH the gateway secret.
# The scan itself runs once on the PR (security-scan.yml); this poller mirrors
# its result, blocking the mirror on a finding. Skipped on the teardown action
# (handled by `cleanup`).
gate:
name: security gate
if: >-
(
github.event_name == 'workflow_dispatch'
) || (
github.event_name == 'pull_request_target'
&& github.event.pull_request.head.repo.fork
&& github.event.action != 'closed'
&& github.event.action != 'unlabeled'
&& (github.event.action != 'labeled' || github.event.label.name == 'e2e-approved')
)
uses: ./.github/workflows/security-gate.yml
mirror:
name: mirror
needs: gate
# Fork PRs only -- same-repo PRs run e2e directly via `pull_request`.
# workflow_dispatch is validated at the step level (verify fork before
# mirroring) but runs the gate unconditionally to keep the flow simple.
if: >-
(
github.event_name == 'workflow_dispatch'
) || (
github.event_name == 'pull_request_target'
&& github.event.pull_request.head.repo.fork
&& github.event.action != 'closed'
&& github.event.action != 'unlabeled'
&& (github.event.action != 'labeled' || github.event.label.name == 'e2e-approved')
)
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest
timeout-minutes: 5
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || inputs.pr }}
steps:
- name: Resolve PR context
id: ctx
run: |
if [[ -n "${{ github.event.pull_request.head.sha || '' }}" ]]; then
echo "sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
echo "is_fork=true" >> "$GITHUB_OUTPUT"
else
# workflow_dispatch: resolve from the PR object.
INFO=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,isCrossRepository)
SHA=$(echo "$INFO" | jq -r '.headRefOid')
IS_FORK=$(echo "$INFO" | jq -r '.isCrossRepository')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "is_fork=$IS_FORK" >> "$GITHUB_OUTPUT"
if [[ "$IS_FORK" != "true" ]]; then
echo "::notice::PR #$PR is same-repo; skipping mirror (same-repo PRs run e2e directly)."
fi
fi
- name: Check out gate scripts from main
if: steps.ctx.outputs.is_fork == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main # trusted; never the PR head
sparse-checkout: .github/scripts
persist-credentials: false
- name: Mint mirror App token
if: steps.ctx.outputs.is_fork == 'true'
id: app-token
# App token, not GITHUB_TOKEN: its pushes DO trigger the downstream e2e.
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.FORK_E2E_APP_ID }}
private-key: ${{ secrets.FORK_E2E_APP_PRIVATE_KEY }}
# MAINTAINER@main, never the PR head: the gate verifies the *approver* is a
# maintainer, so a PR can't self-grant by editing its own MAINTAINER copy.
- name: Load maintainers
if: steps.ctx.outputs.is_fork == 'true'
id: maintainers
run: bash .github/scripts/merge-ready/load-maintainers.sh
- name: Evaluate mirror gate
if: steps.ctx.outputs.is_fork == 'true'
id: gate
env:
MAINTAINERS: ${{ steps.maintainers.outputs.list }}
run: bash .github/scripts/fork-e2e/should-mirror.sh
- name: Mirror head SHA onto trusted branch
if: steps.ctx.outputs.is_fork == 'true' && steps.gate.outputs.mirror == 'true'
env:
TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_SHA: ${{ steps.ctx.outputs.sha }}
MIRROR_BRANCH: fork-e2e/pr-${{ env.PR }}
run: |
set -euo pipefail
# Move git OBJECTS, don't just point a ref. A fork PR's head commit
# reaches the base repo only through the shared fork network (the
# `refs/pull/N/head` pull ref); the Git Data refs API refuses to
# anchor a NEW branch to a commit the base repo doesn't own, returning
# `422 Reference does not exist`. Fetching the pull ref into a scratch
# repo and pushing the SHA materializes the object in the base repo so
# the ref is valid -- and the App-token push is what triggers the
# downstream e2e (a GITHUB_TOKEN push would not). No working tree is
# checked out and no fork code runs in this privileged job; only git
# objects move. `push -f` covers both first create and re-sync.
work="$(mktemp -d)"
git -C "$work" init -q
origin="https://x-access-token:${TOKEN}@github.com/${REPO}.git"
git -C "$work" fetch -q --no-tags "$origin" "refs/pull/${PR}/head"
got="$(git -C "$work" rev-parse FETCH_HEAD)"
# Mirror EXACTLY the SHA the security scan gated: if the fork raced a
# new push after approval, the pull ref would carry an unscanned
# commit -- refuse rather than run secret-bearing e2e on it.
if [ "$got" != "$HEAD_SHA" ]; then
echo "::error::pull/$PR/head is $got but the approved head is $HEAD_SHA; refusing to mirror." >&2
exit 1
fi
git -C "$work" push -q -f "$origin" "${HEAD_SHA}:refs/heads/${MIRROR_BRANCH}"
echo "Mirrored $MIRROR_BRANCH -> $HEAD_SHA"
# Tear down the mirror branch when approval is revoked (review dismissed
# or changes requested). Without this, a stale fork-e2e/pr-N branch
# would remain until the next push or PR close.
- name: Delete stale mirror branch on revocation
if: steps.ctx.outputs.is_fork == 'true' && steps.gate.outputs.mirror == 'false'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
MIRROR_BRANCH: fork-e2e/pr-${{ env.PR }}
run: |
gh api -X DELETE "repos/$REPO/git/refs/heads/$MIRROR_BRANCH" >/dev/null 2>&1 \
&& echo "Deleted stale $MIRROR_BRANCH (approval revoked)" \
|| echo "No $MIRROR_BRANCH to delete"
+9 -53
View File
@@ -3,9 +3,10 @@ name: Integration Tests
# Per-PR journey-suite matrix (tests/integration/), once per wrapped harness
# using the mock LLM server (no real gateway credentials required). All tests
# are mock_only: they script the LLM responses via configure_mock_llm and run
# against a local mock FastAPI server. Triggers: daily schedule, same-repo PR
# gate (fork PRs skip and run via the fork-e2e/** push after
# fork-e2e-mirror.yml), the fork-e2e/** push itself, and workflow_dispatch.
# against a local mock FastAPI server. Because it uses NO secrets, it runs on
# ALL PRs -- same-repo AND fork -- directly via `pull_request`, like ci.yml; no
# fork-e2e/** mirror needed. Triggers: daily schedule, the PR gate, and
# workflow_dispatch.
on:
schedule:
@@ -16,10 +17,6 @@ on:
# the heavy integration suite. (#399 added these for the gate; superseded.)
types: [opened, synchronize, reopened, ready_for_review]
paths-ignore: ['ap-web/**']
push:
branches:
- 'fork-e2e/**'
paths-ignore: ['ap-web/**']
workflow_dispatch:
permissions:
@@ -47,11 +44,8 @@ jobs:
gate:
uses: ./.github/workflows/security-gate.yml
# Compute the harness matrix once. A skipped run (draft, or a fork's
# pull_request) yields an EMPTY matrix -> zero jobs -> no skipped check-runs
# with an unexpanded `Integration (${{ matrix.name }})` name. Mirrors the
# e2e.yml / e2e-ui.yml setup-job pattern via
# .github/scripts/ci/integration-matrix.sh.
# Harness matrix (integration-matrix.sh). Fork PRs run by default; draft PRs
# resolve to an empty matrix.
setup:
name: setup
needs: gate
@@ -74,14 +68,13 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: bash .github/scripts/ci/integration-matrix.sh
integration:
name: Integration (${{ matrix.name }})
# Draft PRs and fork pull_request events resolve to an EMPTY matrix in
# `setup` (forks run via the fork-e2e/** mirror push instead), so this job
# produces zero leg runs for them -- and thus no skipped placeholder check.
# Draft PRs resolve to an EMPTY matrix in `setup`, so this job produces zero
# leg runs (and thus no skipped placeholder check). Fork PRs DO run (mock
# LLM, no secrets) -- same as ci.yml.
needs: setup
runs-on: ubuntu-latest
# Per-leg ceiling; inner test step caps at 25 min, rest covers install +
@@ -111,40 +104,3 @@ jobs:
harness: ${{ matrix.harness }}
model: ${{ matrix.model }}
workers: ${{ matrix.workers }}
# Explicitly re-dispatch Merge Ready (same-repo PR or fork-e2e push): the
# workflow_run hop is brittle and was dropped on #751/#792. No checkout,
# actions:write only; GITHUB_TOKEN workflow_dispatch is exempt from recursion.
merge-ready-rerun:
name: Merge Ready rerun
needs: integration
if: >-
always()
&& needs.integration.result != 'skipped'
&& (
(github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository)
|| (github.event_name == 'push'
&& startsWith(github.ref_name, 'fork-e2e/pr-'))
)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Re-dispatch Merge Ready
run: |
set -euo pipefail
# same-repo PR -> event number; fork-e2e push -> parse fork-e2e/pr-<N>
PR="${PR_NUMBER:-${REF_NAME##*/pr-}}"
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "::notice::could not resolve PR number (ref='$REF_NAME'); nothing to do."
exit 0
fi
echo "Re-dispatching merge-ready.yml for PR #$PR after $GITHUB_WORKFLOW."
gh workflow run merge-ready.yml --repo "$REPO" -f pr="$PR"
@@ -80,30 +80,3 @@ jobs:
core.info(`Re-running Maintainer Approval run ${run_id} for PR #${pull_number}`);
await github.rest.actions.reRunWorkflowFailedJobs({ owner, repo, run_id: Number(run_id) });
}
# Fork PRs: maintainer approval also gates e2e (replacing the old
# e2e-approved label). Dispatch the fork-e2e-mirror workflow so the
# approval triggers e2e on the trusted mirror branch.
- name: Dispatch fork e2e mirror for fork PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const fs = require('fs');
const { owner, repo } = context.repo;
if (!fs.existsSync('pr_number')) {
core.info('No pr_number file; nothing to do.');
return;
}
const pull_number = Number(fs.readFileSync('pr_number', 'utf8').trim());
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
if (!pr.head.repo || pr.head.repo.full_name === `${owner}/${repo}`) {
core.info(`PR #${pull_number} is same-repo; skipping fork-e2e-mirror dispatch.`);
return;
}
core.info(`PR #${pull_number} is a fork PR; dispatching fork-e2e-mirror.`);
await github.rest.actions.createWorkflowDispatch({
owner, repo,
workflow_id: 'fork-e2e-mirror.yml',
ref: 'main',
inputs: { pr: String(pull_number) },
});
@@ -21,8 +21,7 @@ concurrency:
jobs:
record:
# Approvals flip the check green; dismissals and changes-requested flip
# it red and revoke the fork-e2e mirror. Skip COMMENTED reviews (they
# don't change review state).
# it red. Skip COMMENTED reviews (they don't change review state).
if: github.event.review.state != 'commented'
runs-on: ubuntu-latest
timeout-minutes: 5
+15 -87
View File
@@ -4,12 +4,11 @@ name: Merge Ready
# required branch-protection check, backed by the REQUIRED list inside
# this workflow. Triggers: `/merge` comment (write-access commenter only),
# `pull_request_target` labeled (acts only with `automerge`),
# `workflow_run` on same-repo CI completion, `check_suite` completion on a
# `fork-e2e/**` branch (the mirrored fork PR e2e -- a delivery that actually
# fires, unlike the brittle fork-PR `workflow_run` hop it replaces), and
# `workflow_dispatch` (programmatic/manual re-evaluation of one PR). Posted
# via the REST API (not the job's implicit check run) so the status lands on
# the PR head SHA, since these jobs run on the default branch.
# `workflow_run` on CI completion (same-repo and fork PRs -- ctx resolves the
# PR from the head SHA), and `workflow_dispatch` (programmatic/manual
# re-evaluation of one PR). Posted via the REST API (not the job's implicit
# check run) so the status lands on the PR head SHA, since these jobs run on
# the default branch.
#
# Labels:
# automerge enable GitHub auto-merge (one-shot on label add) + opt
@@ -21,8 +20,8 @@ name: Merge Ready
# (branch protection has enforce_admins=false).
on:
# `labeled` only; `workflow_run` re-evaluates on CI completion (same-repo PRs
# and the fork-e2e/** mirror push -- see the job `if`).
# `labeled` only; `workflow_run` re-evaluates on CI completion for all PRs
# (same-repo and fork -- ctx resolves the PR from the head SHA).
# pull_request_target (not pull_request) so this workflow always runs from
# main -- a PR cannot modify the gate logic by editing this file.
pull_request_target:
@@ -30,14 +29,9 @@ on:
workflow_run:
workflows: [PR Template, CI, Lint, E2E UI Tests, E2E Tests, Integration Tests]
types: [completed]
# check_suite is a fork-PR fallback (workflow_run on the fork-e2e/** push is
# primary); ctx maps the head SHA back to the open PR.
check_suite:
types: [completed]
issue_comment:
types: [created]
# Programmatic / manual re-evaluation of a single PR -- a reliable entry
# point that does not depend on the fork-e2e mirror at all.
# Programmatic / manual re-evaluation of a single PR.
workflow_dispatch:
inputs:
pr:
@@ -54,7 +48,7 @@ permissions:
contents: read
concurrency:
group: merge-ready-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr || github.event.check_suite.head_sha || 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 }}
cancel-in-progress: true
jobs:
@@ -66,9 +60,8 @@ jobs:
checks: read
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 the fork-e2e/** mirror push), `/merge` comments, or a
# workflow_dispatch re-eval; check_suite is a fork-PR fallback. Runs with no
# 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.
if: >-
(
@@ -77,17 +70,7 @@ jobs:
) ||
(
github.event_name == 'workflow_run' &&
(
github.event.workflow_run.event == 'pull_request' ||
(
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'fork-e2e/')
)
)
) ||
(
github.event_name == 'check_suite' &&
startsWith(github.event.check_suite.head_branch, 'fork-e2e/')
github.event.workflow_run.event == 'pull_request'
) ||
github.event_name == 'workflow_dispatch' ||
(
@@ -119,7 +102,6 @@ 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) }}
CS_PRS: ${{ toJSON(github.event.check_suite.pull_requests) }}
COMMENT_BODY: ${{ github.event.comment.body }}
PR_INPUT: ${{ inputs.pr }}
SHA_INPUT: ${{ inputs.sha }}
@@ -156,17 +138,6 @@ jobs:
fi
PR="${{ github.event.issue.number }}"
SHA=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
elif [[ "${{ github.event_name }}" == "check_suite" ]]; then
# Mirrored fork e2e completed on fork-e2e/pr-N; its head SHA is
# the PR head (the mirror pushes the exact fork head SHA).
SHA="${{ github.event.check_suite.head_sha }}"
PR=$(echo "$CS_PRS" | jq -r '.[0].number // empty')
[[ -z "$PR" ]] && PR=$(resolve_pr_from_sha "$SHA")
if [[ -z "$PR" ]]; then
echo "::notice::Skipped: check_suite 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 }}"
@@ -180,61 +151,20 @@ jobs:
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- name: Load maintainers
id: maintainers
if: steps.ctx.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/merge-ready/load-maintainers.sh
- name: Read PR labels and fork approval state
- name: Read PR labels
id: labels
if: steps.ctx.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.ctx.outputs.pr }}
MAINTAINERS: ${{ steps.maintainers.outputs.list }}
run: |
INFO=$(gh pr view "$PR" --repo "$REPO" --json labels,isCrossRepository)
NAMES=$(echo "$INFO" | jq -r '.labels[].name')
NAMES=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name')
if echo "$NAMES" | grep -qx "automerge"; then
echo "automerge=true" >> "$GITHUB_OUTPUT"
else
echo "automerge=false" >> "$GITHUB_OUTPUT"
fi
# A fork PR without a maintainer's approving review or the
# `e2e-approved` label never runs e2e (the fork pull_request run is
# an empty matrix), so the gate blocks until one of these is present.
# Same-repo PRs run e2e with secrets directly and need no gate.
if [[ "$(echo "$INFO" | jq -r '.isCrossRepository')" == "true" ]]; then
# Check path 1: maintainer approval via PR review.
MAINTAINERS_LC=$(echo "${MAINTAINERS:-}" | tr '[:upper:]' '[:lower:]')
APPROVERS=$(gh api "repos/$REPO/pulls/$PR/reviews" --paginate \
--jq '[.[] | select(.state != "COMMENTED")] | group_by(.user.login) | map(max_by(.submitted_at)) | .[] | select(.state == "APPROVED") | .user.login')
HAS_GATE=false
for u in $APPROVERS; do
u_lc=$(echo "$u" | tr '[:upper:]' '[:lower:]')
for m in $MAINTAINERS_LC; do
if [[ "$m" == "$u_lc" ]]; then
HAS_GATE=true
break 2
fi
done
done
# Check path 2: e2e-approved label.
if [[ "$HAS_GATE" == "false" ]] && echo "$NAMES" | grep -qx "e2e-approved"; then
HAS_GATE=true
fi
if [[ "$HAS_GATE" == "false" ]]; then
echo "fork_needs_e2e_approval=true" >> "$GITHUB_OUTPUT"
else
echo "fork_needs_e2e_approval=false" >> "$GITHUB_OUTPUT"
fi
else
echo "fork_needs_e2e_approval=false" >> "$GITHUB_OUTPUT"
fi
# post_red gates posting a red status: /merge needs it, automerge opts
# in; otherwise post green only so partial CI doesn't paint red.
@@ -273,7 +203,6 @@ jobs:
env:
EVAL: ${{ steps.eval.outcome }}
FAILED: ${{ steps.eval.outputs.failed }}
FORK_NEEDS_E2E_APPROVAL: ${{ steps.labels.outputs.fork_needs_e2e_approval }}
run: bash .github/scripts/merge-ready/compute-gate.sh
# Skipped when post_red is false AND gate is red: leaves prior
@@ -341,12 +270,11 @@ jobs:
# Not on pull_request_target-labeled: auto-merge was enabled in an earlier
# step there, so failing here would make the label look broken even
# though it worked. Safe on workflow_run/check_suite/workflow_dispatch.
# though it worked. Safe on workflow_run/workflow_dispatch.
- name: Fail job when gate is red
if: >-
(
github.event_name == 'workflow_run' ||
github.event_name == 'check_suite' ||
github.event_name == 'workflow_dispatch'
) &&
steps.ctx.outputs.skip != 'true' &&
+37 -3
View File
@@ -123,16 +123,19 @@ jobs:
set -euo pipefail
IMAGE="ghcr.io/omnigent-ai/omnigent-server"
HOST_IMAGE="ghcr.io/omnigent-ai/omnigent-host"
OPENSHELL_IMAGE="ghcr.io/omnigent-ai/omnigent-server-openshell"
SHORT_SHA=$(git rev-parse --short HEAD)
# Immutable per-commit pin, always.
TAGS="${IMAGE}:sha-${SHORT_SHA}"
HOST_TAGS="${HOST_IMAGE}:sha-${SHORT_SHA}"
OPENSHELL_TAGS="${OPENSHELL_IMAGE}:sha-${SHORT_SHA}"
# Append a floating/version tag to both images.
# Append a floating/version tag to all images.
add_tag() {
TAGS="${TAGS},${IMAGE}:$1"
HOST_TAGS="${HOST_TAGS},${HOST_IMAGE}:$1"
OPENSHELL_TAGS="${OPENSHELL_TAGS},${OPENSHELL_IMAGE}:$1"
}
# Every qualifying main commit moves :latest-dev (bleeding edge).
@@ -171,6 +174,7 @@ jobs:
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "host_tags=${HOST_TAGS}" >> "$GITHUB_OUTPUT"
echo "openshell_tags=${OPENSHELL_TAGS}" >> "$GITHUB_OUTPUT"
# No build-args: the Dockerfile ARGs default to public registries.
# Multi-arch: each tag publishes as a manifest list spanning amd64 + arm64,
@@ -210,9 +214,30 @@ jobs:
cache-to: type=gha,mode=max
provenance: false
sbom: true
# OpenShell server variant: the default server image plus the
# openshell SDK extra (OMNIGENT_EXTRAS=openshell). Used by the
# deploy/kubernetes/overlays/openshell kustomize overlay. Reuses
# the shared builder-stage layers from the gha cache.
- name: Build and push openshell server image
id: build-openshell
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: deploy/docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.openshell_tags }}
build-args: |
OMNIGENT_EXTRAS=openshell
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: true
outputs:
server-digest: ${{ steps.build-server.outputs.digest }}
host-digest: ${{ steps.build-host.outputs.digest }}
openshell-digest: ${{ steps.build-openshell.outputs.digest }}
generate-sbom:
# Runs in a separate job with read-only permissions so the Syft
@@ -249,6 +274,13 @@ jobs:
-o cyclonedx-json=host-sbom.cdx.json \
-o spdx-json=host-sbom.spdx.json
- name: Generate openshell server SBOM
run: |
set -euo pipefail
syft "ghcr.io/omnigent-ai/omnigent-server-openshell@${{ needs.build-and-push.outputs.openshell-digest }}" \
-o cyclonedx-json=openshell-sbom.cdx.json \
-o spdx-json=openshell-sbom.spdx.json
- name: Upload SBOMs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -258,6 +290,8 @@ jobs:
server-sbom.spdx.json
host-sbom.cdx.json
host-sbom.spdx.json
openshell-sbom.cdx.json
openshell-sbom.spdx.json
retention-days: 90
promote-nightly:
@@ -288,7 +322,7 @@ jobs:
set -euo pipefail
# crane tag points a new tag at an EXISTING manifest digest without
# re-serializing it, so :latest-nightly keeps :latest-dev's exact digest.
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host; do
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
if crane digest "${img}:latest-dev" >/dev/null 2>&1; then
crane tag "${img}:latest-dev" latest-nightly
echo "promoted ${img}:latest-dev -> :latest-nightly ($(crane digest "${img}:latest-nightly"))"
@@ -359,7 +393,7 @@ jobs:
fi
}
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host; do
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
retag "${img}" "latest-rc" "${RC_TAG}"
retag "${img}" "latest" "${LATEST_TAG}"
done
@@ -13,9 +13,8 @@ name: Polly Review Approval Dispatch
# workflow_dispatch entry point) for that PR.
#
# Maintainer approval is the trust gate that authorizes spending the LLM gateway
# secret on fork code -- the same model as the fork-e2e maintainer-approval gate.
# Polly itself never runs PR code: it reviews the diff fetched via the API from
# a default-branch checkout.
# secret on fork code. Polly itself never runs PR code: it reviews the diff
# fetched via the API from a default-branch checkout.
#
# This workflow checks out NO code and runs NO PR code -- it only reads API data
# and dispatches a workflow, so it is not a "dangerous" workflow_run consumer.
+14 -35
View File
@@ -257,48 +257,29 @@ jobs:
run: |
set -euo pipefail
# Fetch the diff (capped at 512 KB — covers the vast majority of
# real PRs; truncation is surfaced to Polly in the prompt).
# The write-scoped github.token stays in this trusted step and is
# NOT passed to the Polly run.
# || true: head -c closes the pipe once the cap is reached, causing
# gh to get SIGPIPE (exit 141). Under pipefail that would abort the
# step; || true degrades it into the DIFF_TRUNCATED path instead.
# Fetch the full diff to a file — no size cap needed since the diff
# is read from disk by Polly via sys_os_shell, not embedded in the
# CLI argument (which would hit ARG_MAX for large PRs).
gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
-H "Accept: application/vnd.github.v3.diff" \
| head -c 524288 > /tmp/pr_diff.txt || true
> /tmp/pr_diff.txt || true
DIFF_SIZE=$(wc -c < /tmp/pr_diff.txt)
[ "$DIFF_SIZE" -ge 524288 ] && DIFF_TRUNCATED=true || DIFF_TRUNCATED=false
export DIFF_TRUNCATED
# Extract lockfile pin changes from the already-fetched diff —
# no second network call needed.
# Extract lockfile pin changes from the diff.
grep -E '^[+-]name = |^[+-]version = ' /tmp/pr_diff.txt \
| head -500 > /tmp/lockfile_pins.txt || true
# Fetch PR metadata to separate files — avoids embedding
# attacker-controlled strings (PR title/body) into heredocs.
# Fetch PR metadata.
gh pr view "$PR_NUMBER" --repo "$REPO" \
--json title,body,baseRefName,headRefName,additions,deletions,changedFiles \
> /tmp/pr_meta.json
# Build the review prompt safely using python — all untrusted
# fields (title, body, diff) are read from files, never
# interpolated into shell heredocs.
# Build the review prompt — the diff is NOT embedded in the prompt.
# Polly reads it from /tmp/pr_diff.txt via sys_os_shell at review time.
python3 -u <<'PYEOF'
import json, os, pathlib
import json, pathlib
meta = json.loads(pathlib.Path("/tmp/pr_meta.json").read_text())
diff = pathlib.Path("/tmp/pr_diff.txt").read_text(encoding="utf-8", errors="replace")
lockfile_pins = pathlib.Path("/tmp/lockfile_pins.txt").read_text(encoding="utf-8", errors="replace").strip()
truncated = os.environ.get("DIFF_TRUNCATED", "false") == "true"
truncation_notice = """
> ⚠️ **Diff truncated at 512 KB** — this review covers only the first
> portion of the diff. Flag this as a non-blocking note and recommend
> a manual review of the remaining changes.
""" if truncated else ""
lockfile_section = f"""
## Changed lockfile pins (uv.lock / package-lock.json)
@@ -317,14 +298,12 @@ jobs:
## PR Description
{(meta.get('body') or '')[:4096]}{" *(truncated)*" if len(meta.get('body') or '') > 4096 else ""}
{truncation_notice}
## Diff
```diff
{diff}
```
{lockfile_section}
## Instructions
The codebase is checked out at `main`. Read source files freely for
**Step 1 — read the diff.** The full PR diff has been pre-fetched to
`/tmp/pr_diff.txt`. Read it with `sys_os_shell("cat /tmp/pr_diff.txt")`.
The codebase is checked out at `main` — read source files freely for
additional context when needed.
**Security:** you are running in a CI environment with access to secrets
@@ -332,7 +311,7 @@ jobs:
credentials in your output, and never make outbound network calls
except to the configured LLM gateway.
Review the diff against the PR description. Report:
**Step 2 — review.** Report:
1. **Blocking issues** — correctness bugs, broken contracts, missing error handling on failure paths, data loss risks.
2. **Security vulnerabilities** — injection (SQL, command, template), authentication/authorization bypasses, secret exposure, unsafe deserialization, path traversal, SSRF, and any change that weakens an existing security boundary. Flag even subtle issues.
3. **Non-blocking notes** — design concerns or edge cases worth flagging (brief).
@@ -8,9 +8,7 @@ name: Rerun Security Gate Run
# workflow whose latest run for that SHA is a completed failure whose
# `Security Gate` job failed -- so a workflow that already self-triggered on the
# label (ci/e2e trigger on `labeled` to re-poll the security gate) is in-progress or
# green and skipped, avoiding a double-run. fork-e2e-mirror is excluded: it is
# approval-driven mirror plumbing with branch side effects, not a
# gate-mirroring check.
# green and skipped, avoiding a double-run.
#
# RACE GUARD: the label event fires this relay AND the Security Scan re-run
# concurrently. Before re-running anything we WAIT for the Security Scan check on
+9 -1
View File
@@ -145,8 +145,16 @@ jobs:
echo "uv.lock not changed; skipping OSV scan."
exit 0
fi
# Drop editable local packages (the project itself + sdks/*) before
# auditing. pip-audit can't hash an editable path requirement and
# errors out when one is present, so without this filter any PR that
# actually changes uv.lock fails here. We only want to audit
# third-party pinned packages anyway — OSV has no advisories for
# local source. Filtering all `-e` lines (rather than naming each
# workspace member) keeps this correct if members are added later.
uv export --frozen --format requirements-txt --all-extras \
> /tmp/uv-req.txt
> /tmp/uv-req-full.txt
grep -v '^-e ' /tmp/uv-req-full.txt > /tmp/uv-req.txt
uvx pip-audit --requirement /tmp/uv-req.txt --no-deps
- name: Semgrep (changed files, local rules)
@@ -0,0 +1,95 @@
name: Sync OpenAPI to site
# Keeps the public API reference on the omnigent website in sync with
# the spec generated here. When openapi.json changes on main, copy it
# into omnigent-site/public/openapi.json and open (or update) a PR there.
#
# Cross-repo writes can't use the workflow's own GITHUB_TOKEN (it's
# scoped to this repo), so we mint a short-lived token from the
# omnigent-ci GitHub App — the same App used by oss-regen-on-comment.yml
# — scoped to omnigent-site. The App must be installed on omnigent-site
# with contents + pull-requests write.
on:
push:
branches: [main]
paths: [openapi.json]
# Manual trigger for backfills / re-syncs after editing this workflow.
workflow_dispatch:
# One sync at a time; a newer spec supersedes an in-flight run.
concurrency:
group: sync-openapi-to-site
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Open sync PR on omnigent-site
runs-on: ubuntu-latest
# Skip cleanly on forks / installs where the App isn't configured,
# rather than failing the token step with a confusing error.
if: ${{ vars.OMNIGENT_BOT_APP_ID != '' }}
env:
SYNC_BRANCH: auto/openapi-sync
TARGET_REPO: ${{ github.repository_owner }}/omnigent-site
steps:
- name: Checkout omnigent (spec source)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: omnigent
- name: Mint App token for omnigent-site
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
owner: ${{ github.repository_owner }}
repositories: omnigent-site
- name: Checkout omnigent-site (sync target)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ env.TARGET_REPO }}
token: ${{ steps.app-token.outputs.token }}
path: site
- name: Copy spec into the site
run: cp omnigent/openapi.json site/public/openapi.json
# Commit + push to a fixed branch and open a PR if one isn't
# already open. If a PR exists, the force-push updates it in place
# — so repeated spec changes collapse into a single rolling PR.
- name: Open or update sync PR
working-directory: site
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
if [ -z "$(git status --porcelain -- public/openapi.json)" ]; then
echo "openapi.json already in sync — nothing to do."
exit 0
fi
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
git switch -C "$SYNC_BRANCH"
git add public/openapi.json
git commit -m "chore(api): sync openapi.json from omnigent@${GITHUB_SHA:0:7}"
git push --force origin "$SYNC_BRANCH"
if [ -n "$(gh pr list --head "$SYNC_BRANCH" --state open --json number --jq '.[].number')" ]; then
echo "PR already open for $SYNC_BRANCH — the force-push updated it."
exit 0
fi
# Build the body with printf so YAML block indentation never
# leaks leading spaces into the Markdown.
short="${GITHUB_SHA:0:7}"
body="$(printf 'Automated sync of `public/openapi.json` from [omnigent@`%s`](https://github.com/%s/commit/%s).\n\nGenerated by `.github/workflows/sync-openapi-to-site.yml`. Merging publishes the updated API reference at `/reference`.' "$short" "$GITHUB_REPOSITORY" "$GITHUB_SHA")"
gh pr create \
--base main \
--head "$SYNC_BRANCH" \
--title "chore(api): sync OpenAPI reference from omnigent" \
--body "$body"
+5 -25
View File
@@ -113,37 +113,17 @@ jobs:
- name: Compare the baselines (no --update-snapshots)
# Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning
# baselines that already pass (a sub-threshold re-render still changes the
# bytes). In plain compare mode the plugin instead leaves passing
# baselines untouched and surfaces only the drift -- a mismatching
# baseline's fresh render lands in snapshot_failures/.../actual_*.png (the
# committed PNG is left in place), and a MISSING baseline is created
# directly under snapshots/. The run "fails" by design on any drift, so
# don't gate on its exit code.
# bytes). In plain compare mode the plugin leaves passing baselines
# untouched and rewrites only the drift: under GitHub Actions it updates a
# mismatching baseline IN PLACE (and creates a MISSING one) under
# snapshots/, so the tree below already holds exactly the changed PNGs.
# The run "fails" by design on any drift, so don't gate on its exit code.
run: |
uv run pytest tests/e2e_ui/visual -m visual \
-v --tb=long --log-level=INFO -r a \
-p no:rerunfailures \
--ui-skip-build || true
- name: Adopt only the changed renders over their baselines
# Copy each mismatching test's actual_<name>.png over its committed
# baseline; previously-missing baselines were already written under
# snapshots/ by the compare above. Baselines that passed are not in
# snapshot_failures, so they stay byte-for-byte unchanged.
run: |
fail_dir=tests/e2e_ui/visual/snapshot_failures
if [ -d "$fail_dir" ]; then
while IFS= read -r src; do
rel=${src#"$fail_dir"/} # <module>/<test>/actual_<name>.png
dest="tests/e2e_ui/visual/snapshots/$(dirname "$rel")/$(basename "$rel" | sed 's/^actual_//')"
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
echo "adopted: $dest"
done < <(find "$fail_dir" -type f -name 'actual_*.png')
else
echo "No snapshot_failures dir -- no existing baseline drifted (only new baselines, if any, were created)."
fi
# Tar the snapshots tree (paths intact) so the commit job can restore it
# wholesale. Only genuinely-changed/created PNGs differ from the committed
# tree, so git add in the commit job stages exactly those.
+49 -2
View File
@@ -22,7 +22,13 @@ name: UI Snapshot
# pull_request compare the rendered pages against the committed
# baselines; fail (with actual/expected/diff PNGs in the
# artifact) on any mismatch. No secrets, so fork PRs run
# fine.
# fine. The render job is gated on the `detect` job (below):
# a PR that touches none of the render inputs (ap-web, the
# visual tests + fixtures, the pinned toolchain) SKIPS the
# render. We gate at the job (not via `on: paths:`) on
# purpose -- a job skipped by `if` reports SUCCESS, so this
# stays safe to register as a required check, whereas a
# path-filtered *workflow* would sit "pending" and block.
# workflow_dispatch regenerate the baselines with --update-snapshots in the
# same pinned image; the regenerated PNGs are in the
# `ui-snapshot-<run_id>` artifact to download and commit.
@@ -45,6 +51,7 @@ on:
permissions:
contents: read
pull-requests: read # detect: list the PR's changed files
concurrency:
group: ui-snapshot-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.ref || github.ref }}
@@ -62,8 +69,48 @@ env:
UV_PYTHON_PREFERENCE: only-system
jobs:
# Cheap pre-flight (no container/build): does this PR touch anything that can
# change the render? The heavy job below is `if`-gated on it, so non-UI PRs
# skip the render (no wasted CI, no flaking against unrelated changes). The
# render is a pure function of the ap-web bundle + the visual tests + their
# shared fixtures + the pinned toolchain (npm pin, the image digest in THIS
# file, and the playwright/plugin versions in the lock), so watch exactly
# those. Fails open: if the file list can't be fetched, render rather than
# risk a false pass.
detect:
name: Detect render-affecting changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- id: changes
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
if [ -z "${PR:-}" ]; then
echo "ui=true" >> "$GITHUB_OUTPUT"; echo "no PR (dispatch) -> render"; exit 0
fi
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='^(ap-web/|tests/e2e_ui/visual/|tests/e2e_ui/conftest\.py|\.github/actions/setup-node/|\.github/workflows/ui-snapshot\.yml|pyproject\.toml|uv\.lock)'
if printf '%s\n' "$files" | grep -qE "$pattern"; then
echo "ui=true" >> "$GITHUB_OUTPUT"
echo "render-affecting files changed:"
printf '%s\n' "$files" | grep -E "$pattern" | sed 's/^/ /'
else
echo "ui=false" >> "$GITHUB_OUTPUT"
echo "no render-affecting files changed -> skip the render"
fi
ui-snapshot:
name: UI Snapshot (visual baselines)
name: UI Snapshot (visual baselines) [non-blocking]
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.
if: ${{ needs.detect.outputs.ui == 'true' }}
runs-on: ubuntu-24.04
# Render in the digest-pinned Playwright image (browsers + fonts baked in),
# so the committed baseline and the PR comparison are byte-identical and a
+3 -1
View File
@@ -73,6 +73,8 @@ omnigent/server/static/web-ui/
# bundle deploy` respects .gitignore for its file sync — gitignored
# wheels would silently fail to reach the deployed app's source folder
# and the install would error with "No such file or directory".
# The per-deploy app payload (src/pyproject.toml, src/uv.lock) is regenerated
# by deploy.py and likewise kept untracked rather than gitignored, for the same
# reason — `bundle deploy` must be able to sync it to the app source folder.
# DAB local state directory (created by `databricks bundle deploy`).
deploy/databricks/.databricks/
deploy/databricks/**/*.whl
+20 -11
View File
@@ -4,7 +4,7 @@
### The open-source AI agent framework and meta-harness for all your AI agents.
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
Omnigent is an open-source **AI agent framework** and meta-harness that gives you a common orchestration layer over Claude Code, Codex, Cursor, Kimi Code, Pi, and the agents you write yourself: swap or combine harnesses without rewriting, enforce policies and sandboxing, and collaborate in real time from any device.
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/omnigent-ai/omnigent/blob/main/LICENSE)
![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)
@@ -41,9 +41,12 @@ Omnigent lets you:
conversation to continue on their own.
- **☁️ Run agents in cloud sandboxes.** No laptop required: run sessions in
disposable [Modal](https://modal.com), [Daytona](https://www.daytona.io), or
[Islo](https://islo.dev) sandboxes, launched from the CLI or provisioned by
the server per session (*managed hosts*).
disposable [Modal](https://modal.com), [Daytona](https://www.daytona.io),
[Islo](https://islo.dev), [E2B](https://e2b.dev),
[CoreWeave](https://docs.coreweave.com/products/sandboxes),
[Kubernetes](https://kubernetes.io), [OpenShell](https://github.com/NVIDIA/OpenShell),
or [Boxlite](https://github.com/boxlite-ai/boxlite) sandboxes, launched from the
CLI or provisioned by the server per session (*managed hosts*).
- **🛡️ Govern your agents.** Create
[policies](#6-govern-your-agents-with-policies) to pause for your approval
@@ -94,15 +97,18 @@ uv tool install -q --python 3.12 git+https://github.com/omnigent-ai/omnigent.git
- **Node.js 22 LTS or newer** with **`npm`**, for the Claude, Codex, and Pi
coding harnesses. `omnigent run` installs the harness CLI you pick.
https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
- **`tmux`**, required by the native `omnigent claude` / `omnigent codex`
- **Kiro CLI** (optional), for `omnigent kiro`: install with
`curl -fsSL https://cli.kiro.dev/install | bash`, then sign in with Kiro.
- **`tmux`**, required by the native `omnigent claude` / `omnigent codex` /
`omnigent kiro`
wrappers (`brew install tmux` / `apt install tmux`; the installer offers
to install it for you).
- **`bubblewrap`** (`bwrap`), **Linux only**. The native `omnigent claude` /
`omnigent codex` and `pi` harnesses wrap each agent terminal in a `bwrap`
OS-sandbox; on Linux that isolation is mandatory, so a missing `bwrap`
binary makes those terminals fail to start (`apt install bubblewrap`; the
installer offers to install it for you). macOS uses the built-in `seatbelt`
sandbox and needs nothing extra.
`omnigent codex` / `omnigent kiro` and `pi` harnesses wrap each agent
terminal in a `bwrap` OS-sandbox; on Linux that isolation is mandatory, so a
missing `bwrap` binary makes those terminals fail to start
(`apt install bubblewrap`; the installer offers to install it for you). macOS
uses the built-in `seatbelt` sandbox and needs nothing extra.
- **Databricks** (optional). To use a Databricks workspace as your model
provider, install Omnigent with the `databricks` extra:
`uv tool install "omnigent[databricks]"` — or pass it to the bootstrap
@@ -161,6 +167,8 @@ Or launch a specific agent runtime, or your own agent:
```bash
omnigent claude # Claude Code, in a session your team can join
omnigent codex # Codex
omnigent kiro # Kiro CLI
omnigent kimi # Kimi Code (https://kimi.com), headless
omnigent run path/to/agent.yaml # your own agent (see "Write your own agent")
```
@@ -177,6 +185,7 @@ omnigent run examples/scribe/
omnigent run examples/polly/ --harness pi
omnigent run examples/debby/ --harness openai-agents
omnigent run examples/polly/ --harness cursor # Cursor CLI (needs cursor-agent + CURSOR_API_KEY)
omnigent run examples/polly/ --harness copilot # GitHub Copilot SDK (needs a GitHub token w/ Copilot, e.g. GH_TOKEN)
```
**🐙 Polly** is a multi-agent coding orchestrator who writes no code herself.
@@ -375,7 +384,7 @@ name: my_agent
prompt: You are a helpful data analyst.
executor:
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, openai-agents, pi, pi-native, antigravity, qwen
harness: claude-sdk # or: claude-native, codex, codex-native, cursor, cursor-native, kiro-native, openai-agents, pi, pi-native, antigravity, qwen, kimi, copilot
tools:
# A local Python function (schema auto-generated from the signature)
+1
View File
@@ -9,6 +9,7 @@
"scripts": {
"start": "electron .",
"dev": "electron .",
"test": "node --test",
"build": "electron-builder",
"build:mac": "electron-builder --mac",
"build:mac:release": "electron-builder --mac -c.mac.notarize=true",
+4 -19
View File
@@ -180,16 +180,17 @@
<div id="recents-list"></div>
</div>
</div>
<script src="../src/url.js"></script>
<script>
// Shared URL helpers (electron/src/url.js), exposed as window.omnigentUrl
// — the same module the main process uses, so the two never drift.
const { isPlainHttpRemote } = window.omnigentUrl;
// Uses the Electron preload bridge (electron/src/preload.js).
const setup = window.omnigentSetup;
const input = document.getElementById("url");
const button = document.getElementById("connect");
const err = document.getElementById("err");
// Hosts where plain http:// is fine (no network path to speak of).
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
// The main process loads this page with ?error=…&url=… when a server
// navigation fails (server down, DNS, TLS), so the user sees what went
// wrong and can retry or change the URL.
@@ -254,22 +255,6 @@
})
.catch(() => {});
// True when the entered URL is unencrypted http:// to a non-local
// host. Mirrors the scheme-defaulting of the main process's
// normalizeUrl; invalid URLs return false so the real error comes
// from normalizeUrl on Connect.
function isPlainHttpRemote(raw) {
const trimmed = (raw || "").trim();
const withScheme = trimmed.includes("://") ? trimmed : "http://" + trimmed;
let url;
try {
url = new URL(withScheme);
} catch {
return false;
}
return url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname);
}
// The exact URL value the user has already been warned about — a
// second Connect click on the same value proceeds; editing the input
// re-arms the warning.
+1 -94
View File
@@ -31,6 +31,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { registerLocalhostCors } = require("./localhost_cors");
const { normalizeUrl, expandDatabricksWorkspaceUrl, WORKSPACE_UI_PATH } = require("./url");
/** Absolute path to the bundled setup page (the "connect to server" form). */
const SETUP_PAGE = path.join(__dirname, "..", "setup", "index.html");
@@ -596,44 +597,6 @@ function rememberRecentServer(settings, url) {
].slice(0, MAX_RECENT_SERVERS);
}
/**
* Normalize a user-entered server URL into something navigable. Accepts bare
* `host:port` (assumes http), trims whitespace, and rejects anything that
* isn't an http(s) URL — fail loud rather than navigate to garbage.
*
* @param {string} raw
* @returns {string} A normalized absolute http(s) URL.
*/
function normalizeUrl(raw) {
const trimmed = (raw ?? "").trim();
if (trimmed === "") throw new Error("server URL is empty");
const withScheme = trimmed.includes("://") ? trimmed : `http://${trimmed}`;
let url;
try {
url = new URL(withScheme);
} catch (e) {
throw new Error(`invalid URL: ${e.message}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`unsupported scheme '${url.protocol}' (use http/https)`);
}
return url.toString();
}
/**
* Path under a Databricks workspace where the Omnigent web UI is mounted. A
* bare workspace URL serves the workspace's own web app at the root, so a user
* who pastes just the workspace host (e.g.
* ``https://<ws>.azuredatabricks.net``) lands on a 404 unless this suffix is
* appended.
*
* NOTE: the Python CLI records the same UI mount as ``/ml/omnigent``
* (singular) in ``omnigent/conversation_browser.py`` (WORKSPACE_UI_PATH); the
* plural here is the path that actually resolves on the live workspace. The
* two should be reconciled — see also that file's WORKSPACE_API_PATH.
*/
const WORKSPACE_UI_PATH = "/ml/omnigents";
/**
* CSS that hides the Databricks workspace navigation chrome around a
* workspace-hosted Omnigent SPA.
@@ -657,62 +620,6 @@ const WORKSPACE_CHROME_HIDE_CSS = `
}
`;
/**
* Probe timeout for Databricks workspace detection. Deliberately short: a slow
* or unreachable host must not stall the connect flow — on timeout we fall
* back to loading the URL exactly as entered.
*/
const WORKSPACE_PROBE_TIMEOUT_MS = 8000;
/**
* Expand a bare Databricks workspace URL to its Omnigent web-UI mount.
*
* Mirrors the omni CLI's behavioral detection
* (``omnigent/cli.py:_workspace_api_server_url``): rather than match
* hostnames, probe the URL and adopt the mount only when the host answers
* like a Databricks workspace — a response carrying the ``server: databricks``
* header. URLs that already carry a path, or aren't https, are returned
* untouched WITHOUT a probe, so a user who pastes the full ``…/ml/omnigents``
* URL (or connects to any non-workspace server) is never second-guessed.
*
* The CLI appends the API mount because it's an API client; the desktop shell
* loads the web UI, so it appends the SPA mount instead.
*
* @param {string} normalized A normalized http(s) URL from {@link normalizeUrl}.
* @returns {Promise<string>} The workspace UI URL when expansion applies, else
* the input unchanged.
*/
async function expandDatabricksWorkspaceUrl(normalized) {
let url;
try {
url = new URL(normalized);
} catch {
return normalized;
}
// Only bare https roots are candidates: a non-root path means the user
// already pointed at a specific mount, and Databricks workspaces are
// https-only.
if (url.protocol !== "https:" || (url.pathname !== "/" && url.pathname !== "")) {
return normalized;
}
let probe;
try {
probe = await fetch(`${url.origin}/`, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(WORKSPACE_PROBE_TIMEOUT_MS),
});
} catch {
// Unreachable / DNS / TLS / timeout: connect to the URL as given and let
// the did-fail-load fallback surface any real failure.
return normalized;
}
if ((probe.headers.get("server") ?? "").toLowerCase() !== "databricks") {
return normalized;
}
return `${url.origin}${WORKSPACE_UI_PATH}`;
}
// ---------------------------------------------------------------------------
// Window + navigation
// ---------------------------------------------------------------------------
+182
View File
@@ -0,0 +1,182 @@
// Shared URL-normalization helpers for the desktop shell.
//
// Loaded by both the Electron main process (`require("./url")` in
// `src/main.js`) and the bundled setup page (`<script src="../src/url.js">` in
// `setup/index.html`, where it publishes `window.omnigentUrl`). One copy keeps
// the two from drifting — the setup page's plain-http warning and the main
// process's navigation must agree on what a bare URL means.
//
// Only web/Node globals (URL, fetch, AbortSignal) are used, so the same source
// runs unchanged under CommonJS (main) and in the renderer (setup page).
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) {
module.exports = api;
} else {
root.omnigentUrl = api;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
/**
* Hostnames that resolve to the local machine. A schemeless URL defaults to
* https:// (the workspace / remote case the internal user guide documents),
* but these default to http:// — local dev servers are virtually always plain
* http, and the setup placeholder shows http://localhost.
*/
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
/**
* The scheme a schemeless input should default to: http:// for loopback
* hosts (local dev is plain http), https:// for everything else (the pasted
* workspace-URL case). Unparseable input falls back to https:// so the
* caller's own URL parse raises the real error.
*
* @param {string} trimmed A trimmed, scheme-less `host[:port][/path]`.
* @returns {"http" | "https"}
*/
function defaultSchemeFor(trimmed) {
let host;
try {
host = new URL(`https://${trimmed}`).hostname;
} catch {
host = "";
}
return LOCAL_HOSTS.has(host) ? "http" : "https";
}
/**
* Normalize a user-entered server URL into something navigable. Accepts a
* bare `host[:port][/path]` and defaults the scheme (https://, or http:// for
* loopback hosts), trims whitespace, and rejects anything that isn't an
* http(s) URL — fail loud rather than navigate to garbage.
*
* @param {string} raw
* @returns {string} A normalized absolute http(s) URL.
*/
function normalizeUrl(raw) {
const trimmed = (raw ?? "").trim();
if (trimmed === "") throw new Error("server URL is empty");
const withScheme = trimmed.includes("://")
? trimmed
: `${defaultSchemeFor(trimmed)}://${trimmed}`;
let url;
try {
url = new URL(withScheme);
} catch (e) {
throw new Error(`invalid URL: ${e.message}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`unsupported scheme '${url.protocol}' (use http/https)`);
}
return url.toString();
}
/**
* True when the entered URL is unencrypted http:// to a non-local host — the
* setup page warns before connecting. Mirrors normalizeUrl's scheme-
* defaulting (https:// by default, http:// for loopback), so a bare remote
* host — now https — does not trip the warning; only an explicit http:// to a
* remote host does. Invalid URLs return false so the real error comes from
* normalizeUrl on Connect.
*
* @param {string} raw
* @returns {boolean}
*/
function isPlainHttpRemote(raw) {
const trimmed = (raw || "").trim();
if (trimmed === "") return false;
const withScheme = trimmed.includes("://")
? trimmed
: `${defaultSchemeFor(trimmed)}://${trimmed}`;
let url;
try {
url = new URL(withScheme);
} catch {
return false;
}
return url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname);
}
/**
* Path under a Databricks workspace where the Omnigent web UI is mounted. A
* bare workspace URL serves the workspace's own web app at the root, so a
* user who pastes just the workspace host (e.g.
* ``https://<ws>.azuredatabricks.net``) lands on a 404 unless this suffix is
* appended.
*
* NOTE: the Python CLI records the UI mount as ``/omnigent`` in
* ``omnigent/conversation_browser.py`` (WORKSPACE_UI_PATH), whereas the
* desktop deliberately keeps ``/ml/omnigents`` for now — that is the path the
* live workspace serves the embedded SPA on. The two are intentionally
* divergent pending reconciliation; do not "fix" this to ``/omnigent``
* without verifying what the workspace actually serves to the desktop shell.
*/
const WORKSPACE_UI_PATH = "/ml/omnigents";
/**
* Probe timeout for Databricks workspace detection. Deliberately short: a
* slow or unreachable host must not stall the connect flow — on timeout we
* fall back to loading the URL exactly as entered.
*/
const WORKSPACE_PROBE_TIMEOUT_MS = 8000;
/**
* Expand a bare Databricks workspace URL to its Omnigent web-UI mount.
*
* Mirrors the omni CLI's behavioral detection
* (``omnigent/cli.py:_workspace_api_server_url``): rather than match
* hostnames, probe the URL and adopt the mount only when the host answers
* like a Databricks workspace — a response carrying the ``server: databricks``
* header. URLs that already carry a path, or aren't https, are returned
* untouched WITHOUT a probe, so a user who pastes the full ``…/ml/omnigents``
* URL (or connects to any non-workspace server) is never second-guessed.
*
* The CLI appends the API mount because it's an API client; the desktop shell
* loads the web UI, so it appends the SPA mount instead.
*
* @param {string} normalized A normalized http(s) URL from normalizeUrl().
* @returns {Promise<string>} The workspace UI URL when expansion applies,
* else the input unchanged.
*/
async function expandDatabricksWorkspaceUrl(normalized) {
let url;
try {
url = new URL(normalized);
} catch {
return normalized;
}
// Only bare https roots are candidates: a non-root path means the user
// already pointed at a specific mount, and Databricks workspaces are
// https-only.
if (url.protocol !== "https:" || (url.pathname !== "/" && url.pathname !== "")) {
return normalized;
}
let probe;
try {
probe = await fetch(`${url.origin}/`, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(WORKSPACE_PROBE_TIMEOUT_MS),
});
} catch {
// Unreachable / DNS / TLS / timeout: connect to the URL as given and let
// the did-fail-load fallback surface any real failure.
return normalized;
}
if ((probe.headers.get("server") ?? "").toLowerCase() !== "databricks") {
return normalized;
}
return `${url.origin}${WORKSPACE_UI_PATH}`;
}
return {
LOCAL_HOSTS,
defaultSchemeFor,
normalizeUrl,
isPlainHttpRemote,
WORKSPACE_UI_PATH,
WORKSPACE_PROBE_TIMEOUT_MS,
expandDatabricksWorkspaceUrl,
};
});
+196
View File
@@ -0,0 +1,196 @@
// Tests for the shared desktop URL helpers (src/url.js), run with
// `node --test` (no extra deps). Covers the scheme-defaulting that lets a
// pasted workspace URL (schemeless, /omnigent suffix from the internal user
// guide) connect, the plain-http warning, and the workspace probe/expansion.
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
defaultSchemeFor,
normalizeUrl,
isPlainHttpRemote,
expandDatabricksWorkspaceUrl,
WORKSPACE_UI_PATH,
} = require("../src/url");
describe("defaultSchemeFor", () => {
it("defaults remote hosts to https", () => {
assert.equal(defaultSchemeFor("dbc-x.cloud.databricks.com/omnigent"), "https");
assert.equal(defaultSchemeFor("example.com"), "https");
});
it("defaults loopback hosts to http", () => {
assert.equal(defaultSchemeFor("localhost:6767"), "http");
assert.equal(defaultSchemeFor("127.0.0.1:6767"), "http");
assert.equal(defaultSchemeFor("[::1]:6767"), "http");
});
it("defaults unparseable input to https", () => {
assert.equal(defaultSchemeFor("exa mple"), "https");
});
});
describe("normalizeUrl", () => {
it("defaults a schemeless workspace /omnigent URL to https", () => {
assert.equal(
normalizeUrl("dbc-a5d4177a-49dc.cloud.databricks.com/omnigent"),
"https://dbc-a5d4177a-49dc.cloud.databricks.com/omnigent",
);
});
it("defaults a bare remote host to https", () => {
assert.equal(
normalizeUrl("example.cloud.databricks.com"),
"https://example.cloud.databricks.com/",
);
});
it("defaults loopback hosts to http", () => {
assert.equal(normalizeUrl("localhost:6767"), "http://localhost:6767/");
assert.equal(normalizeUrl("127.0.0.1:6767"), "http://127.0.0.1:6767/");
assert.equal(normalizeUrl("[::1]:6767"), "http://[::1]:6767/");
});
it("preserves an explicit scheme (even http to a remote host)", () => {
assert.equal(normalizeUrl("http://localhost:6767"), "http://localhost:6767/");
assert.equal(normalizeUrl("https://example.com"), "https://example.com/");
assert.equal(normalizeUrl("http://example.databricks.com"), "http://example.databricks.com/");
});
it("trims surrounding whitespace", () => {
assert.equal(normalizeUrl(" example.com/omnigent "), "https://example.com/omnigent");
});
it("rejects empty input", () => {
assert.throws(() => normalizeUrl(""), /server URL is empty/);
assert.throws(() => normalizeUrl(" "), /server URL is empty/);
});
it("rejects a non-http(s) scheme", () => {
assert.throws(() => normalizeUrl("ftp://example.com"), /unsupported scheme/);
});
});
describe("isPlainHttpRemote", () => {
it("does not warn for a bare remote host (now https)", () => {
assert.equal(isPlainHttpRemote("example.databricks.com"), false);
assert.equal(isPlainHttpRemote("dbc-x.cloud.databricks.com/omnigent"), false);
});
it("warns for an explicit http:// to a remote host", () => {
assert.equal(isPlainHttpRemote("http://example.databricks.com"), true);
});
it("does not warn for loopback hosts", () => {
assert.equal(isPlainHttpRemote("localhost:6767"), false);
assert.equal(isPlainHttpRemote("http://localhost:6767"), false);
assert.equal(isPlainHttpRemote("http://127.0.0.1:6767"), false);
});
it("does not warn for https or empty/invalid input", () => {
assert.equal(isPlainHttpRemote("https://example.databricks.com"), false);
assert.equal(isPlainHttpRemote(""), false);
assert.equal(isPlainHttpRemote("ht tp://nope"), false);
});
});
/**
* Run `fn` with `globalThis.fetch` swapped for `stub` and `AbortSignal.timeout`
* neutralized (no real timer), restoring both afterward.
*/
async function withFetch(stub, fn) {
const realFetch = globalThis.fetch;
const realTimeout = AbortSignal.timeout;
globalThis.fetch = stub;
AbortSignal.timeout = () => new AbortController().signal;
try {
return await fn();
} finally {
globalThis.fetch = realFetch;
AbortSignal.timeout = realTimeout;
}
}
/** A minimal Response stand-in exposing only `.headers.get`. */
function fakeResponse(serverHeader) {
return { headers: { get: (name) => (name === "server" ? serverHeader : null) } };
}
describe("expandDatabricksWorkspaceUrl", () => {
it("expands a bare https Databricks workspace root to the UI mount", async () => {
const calls = [];
await withFetch(
async (url, opts) => {
calls.push({ url, method: opts.method });
return fakeResponse("databricks");
},
async () => {
const out = await expandDatabricksWorkspaceUrl("https://ws.cloud.databricks.com/");
assert.equal(out, `https://ws.cloud.databricks.com${WORKSPACE_UI_PATH}`);
},
);
// Probed the root with a HEAD request.
assert.deepEqual(calls, [{ url: "https://ws.cloud.databricks.com/", method: "HEAD" }]);
});
it("leaves a non-Databricks root unchanged", async () => {
await withFetch(
async () => fakeResponse("nginx"),
async () => {
assert.equal(
await expandDatabricksWorkspaceUrl("https://example.com"),
"https://example.com",
);
},
);
});
it("leaves a URL that already carries a path untouched, without probing", async () => {
let probed = false;
await withFetch(
async () => {
probed = true;
return fakeResponse("databricks");
},
async () => {
const url = "https://ws.cloud.databricks.com/omnigent";
assert.equal(await expandDatabricksWorkspaceUrl(url), url);
},
);
assert.equal(probed, false);
});
it("leaves a non-https URL untouched, without probing", async () => {
let probed = false;
await withFetch(
async () => {
probed = true;
return fakeResponse("databricks");
},
async () => {
assert.equal(
await expandDatabricksWorkspaceUrl("http://localhost:6767/"),
"http://localhost:6767/",
);
},
);
assert.equal(probed, false);
});
it("falls back to the input when the probe fails", async () => {
await withFetch(
async () => {
throw new Error("ECONNREFUSED");
},
async () => {
const url = "https://unreachable.example.com";
assert.equal(await expandDatabricksWorkspaceUrl(url), url);
},
);
});
it("returns unparseable input unchanged", async () => {
assert.equal(await expandDatabricksWorkspaceUrl("not a url"), "not a url");
});
});
+2 -2
View File
@@ -18,7 +18,7 @@ struct ChatTerminalBar: View {
segment(.chat, title: "Chat", systemImage: "message")
segment(.terminal, title: "Terminal", systemImage: "terminal")
}
.padding(4)
.padding(InsetMetrics.barCapsulePadding)
.modifier(GlassCapsule(colorScheme: colorScheme))
.animation(.easeInOut(duration: 0.18), value: mode)
.accessibilityElement(children: .contain)
@@ -50,7 +50,7 @@ struct ChatTerminalBar: View {
? DesignTokens.foreground(colorScheme) : DesignTokens.mutedForeground(colorScheme)
)
.padding(.horizontal, 14)
.frame(height: 34)
.frame(height: InsetMetrics.barSegmentHeight)
.background {
if isSelected {
Capsule(style: .continuous)
+33 -9
View File
@@ -55,25 +55,26 @@ struct ConnectView: View {
}
.submitLabel(.go)
.onSubmit(connect)
.disabled(isConnecting)
}
Button(action: connect) {
if isConnecting {
ProgressView()
.tint(primaryForeground)
HStack(spacing: 8) {
ProgressView()
.tint(primaryForeground)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.buttonStyle(.plain)
.font(.system(size: 14, weight: .medium))
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(primary)
.foregroundStyle(primaryForeground)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.radius))
.buttonStyle(PrimaryButtonStyle(background: primary, foreground: primaryForeground))
.padding(.top, 16)
.disabled(isConnecting)
// Fires the moment connect() flips isConnecting, so the tap is
// acknowledged by touch even before the spinner appears.
.sensoryFeedback(.impact(weight: .light), trigger: isConnecting)
Text(message ?? "")
.font(.system(size: 13))
@@ -110,6 +111,7 @@ struct ConnectView: View {
}
}
.padding(.top, 12)
.disabled(isConnecting)
}
}
.frame(maxWidth: 384)
@@ -152,6 +154,28 @@ struct ConnectView: View {
}
}
// Primary (filled) button appearance plus an instant touch-down response.
// `.buttonStyle(.plain)` gave no press feedback, so the tap felt dead until
// the spinner swapped in; the opacity/scale here acknowledges the press the
// moment the finger lands.
private struct PrimaryButtonStyle: ButtonStyle {
let background: Color
let foreground: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 14, weight: .medium))
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(background)
.foregroundStyle(foreground)
.clipShape(RoundedRectangle(cornerRadius: DesignTokens.radius))
.opacity(configuration.isPressed ? 0.85 : 1)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
private let defaultServerURL: String = {
#if DEBUG
"http://localhost:6767"
+36
View File
@@ -42,6 +42,15 @@ struct OmnigentWebView: UIViewRepresentable {
webView.scrollView.backgroundColor = .clear
webView.scrollView.contentInsetAdjustmentBehavior = .never
// Allow Safari Web Inspector to attach to the web content. Since iOS 16.4 a
// WKWebView is inspectable only when this is opt-in. Debug-only so shipping
// builds aren't inspectable.
#if DEBUG
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
#endif
let edgePan = UIScreenEdgePanGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleLeftEdgePan(_:))
@@ -135,6 +144,21 @@ struct OmnigentWebView: UIViewRepresentable {
try { callback(mode); } catch {}
}
});
const insetCallbacks = new Set();
// Cache the last footprint so a subscriber that registers AFTER native
// first emitted (the React app mounts later than document-start) still
// gets the current value immediately on subscribe.
let lastInsets = null;
defineEmit("__omnigentNativeEmitInsets", (topBar, bottomBar) => {
const insets = {
topBar: typeof topBar === "number" && Number.isFinite(topBar) ? topBar : 0,
bottomBar: typeof bottomBar === "number" && Number.isFinite(bottomBar) ? bottomBar : 0,
};
lastInsets = insets;
for (const callback of insetCallbacks) {
try { callback(insets); } catch {}
}
});
const sidebarDragCallbacks = new Set();
Object.defineProperty(window, "__omnigentNativeEmitSidebarDrag", {
configurable: false,
@@ -208,6 +232,12 @@ struct OmnigentWebView: UIViewRepresentable {
viewModeCallbacks.add(callback);
return () => viewModeCallbacks.delete(callback);
},
onNativeInsets(callback) {
if (typeof callback !== "function") return () => {};
insetCallbacks.add(callback);
if (lastInsets) { try { callback(lastInsets); } catch {} }
return () => insetCallbacks.delete(callback);
},
});
})();
"""
@@ -230,6 +260,7 @@ struct OmnigentWebView: UIViewRepresentable {
}
func detach() {
parent.model.cancelServerSwitcherWatchdog()
webView = nil
}
@@ -284,6 +315,9 @@ struct OmnigentWebView: UIViewRepresentable {
_ userContentController: WKUserContentController, didReceive message: WKScriptMessage
) {
guard isTrustedBridgeMessage(message) else { return }
// Any trusted message proves the page is alive and driving the bridge, so
// stand down the liveness watchdog the page owns the switcher from here.
parent.model.cancelServerSwitcherWatchdog()
guard let body = message.body as? [String: Any],
let method = body["method"] as? String
else { return }
@@ -322,6 +356,7 @@ struct OmnigentWebView: UIViewRepresentable {
parent.model.isLoading = true
parent.model.currentURL = webView.url ?? parent.model.currentURL
parent.model.serverSwitcherHidden = true
parent.model.armServerSwitcherWatchdog()
}
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
@@ -468,6 +503,7 @@ struct OmnigentWebView: UIViewRepresentable {
let nsError = error as NSError
guard nsError.code != NSURLErrorCancelled else { return }
parent.model.isLoading = false
parent.model.cancelServerSwitcherWatchdog()
let failedURL = failedURL(from: nsError) ?? webView.url ?? pinnedURL ?? parent.initialURL
guard failedURL.omnigentOrigin == pinnedOrigin else { return }
+35 -3
View File
@@ -33,7 +33,7 @@ struct WebShellView: View {
connectToNewServer: connectToNewServer,
reload: model.reload
)
.padding(.top, 8)
.padding(.top, InsetMetrics.serverSwitcherTopPadding)
.opacity(model.serverSwitcherHidden ? 0 : 1)
.scaleEffect(model.serverSwitcherHidden ? 0.96 : 1, anchor: .top)
.allowsHitTesting(!model.serverSwitcherHidden)
@@ -56,7 +56,7 @@ struct WebShellView: View {
model.emitViewModeChanged(newMode)
}
)
.padding(.bottom, 6)
.padding(.bottom, InsetMetrics.barBottomPadding)
.opacity(model.bottomBarVisible ? 1 : 0)
.allowsHitTesting(model.bottomBarVisible)
.accessibilityHidden(!model.bottomBarVisible)
@@ -69,6 +69,16 @@ struct WebShellView: View {
model.emitNotificationActivation(path)
}
}
.onChange(of: model.isLoading) { _, loading in
// Re-push the native bar footprints once each load completes; the JS
// bridge caches the value so a later-mounting subscriber still gets it.
if !loading {
model.emitInsets(
topBar: InsetMetrics.topBarFootprint,
bottomBar: InsetMetrics.bottomBarFootprint
)
}
}
}
private func switchServer(_ urlString: String) {
@@ -141,7 +151,7 @@ private struct ServerSwitcher: View {
.font(.system(size: 12))
.foregroundStyle(DesignTokens.foreground(colorScheme))
.padding(.horizontal, 10)
.frame(height: 28)
.frame(height: InsetMetrics.serverSwitcherHeight)
.frame(maxWidth: maxWidth)
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
}
@@ -166,3 +176,25 @@ private enum ServerSwitcherMetrics {
min(172, max(120, containerWidth * 0.38))
}
}
/// Single source of truth for the floating native bars' dimensions. These drive
/// both the SwiftUI layout (the `.frame`/`.padding` calls above and in
/// `ChatTerminalBar`) and the footprint pushed to the web layer via
/// `WebViewModel.emitInsets`, so the web's content insets can never drift from
/// the bars' real size. Values are CSS points, excluding the OS safe area (the
/// web layer adds that with `env(safe-area-inset-*)`).
enum InsetMetrics {
// Server switcher the top floating pill.
static let serverSwitcherHeight: CGFloat = 28
static let serverSwitcherTopPadding: CGFloat = 8
static var topBarFootprint: CGFloat { serverSwitcherHeight + serverSwitcherTopPadding }
// Chat/Terminal bar the bottom floating capsule. The capsule wraps the
// segment row (`barSegmentHeight`) in `barCapsulePadding` on every side.
static let barSegmentHeight: CGFloat = 34
static let barCapsulePadding: CGFloat = 4
static let barBottomPadding: CGFloat = 6
static var bottomBarFootprint: CGFloat {
barSegmentHeight + barCapsulePadding * 2 + barBottomPadding
}
}
+46
View File
@@ -24,10 +24,39 @@ final class WebViewModel: ObservableObject {
weak var webView: WKWebView?
/// How long, after a navigation begins, we wait for the web app to prove it's
/// alive by talking over the JS bridge. If the page never speaks within this
/// window a blank render, crashed JS, or a hang that never reaches
/// `didFinish` we surface the server switcher so the user is never stranded
/// on a broken page with no way back to server selection.
private static let bridgeLivenessTimeout: TimeInterval = 6
private var serverSwitcherWatchdog: Task<Void, Never>?
func reload() {
webView?.reload()
}
/// Arm (or re-arm) the liveness watchdog. Called whenever a navigation
/// begins. The switcher has just been hidden for the load; if the page never
/// claims it back over the bridge, the watchdog reveals it as an escape hatch.
func armServerSwitcherWatchdog() {
serverSwitcherWatchdog?.cancel()
serverSwitcherWatchdog = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.bridgeLivenessTimeout * 1_000_000_000))
guard !Task.isCancelled, let self else { return }
self.serverSwitcherHidden = false
self.serverSwitcherWatchdog = nil
}
}
/// Stand the watchdog down. Called the moment the page proves it's alive over
/// the bridge, and when a load fails (we route to server selection anyway).
func cancelServerSwitcherWatchdog() {
serverSwitcherWatchdog?.cancel()
serverSwitcherWatchdog = nil
}
func emitNotificationActivation(_ path: String) {
guard path.starts(with: "/") else { return }
let script =
@@ -35,6 +64,17 @@ final class WebViewModel: ObservableObject {
webView?.evaluateJavaScript(script)
}
/// Push the footprint (in CSS px, excluding the OS safe area which the web
/// layer adds via `env()`) of the native floating bars to the web app. The
/// web side folds these into its `--omnigent-inset-*` variables so page
/// content reserves the right amount of space making native bar dimensions
/// the single source of truth instead of magic numbers duplicated in CSS.
func emitInsets(topBar: CGFloat, bottomBar: CGFloat) {
let script =
"window.__omnigentNativeEmitInsets?.(\(jsNumber(topBar)), \(jsNumber(bottomBar)));"
webView?.evaluateJavaScript(script)
}
/// Tell the web app the user tapped a segment in the native switcher.
func emitViewModeChanged(_ mode: WebViewMode) {
let script =
@@ -49,6 +89,12 @@ final class WebViewModel: ObservableObject {
webView?.evaluateJavaScript(script)
}
/// Format a CGFloat as a bare JS number literal (no units, finite-guarded).
private func jsNumber(_ value: CGFloat) -> String {
guard value.isFinite else { return "0" }
return String(format: "%g", Double(value))
}
static func javascriptString(_ value: String) -> String {
guard let data = try? JSONEncoder().encode(value),
let encoded = String(data: data, encoding: .utf8)
-66
View File
@@ -2264,9 +2264,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2284,9 +2281,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2304,9 +2298,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2324,9 +2315,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2344,9 +2332,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2364,9 +2349,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2384,9 +2366,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2404,9 +2383,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4918,9 +4894,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4938,9 +4911,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4958,9 +4928,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4978,9 +4945,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4998,9 +4962,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5018,9 +4979,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5566,9 +5524,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5586,9 +5541,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5606,9 +5558,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5626,9 +5575,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -11374,9 +11320,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11398,9 +11341,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11422,9 +11362,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11446,9 +11383,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+10
View File
@@ -26,6 +26,9 @@ const ApprovePage = lazy(() =>
import("@/pages/ApprovePage").then((m) => ({ default: m.ApprovePage })),
);
const InboxPage = lazy(() => import("@/pages/InboxPage").then((m) => ({ default: m.InboxPage })));
const SettingsPage = lazy(() =>
import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage })),
);
interface AppProps {
/**
@@ -120,6 +123,13 @@ function App({ basename }: AppProps = {}) {
<Route path={prefix || "/"} element={<ChatPage />} />
<Route path={`${prefix}/c/:conversationId`} element={<ChatPage />} />
<Route path={`${prefix}/inbox`} element={<InboxPage />} />
{/* Settings renders into the chat outlet so the conversations
sidebar stays put — entering settings only swaps the card's
content (the section nav) and the main area. The active section
is carried in the URL (/settings/<section>); bare /settings
defaults to Appearance. */}
<Route path={`${prefix}/settings`} element={<SettingsPage />} />
<Route path={`${prefix}/settings/:section`} element={<SettingsPage />} />
{info.accounts_enabled && (
<>
<Route path={`${prefix}/members`} element={<MembersPage />} />
+6
View File
@@ -20,7 +20,9 @@ vi.mock("@/components/icons/CodexIcon", () => ({ CodexIcon: stub("codex") }));
vi.mock("@/components/icons/CursorIcon", () => ({ CursorIcon: stub("cursor") }));
vi.mock("@/components/icons/GooseIcon", () => ({ GooseIcon: stub("goose") }));
vi.mock("@/components/icons/NessieIcon", () => ({ NessieIcon: stub("nessie") }));
vi.mock("@/components/icons/OpenCodeIcon", () => ({ OpenCodeIcon: stub("opencode") }));
vi.mock("@/components/icons/PiIcon", () => ({ PiIcon: stub("pi") }));
vi.mock("@/components/icons/AntigravityIcon", () => ({ AntigravityIcon: stub("antigravity") }));
vi.mock("lucide-react", () => ({ BotIcon: stub("bot") }));
function agent(overrides: Partial<AvailableAgent> = {}): AvailableAgent {
@@ -48,6 +50,7 @@ describe("AgentCard icon selection", () => {
// "design-reviewer" must still read as Codex, not fall back to bot.
{ name: "design-reviewer", harness: "codex", expected: "codex" },
{ name: "codex-native-ui", harness: "codex-native", expected: "codex" },
{ name: "opencode-native-ui", harness: "opencode-native", expected: "opencode" },
{ name: "claude-native-ui", harness: "claude-native", expected: "claude" },
{ name: "pi-native-ui", harness: "pi-native", expected: "pi" },
{ name: "cursor-native-ui", harness: "cursor-native", expected: "cursor" },
@@ -58,6 +61,9 @@ describe("AgentCard icon selection", () => {
{ name: "x", harness: "goose", expected: "goose" },
// The SDK "cursor" harness also reads as Cursor via the harness fallback.
{ name: "x", harness: "cursor", expected: "cursor" },
{ name: "antigravity-native-ui", harness: "antigravity-native", expected: "antigravity" },
// The in-process Antigravity SDK harness shares the same glyph.
{ name: "x", harness: "antigravity", expected: "antigravity" },
{ name: "x", harness: "claude-sdk", expected: "claude" },
{ name: "pi", harness: "pi", expected: "pi" },
// The pi match is exact: a harness merely containing "pi" stays generic.
+13
View File
@@ -1,9 +1,12 @@
import { BotIcon } from "lucide-react";
import { AntigravityIcon } from "@/components/icons/AntigravityIcon";
import { ClaudeIcon } from "@/components/icons/ClaudeIcon";
import { CodexIcon } from "@/components/icons/CodexIcon";
import { CursorIcon } from "@/components/icons/CursorIcon";
import { GooseIcon } from "@/components/icons/GooseIcon";
import { KimiIcon } from "@/components/icons/KimiIcon";
import { NessieIcon } from "@/components/icons/NessieIcon";
import { OpenCodeIcon } from "@/components/icons/OpenCodeIcon";
import { PiIcon } from "@/components/icons/PiIcon";
import type { ComponentType, SVGProps } from "react";
import type { AvailableAgent } from "@/hooks/useAvailableAgents";
@@ -28,18 +31,28 @@ function iconForAgent(agent: AvailableAgent): ComponentType<SVGProps<SVGSVGEleme
const nativeAgent = nativeCodingAgentForAvailableAgent(agent);
if (nativeAgent?.iconKind === "claude") return ClaudeIcon;
if (nativeAgent?.iconKind === "codex") return CodexIcon;
if (nativeAgent?.iconKind === "opencode") return OpenCodeIcon;
if (nativeAgent?.iconKind === "pi") return PiIcon;
if (nativeAgent?.iconKind === "cursor") return CursorIcon;
if (nativeAgent?.iconKind === "kiro") return CursorIcon;
if (nativeAgent?.iconKind === "goose") return GooseIcon;
if (nativeAgent?.iconKind === "kimi") return KimiIcon;
if (nativeAgent?.iconKind === "antigravity") return AntigravityIcon;
// A null harness (spec couldn't load) flows through to the bot fallback.
if (agent.harness?.includes("codex")) return CodexIcon;
if (agent.harness?.includes("claude")) return ClaudeIcon;
// Both the SDK "cursor" harness and "cursor-native" get the Cursor glyph.
if (agent.harness?.includes("cursor")) return CursorIcon;
if (agent.harness?.includes("kiro")) return CursorIcon;
if (agent.harness?.includes("goose")) return GooseIcon;
// Both the SDK "kimi"/"kimi-code" harness and "kimi-native" get the Kimi glyph.
if (agent.harness?.includes("kimi")) return KimiIcon;
// qwen falls back to generic BotIcon for now; see docs/QWEN_FOLLOWUPS.md
// Exact match — a substring check would false-match e.g. "openapi".
if (agent.harness === "pi") return PiIcon;
// Both the native (`antigravity-native`) and SDK (`antigravity`) harnesses
// share the Antigravity glyph.
if (agent.harness?.includes("antigravity")) return AntigravityIcon;
return BotIcon;
}
+192
View File
@@ -13,14 +13,41 @@ const { addMutate, deleteMutate, copyTextMock } = vi.hoisted(() => ({
deleteMutate: vi.fn(),
copyTextMock: vi.fn(() => Promise.resolve()),
}));
const { createMcpMutate, updateMcpMutate, deleteMcpMutate } = vi.hoisted(() => ({
createMcpMutate: vi.fn((_payload, options?: { onSuccess?: () => void }) =>
options?.onSuccess?.(),
),
updateMcpMutate: vi.fn((_payload, options?: { onSuccess?: () => void }) =>
options?.onSuccess?.(),
),
deleteMcpMutate: vi.fn(),
}));
const policiesData = { current: [] as unknown[] };
const registryData = { current: [] as unknown[] };
// Session owner + viewer identity, controllable per test. Dereferenced lazily
// inside the mock hooks (same pattern as policiesData) so the closures are safe
// despite vi.mock hoisting. Default null → no owner row, keeping the pre-existing
// cost/id/usage tests untouched.
const ownerData = { current: null as string | null | undefined };
const viewerData = { current: null as string | null };
vi.mock("@/hooks/usePolicies", () => ({
usePolicies: () => ({ data: policiesData.current }),
usePolicyRegistry: () => ({ data: registryData.current }),
useAddPolicy: () => ({ mutate: addMutate, isPending: false, isError: false, error: null }),
useDeletePolicy: () => ({ mutate: deleteMutate }),
}));
vi.mock("@/hooks/useAgents", () => ({
useCreateMcpServer: () => ({ mutate: createMcpMutate, isPending: false, error: null }),
useUpdateMcpServer: () => ({ mutate: updateMcpMutate, isPending: false, error: null }),
useDeleteMcpServer: () => ({ mutate: deleteMcpMutate, isPending: false, error: null }),
}));
vi.mock("@/hooks/usePermissions", () => ({
useSessionOwner: () => ({ data: ownerData.current }),
}));
vi.mock("@/lib/identity", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/identity")>()),
getCurrentUserId: () => viewerData.current,
}));
vi.mock("@/lib/clipboard", () => ({ copyText: copyTextMock }));
import { AgentInfoButton, AgentInfoContent, agentDisplayLabel } from "./AgentInfo";
@@ -28,6 +55,11 @@ import { AgentInfoButton, AgentInfoContent, agentDisplayLabel } from "./AgentInf
afterEach(() => {
cleanup();
copyTextMock.mockClear();
createMcpMutate.mockClear();
updateMcpMutate.mockClear();
deleteMcpMutate.mockClear();
ownerData.current = null;
viewerData.current = null;
});
function renderButton(agent: Agent | undefined) {
@@ -61,6 +93,7 @@ const AGENT_WITH_BOTH: Agent = {
id: "agent_1",
name: "databricks_coding_agent",
description: "Codes against Databricks.",
mcp_servers_editable: true,
mcp_servers: [
{ name: "slack", transport: "http", description: "Slack MCP", url: "https://example/slack" },
{ name: "jira", transport: "stdio", command: "jira-mcp" },
@@ -173,6 +206,54 @@ describe("AgentInfoButton session id row", () => {
});
});
describe("AgentInfoButton session owner row", () => {
// The owner row lets a viewer see whose session a shared chat is. It reads
// the owner via useSessionOwner (mocked) and the viewer via getCurrentUserId
// (mocked); both reset to null in afterEach.
it("shows the session owner in the popover when one is known", () => {
ownerData.current = "alice@example.com";
renderButtonWithSession(AGENT_WITH_BOTH, "conv_owner");
// Closed popover: the owner row is not mounted yet.
expect(screen.queryByTestId("agent-info-session-owner")).toBeNull();
fireEvent.click(screen.getByTestId("agent-info-trigger"));
expect(screen.getByTestId("agent-info-session-owner")).toHaveTextContent("alice@example.com");
});
it("appends (you) when the viewer owns the session", () => {
ownerData.current = "alice@example.com";
viewerData.current = "alice@example.com";
renderButtonWithSession(AGENT_WITH_BOTH, "conv_owner");
fireEvent.click(screen.getByTestId("agent-info-trigger"));
const row = screen.getByTestId("agent-info-session-owner");
expect(row).toHaveTextContent("alice@example.com");
expect(row).toHaveTextContent("(you)");
});
it("omits (you) when someone else owns the session", () => {
ownerData.current = "alice@example.com";
viewerData.current = "bob@example.com";
renderButtonWithSession(AGENT_WITH_BOTH, "conv_owner");
fireEvent.click(screen.getByTestId("agent-info-trigger"));
const row = screen.getByTestId("agent-info-session-owner");
expect(row).toHaveTextContent("alice@example.com");
expect(row).not.toHaveTextContent("(you)");
});
it("omits the owner row when no owner is known (permissions off / loading)", () => {
// owner null → no row at all, rather than an empty placeholder. The rest of
// the popover still renders (agent name proves it opened).
renderButtonWithSession(AGENT_WITH_BOTH, "conv_owner");
fireEvent.click(screen.getByTestId("agent-info-trigger"));
expect(screen.getByText("Databricks_coding_agent")).toBeInTheDocument();
expect(screen.queryByTestId("agent-info-session-owner")).toBeNull();
});
});
describe("AgentInfoButton per-model usage breakdown", () => {
// The breakdown reads `sessionUsageByModel` from the store; reset between
// cases so they stay independent.
@@ -246,6 +327,42 @@ describe("AgentInfoButton per-model usage breakdown", () => {
expect(screen.getByText("Databricks_coding_agent")).toBeInTheDocument();
expect(screen.queryByTestId("agent-info-usage-by-model")).toBeNull();
});
it("toggles the arrow indicator when expanding/collapsing the details", () => {
useChatStore.setState({
sessionUsageByModel: {
"claude-sonnet-4-6": {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
cacheReadInputTokens: null,
cacheCreationInputTokens: null,
totalCostUsd: 0.1,
},
},
});
renderButtonWithSession(AGENT_WITH_BOTH, "conv_arrow");
fireEvent.click(screen.getByTestId("agent-info-trigger"));
const details = screen.getByTestId("agent-info-usage-by-model") as HTMLDetailsElement;
const summary = details.querySelector("summary")!;
// Initially collapsed — arrow points right.
expect(summary).toHaveTextContent("▶");
expect(summary).not.toHaveTextContent("▼");
// Expand the details by setting the open attribute and firing toggle.
details.open = true;
fireEvent(details, new Event("toggle"));
expect(summary).toHaveTextContent("▼");
expect(summary).not.toHaveTextContent("▶");
// Collapse again.
details.open = false;
fireEvent(details, new Event("toggle"));
expect(summary).toHaveTextContent("▶");
expect(summary).not.toHaveTextContent("▼");
});
});
// ---------------------------------------------------------------------------
@@ -376,11 +493,86 @@ describe("SessionPoliciesSection", () => {
});
});
describe("McpServersSection", () => {
beforeEach(() => {
createMcpMutate.mockClear();
updateMcpMutate.mockClear();
deleteMcpMutate.mockClear();
});
it("creates an HTTP MCP server from the manager dialog", () => {
renderContent("conv_mcp");
fireEvent.click(screen.getByRole("button", { name: "Manage MCP servers" }));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "github" } });
fireEvent.change(within(dialog).getByLabelText("URL"), {
target: { value: "https://example.com/sse" },
});
fireEvent.click(within(dialog).getByRole("button", { name: /Save/ }));
expect(createMcpMutate).toHaveBeenCalledWith(
expect.objectContaining({
name: "github",
transport: "http",
url: "https://example.com/sse",
}),
expect.anything(),
);
});
it("updates an existing stdio MCP server", () => {
renderContent("conv_mcp");
fireEvent.click(screen.getByRole("button", { name: "Manage MCP servers" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Edit jira" }));
fireEvent.change(within(dialog).getByLabelText("Command"), {
target: { value: "jira-mcp-new" },
});
fireEvent.click(within(dialog).getByRole("button", { name: /Save/ }));
expect(updateMcpMutate).toHaveBeenCalledWith(
{
serverName: "jira",
payload: expect.objectContaining({
name: "jira",
transport: "stdio",
command: "jira-mcp-new",
}),
},
expect.anything(),
);
});
it("deletes an MCP server from the manager dialog", () => {
renderContent("conv_mcp");
fireEvent.click(screen.getByRole("button", { name: "Manage MCP servers" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Delete slack" }));
expect(deleteMcpMutate).toHaveBeenCalledWith("slack", expect.anything());
});
it("deletes an MCP server from the inline pill popover", () => {
renderContent("conv_mcp");
// Click the pill to open its popover
fireEvent.click(screen.getByRole("button", { name: "slack" }));
// Click "Remove" in the popover
fireEvent.click(screen.getByRole("button", { name: /Remove/ }));
expect(deleteMcpMutate).toHaveBeenCalledWith("slack", expect.anything());
});
});
describe("agentDisplayLabel", () => {
it("maps native wrapper slugs to their display name", () => {
expect(agentDisplayLabel("pi-native-ui")).toBe("Pi");
expect(agentDisplayLabel("claude-native-ui")).toBe("Claude");
expect(agentDisplayLabel("codex-native-ui")).toBe("Codex");
expect(agentDisplayLabel("antigravity-native-ui")).toBe("Antigravity");
});
it("strips the fork/switch clone suffix before resolving the native label", () => {
+466 -19
View File
@@ -5,14 +5,24 @@ import { useEffect, useRef, useState } from "react";
import {
CheckIcon,
CopyIcon,
PencilIcon,
InfoIcon,
PlusIcon,
SaveIcon,
ServerIcon,
ShieldCheckIcon,
TrashIcon,
XIcon,
} from "lucide-react";
import {
useCreateMcpServer,
useDeleteMcpServer,
useUpdateMcpServer,
type UpsertMcpServerInput,
} from "@/hooks/useAgents";
import type { Agent, McpServerSummary } from "@/hooks/useAgents";
import type { ModelUsage } from "@/lib/types";
import { showToast } from "@/components/ui/toast";
import {
usePolicies,
usePolicyRegistry,
@@ -20,7 +30,11 @@ import {
useDeletePolicy,
type PolicyRegistryEntry,
} from "@/hooks/usePolicies";
import { useSessionOwner } from "@/hooks/usePermissions";
import { getCurrentUserId } from "@/lib/identity";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
@@ -59,19 +73,64 @@ export function agentDisplayLabel(name: string): string {
}
/** Compact pill row listing MCP servers attached to an agent. */
export function McpServerList({ servers }: { servers: McpServerSummary[] }) {
export function McpServerList({
servers,
onDelete,
}: {
servers: McpServerSummary[];
onDelete?: (name: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1">
{servers.map((srv) => (
<span
key={srv.name}
title={srv.description ?? srv.name}
className="flex items-center gap-0.5 rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
<ServerIcon className="size-2.5 shrink-0" />
{srv.name}
</span>
))}
{servers.map((srv) =>
onDelete ? (
<Popover key={srv.name}>
<PopoverTrigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-0.5 rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-muted/80"
onClick={(e) => e.stopPropagation()}
>
<ServerIcon className="size-2.5 shrink-0" />
{srv.name}
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-64"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<ServerIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">{srv.name}</span>
</div>
{srv.description && (
<p className="text-xs text-muted-foreground">{srv.description}</p>
)}
<button
type="button"
onClick={() => onDelete(srv.name)}
className="flex items-center gap-1 self-end rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
>
<TrashIcon className="size-3" />
Remove
</button>
</div>
</PopoverContent>
</Popover>
) : (
<span
key={srv.name}
title={srv.description ?? srv.name}
className="flex items-center gap-0.5 rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
>
<ServerIcon className="size-2.5 shrink-0" />
{srv.name}
</span>
),
)}
</div>
);
}
@@ -128,18 +187,22 @@ const MODEL_TOKEN_ROWS: ReadonlyArray<{ key: keyof ModelUsage; label: string }>
* @param usageByModel - Map of raw harness model id to its cumulative usage.
*/
function ModelUsageBreakdown({ usageByModel }: { usageByModel: Record<string, ModelUsage> }) {
const [isOpen, setIsOpen] = useState(false);
// Stable display order: most total tokens first, so the dominant model
// leads. Falls back to 0 for models that haven't recorded a total yet.
const models = Object.entries(usageByModel).sort(
([, a], [, b]) => (b.totalTokens ?? 0) - (a.totalTokens ?? 0),
);
return (
<details data-testid="agent-info-usage-by-model">
<details
data-testid="agent-info-usage-by-model"
onToggle={(e) => setIsOpen(e.currentTarget.open)}
>
<summary className="cursor-pointer select-none list-none">
<SectionLabel>
<span className="inline-flex items-center gap-1">
Token usage
<span className="text-[9px]"></span>
<span className="text-[9px]">{isOpen ? "▼" : "▶"}</span>
</span>
</SectionLabel>
</summary>
@@ -504,6 +567,375 @@ function AddPolicyDialog({
);
}
// ---------------------------------------------------------------------------
// MCP server management
// ---------------------------------------------------------------------------
interface McpFormState {
originalName: string | null;
name: string;
transport: "http" | "stdio";
description: string;
url: string;
command: string;
argsText: string;
}
const EMPTY_MCP_FORM: McpFormState = {
originalName: null,
name: "",
transport: "http",
description: "",
url: "",
command: "",
argsText: "",
};
function mcpFormFromServer(server: McpServerSummary): McpFormState {
return {
originalName: server.name,
name: server.name,
transport: server.transport === "stdio" ? "stdio" : "http",
description: server.description ?? "",
url: server.url ?? "",
command: server.command ?? "",
argsText: (server.args ?? []).join("\n"),
};
}
function payloadFromMcpForm(form: McpFormState): UpsertMcpServerInput {
const base = {
name: form.name.trim(),
transport: form.transport,
description: form.description.trim() || null,
};
if (form.transport === "http") {
return {
...base,
transport: "http",
url: form.url.trim(),
command: null,
args: [],
};
}
return {
...base,
transport: "stdio",
url: null,
command: form.command.trim(),
args: form.argsText
.split("\n")
.map((arg) => arg.trim())
.filter(Boolean),
};
}
function validateMcpForm(form: McpFormState): string | null {
const name = form.name.trim();
if (!name) return "Name is required.";
if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name)) {
return "Name can use letters, numbers, dots, dashes, and underscores.";
}
if (form.transport === "http") {
const url = form.url.trim();
if (!url) return "URL is required.";
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return "URL must start with http:// or https://.";
}
}
if (form.transport === "stdio" && !form.command.trim()) {
return "Command is required.";
}
return null;
}
function McpServerManagerDialog({
sessionId,
servers,
open,
onOpenChange,
}: {
sessionId: string;
servers: McpServerSummary[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [form, setForm] = useState<McpFormState>(EMPTY_MCP_FORM);
const [formError, setFormError] = useState<string | null>(null);
const createServer = useCreateMcpServer(sessionId);
const updateServer = useUpdateMcpServer(sessionId);
const deleteServer = useDeleteMcpServer(sessionId);
const mutationError =
createServer.error?.message ?? updateServer.error?.message ?? deleteServer.error?.message;
const saving = createServer.isPending || updateServer.isPending;
function resetForm() {
setForm(EMPTY_MCP_FORM);
setFormError(null);
}
function notifyRestart() {
showToast(
<span className="text-sm">MCP servers updated. Restart the session to apply changes.</span>,
);
}
function handleSave() {
const error = validateMcpForm(form);
if (error) {
setFormError(error);
return;
}
const payload = payloadFromMcpForm(form);
setFormError(null);
if (form.originalName) {
updateServer.mutate(
{ serverName: form.originalName, payload },
{
onSuccess: () => {
resetForm();
notifyRestart();
},
},
);
return;
}
createServer.mutate(payload, {
onSuccess: () => {
resetForm();
notifyRestart();
},
});
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) resetForm();
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Manage MCP Servers</DialogTitle>
<DialogDescription>Add, edit, or remove MCP servers for this session.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 pt-1 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="flex min-w-0 flex-col gap-1.5">
<SectionLabel>Servers</SectionLabel>
{servers.length > 0 ? (
<div className="flex max-h-56 flex-col divide-y divide-border overflow-y-auto rounded border border-border">
{servers.map((server) => (
<div key={server.name} className="flex min-w-0 items-center gap-1.5 px-2 py-2">
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
onClick={() => {
setForm(mcpFormFromServer(server));
setFormError(null);
}}
className="min-w-0 flex-1 text-left"
>
<span className="block truncate font-mono text-xs">{server.name}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{server.transport}
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Edit ${server.name}`}
onClick={() => {
setForm(mcpFormFromServer(server));
setFormError(null);
}}
>
<PencilIcon className="size-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Delete ${server.name}`}
onClick={() => deleteServer.mutate(server.name, { onSuccess: notifyRestart })}
disabled={deleteServer.isPending}
>
<TrashIcon className="size-3 text-destructive" />
</Button>
</div>
))}
</div>
) : (
<p className="py-3 text-xs text-muted-foreground">No MCP servers</p>
)}
{form.originalName && (
<Button type="button" variant="ghost" size="sm" onClick={resetForm}>
<PlusIcon className="size-3.5" />
New server
</Button>
)}
</div>
<div className="flex min-w-0 flex-col gap-2">
<SectionLabel>{form.originalName ? "Edit Server" : "New Server"}</SectionLabel>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Name
<Input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
className="font-mono"
placeholder="github"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Transport
<select
value={form.transport}
onChange={(e) =>
setForm((prev) => ({
...prev,
transport: e.target.value === "stdio" ? "stdio" : "http",
}))
}
className="h-8 rounded-lg border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="http">HTTP</option>
<option value="stdio">stdio</option>
</select>
</label>
{form.transport === "http" ? (
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
URL
<Input
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://example.com/sse"
/>
</label>
) : (
<>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Command
<Input
value={form.command}
onChange={(e) => setForm((prev) => ({ ...prev, command: e.target.value }))}
placeholder="npx"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Args
<Textarea
value={form.argsText}
onChange={(e) => setForm((prev) => ({ ...prev, argsText: e.target.value }))}
className="min-h-20 font-mono text-xs"
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
</label>
</>
)}
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Description
<Input
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Optional"
/>
</label>
{(formError || mutationError) && (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{formError ?? mutationError}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={resetForm}>
<XIcon className="size-3.5" />
Clear
</Button>
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={saving || validateMcpForm(form) !== null}
>
<SaveIcon className="size-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function McpServersSection({
sessionId,
servers,
editable,
}: {
sessionId?: string | null;
servers: McpServerSummary[];
editable: boolean;
}) {
const [managerOpen, setManagerOpen] = useState(false);
const canEdit = !!(sessionId && editable);
const deleteServer = useDeleteMcpServer(canEdit ? sessionId : "");
const showSection = servers.length > 0 || canEdit;
if (!showSection) return null;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<SectionLabel>Tools</SectionLabel>
{canEdit && (
<button
type="button"
onClick={() => setManagerOpen(true)}
className="rounded p-0.5 hover:bg-muted"
title="Manage MCP servers"
aria-label="Manage MCP servers"
>
<PlusIcon className="size-3 text-muted-foreground" />
</button>
)}
</div>
{servers.length > 0 ? (
<McpServerList
servers={servers}
onDelete={
canEdit
? (name) =>
deleteServer.mutate(name, {
onSuccess: () =>
showToast(
<span className="text-sm">
MCP servers updated. Restart the session to apply changes.
</span>,
),
})
: undefined
}
/>
) : (
<p className="text-xs text-muted-foreground">No MCP servers</p>
)}
{canEdit && (
<McpServerManagerDialog
sessionId={sessionId!}
servers={servers}
open={managerOpen}
onOpenChange={setManagerOpen}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Session policies section (user-editable only)
// ---------------------------------------------------------------------------
@@ -618,6 +1050,7 @@ export function agentHasInfo(agent: Agent | undefined, sessionId?: string | null
*/
export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
const servers = agent?.mcp_servers ?? [];
const mcpEditable = agent?.mcp_servers_editable === true;
const displayName = agent ? agentDisplayLabel(agent.name) : null;
const [sessionIdCopied, setSessionIdCopied] = useState(false);
const copyResetTimeoutRef = useRef<number | null>(null);
@@ -630,6 +1063,12 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
// popover renders it directly — the frontend derives any aggregate view
// from this map rather than receiving flat token fields.
const usageByModel = useChatStore((s) => s.sessionUsageByModel);
// Session owner (the user_id granted LEVEL_OWNER), so a viewer can see whose
// session this is — e.g. a chat shared into a workspace group. ``null`` /
// undefined when permissions are off (single-user) or still loading, in
// which case the row is omitted rather than showing a placeholder.
const { data: owner } = useSessionOwner(sessionId ?? null);
const viewerId = getCurrentUserId();
useEffect(() => {
return () => {
@@ -660,6 +1099,19 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
)}
</div>
)}
{sessionId && owner && (
<div className="flex flex-col gap-1.5">
<SectionLabel>Owner</SectionLabel>
<span
className="truncate font-mono text-xs text-muted-foreground"
data-testid="agent-info-session-owner"
title={owner}
>
{owner}
{owner === viewerId && <span className="ml-1 text-muted-foreground/60">(you)</span>}
</span>
</div>
)}
{sessionId && (
<div className="flex flex-col gap-1.5">
<SectionLabel>Session ID</SectionLabel>
@@ -703,12 +1155,7 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
{sessionId && usageByModel != null && Object.keys(usageByModel).length > 0 && (
<ModelUsageBreakdown usageByModel={usageByModel} />
)}
{servers.length > 0 && (
<div className="flex flex-col gap-1.5">
<SectionLabel>Tools</SectionLabel>
<McpServerList servers={servers} />
</div>
)}
<McpServersSection sessionId={sessionId} servers={servers} editable={mcpEditable} />
{sessionId && <SessionPoliciesSection sessionId={sessionId} />}
</div>
);
@@ -30,11 +30,12 @@ describe("KeyboardShortcutsDialog", () => {
toggleViaHotkey();
expect(screen.getByText("Keyboard shortcuts")).toBeTruthy();
// General / In chats / Navigation / Slash commands — one representative each.
// General / In chats / Navigation / View / Slash commands — one each.
expect(screen.getByText("Show keyboard shortcuts")).toBeTruthy();
expect(screen.getByText("Send message")).toBeTruthy();
expect(screen.getByText("Recall previous prompt")).toBeTruthy();
expect(screen.getByText("Previous session")).toBeTruthy();
expect(screen.getByText("Toggle conversations sidebar")).toBeTruthy();
expect(screen.getByText("Navigate suggestions")).toBeTruthy();
});
@@ -42,6 +42,7 @@ export const MOD_KEY = IS_MAC ? "⌘" : "Ctrl";
// Glyphs match the in-app tooltips (e.g. UserMessageNav's "⌘⌥↑").
const ENTER = "↵";
const SHIFT = "⇧";
const ALT = IS_MAC ? "⌥" : "Alt";
const UP = "↑";
const DOWN = "↓";
@@ -84,6 +85,13 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
{ label: "Next session", keys: [MOD_KEY, DOWN] },
],
},
{
title: "View",
items: [
{ label: "Toggle conversations sidebar", keys: [MOD_KEY, ALT, "["] },
{ label: "Toggle workspace sidebar", keys: [MOD_KEY, ALT, "]"] },
],
},
{
title: "Slash commands",
note: "while the suggestions menu is open",
@@ -121,10 +129,47 @@ function Kbd({ children }: { children: ReactNode }) {
);
}
export function KeyboardShortcutsDialog() {
const [open, setOpen] = useState(false);
/**
* The shortcut reference, grouped, as plain inline content (no dialog
* chrome). Shared by the {@link KeyboardShortcutsDialog} overlay and the
* Settings page, which embeds it directly instead of behind a trigger.
*/
export function KeyboardShortcutsList() {
// Feature-based, stable per session; computed at render so tests can vary it.
const groups = shortcutGroupsFor(isNativeShell());
return (
<>
{groups.map((group) => (
<section key={group.title} className="mb-4 last:mb-0">
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
{group.title}
{group.note ? (
<span className="ml-1.5 font-normal text-muted-foreground/70">· {group.note}</span>
) : null}
</h3>
<ul>
{group.items.map((item) => (
<li
key={item.label}
className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-b-0"
>
<span className="text-sm text-foreground">{item.label}</span>
<span className="flex shrink-0 items-center gap-1">
{item.keys.map((key) => (
<Kbd key={`${item.label}-${key}`}>{key}</Kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</>
);
}
export function KeyboardShortcutsDialog() {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -154,33 +199,7 @@ export function KeyboardShortcutsDialog() {
</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto pr-1">
{groups.map((group) => (
<section key={group.title} className="mb-4 last:mb-0">
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
{group.title}
{group.note ? (
<span className="ml-1.5 font-normal text-muted-foreground/70">
· {group.note}
</span>
) : null}
</h3>
<ul>
{group.items.map((item) => (
<li
key={item.label}
className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-b-0"
>
<span className="text-sm text-foreground">{item.label}</span>
<span className="flex shrink-0 items-center gap-1">
{item.keys.map((key) => (
<Kbd key={`${item.label}-${key}`}>{key}</Kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
<KeyboardShortcutsList />
</div>
</DialogContent>
</Dialog>
+75
View File
@@ -0,0 +1,75 @@
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The single scroll primitive for routed pages. It owns the one thing every
* page used to reinvent by hand: keeping content clear of the chrome at the top
* (the AppShell header + OS safe area) and bottom (the iOS native chat/terminal
* bar + OS safe area).
*
* All of that comes from the shared inset variables defined in index.css
* (`--omnigent-header-height`, `--omnigent-inset-top/bottom`). Off the iOS shell
* those resolve to `0` / plain `env(safe-area-*)`, so the SAME component renders
* correctly in the browser, Electron, and the iOS shell with no runtime branch.
*
* This is the "respect the inset" side of the system. Full-bleed elements that
* intentionally ignore the inset (the webview, app-shell background, sidebar
* drawer) simply don't use this — they stay edge-to-edge as before.
*/
interface PageScrollProps {
children: ReactNode;
/** Reserve room for the AppShell's absolute header at the top. Default true. */
clearHeader?: boolean;
/** Reserve the top OS safe-area inset. Default true. */
insetTop?: boolean;
/** Reserve the bottom inset (safe area + native chat/terminal bar). Default true. */
insetBottom?: boolean;
/** Extra top padding beyond the header/inset, as a CSS length. */
extraTop?: string;
/** Extra bottom padding beyond the inset, as a CSS length. */
extraBottom?: string;
/** Max-width class for the centered content column. Default "max-w-3xl". */
maxWidthClassName?: string;
/** Extra classes for the centered content column (e.g. horizontal padding). */
contentClassName?: string;
/** Extra classes for the scroll container itself. */
className?: string;
/** Forwarded to the scroll container (e.g. data-testid). */
"data-testid"?: string;
}
export function PageScroll({
children,
clearHeader = true,
insetTop = true,
insetBottom = true,
extraTop = "1.5rem",
extraBottom = "2rem",
maxWidthClassName = "max-w-3xl",
contentClassName,
className,
"data-testid": testId,
}: PageScrollProps) {
// Padding is composed from the shared inset vars rather than the old fixed
// `pt-14 py-8`, so the bottom always clears the native bar + home indicator
// and the top always clears the header + notch — the bug those magic numbers
// missed. The padding lives on the scroll container so the last item can
// scroll up clear of the bar.
const style: CSSProperties = {
paddingTop: `calc(${extraTop}${clearHeader ? " + var(--omnigent-header-height)" : ""}${
insetTop ? " + var(--omnigent-inset-top)" : ""
})`,
paddingBottom: `calc(${extraBottom}${insetBottom ? " + var(--omnigent-inset-bottom)" : ""})`,
};
return (
<div
data-testid={testId}
className={cn("min-h-0 w-full flex-1 overflow-y-auto", className)}
style={style}
>
<div className={cn("mx-auto w-full", maxWidthClassName, contentClassName)}>{children}</div>
</div>
);
}
@@ -624,6 +624,37 @@ describe("ApprovalCard — AskUserQuestion form (parsed from content_preview)",
expect(screen.queryByText("Claude has questions")).toBeNull();
});
it("labels Antigravity structured input prompts as Antigravity prompts", () => {
// Antigravity (agy) reuses the same AskUserQuestion renderer via the
// ``ask_user_question`` extra, so the title must reflect the producer
// rather than defaulting to "Claude has questions".
render(
<ApprovalCard
elicitationId="elic_agy_label"
message="Antigravity needs your input"
phase="agy_ask_question"
policyName="agy_native_ask_question"
contentPreview=""
requestedSchema={{}}
status="pending"
response={null}
askUserQuestion={{
questions: [
{
id: "0",
question: "What type of project?",
options: [{ label: "Web app" }, { label: "CLI tool" }],
multiSelect: false,
},
],
}}
/>,
);
expect(screen.getByText("Antigravity needs your input")).toBeDefined();
expect(screen.queryByText("Claude has questions")).toBeNull();
});
it("submits multi-select answers as an array", () => {
const submitSpy = vi.fn().mockResolvedValue(undefined);
useChatStore.setState({ submitApproval: submitSpy } as Partial<
+41 -3
View File
@@ -49,6 +49,7 @@ import {
parseAskUserQuestionPreview,
} from "@/lib/askUserQuestion";
import { formatPreview } from "@/lib/previewFormat";
import type { RenderItem } from "@/lib/renderItems";
import type { RememberScope } from "@/lib/types";
import { useChatStore } from "@/store/chatStore";
import { AskUserQuestionForm, type AskUserQuestionAnswers } from "./AskUserQuestionForm";
@@ -245,9 +246,11 @@ export function ApprovalCard({
// paths are handled inline with approve/reject buttons.
const isExternalUrl = typeof url === "string" && url.length > 0 && !url.startsWith("/approve/");
const askUserQuestionTitle =
policyName.startsWith("codex_") || phase.startsWith("codex_")
? "Codex needs input"
: "Claude has questions";
policyName.startsWith("agy_") || phase.startsWith("agy_")
? "Antigravity needs your input"
: policyName.startsWith("codex_") || phase.startsWith("codex_")
? "Codex needs input"
: "Claude has questions";
// Hide the raw JSON preview for AskUserQuestion (the form already
// renders the questions + options structurally) and for option-
@@ -548,3 +551,38 @@ export function ApprovalCard({
</Alert>
);
}
/**
* Render an elicitation ``RenderItem`` as an ``ApprovalCard``. The prop
* mapping lives here, in one place, so the two callers stay in sync:
* ``BlockRenderer`` (inline in the message stream) and ``ChatPage``'s
* pinned tray (pending cards lifted above the composer). Pass ``onSubmit``
* to route the verdict somewhere other than the active chat store.
*/
export function ElicitationCard({
item,
onSubmit,
}: {
item: Extract<RenderItem, { kind: "elicitation" }>;
onSubmit?: SubmitApprovalFn;
}) {
return (
<ApprovalCard
elicitationId={item.elicitationId}
message={item.message}
phase={item.phase}
policyName={item.policyName}
contentPreview={item.contentPreview}
requestedSchema={item.requestedSchema}
url={item.url}
status={item.status}
response={item.response}
askUserQuestion={item.askUserQuestion}
exitPlanMode={item.exitPlanMode}
codexCommand={item.codexCommand}
allowAllEdits={item.allowAllEdits}
rememberScope={item.rememberScope}
onSubmit={onSubmit}
/>
);
}
+2 -20
View File
@@ -30,7 +30,7 @@ import {
useWorkspacePaths,
} from "@/shell/FileViewerContext";
import { toWorkspaceRelativePath, useWorkspaceFileExists } from "@/hooks/useWorkspaceChangedFiles";
import { ApprovalCard } from "./ApprovalCard";
import { ElicitationCard } from "./ApprovalCard";
import { ReasoningView } from "./ReasoningView";
import { SlashCommandCard } from "./SlashCommandCard";
import { TerminalCommandCard } from "./TerminalCommandCard";
@@ -467,25 +467,7 @@ function renderItem(item: RenderItem, index: number, isReasoningStreaming: boole
/>
);
case "elicitation":
return (
<ApprovalCard
key={key}
elicitationId={item.elicitationId}
message={item.message}
phase={item.phase}
policyName={item.policyName}
contentPreview={item.contentPreview}
requestedSchema={item.requestedSchema}
url={item.url}
status={item.status}
response={item.response}
askUserQuestion={item.askUserQuestion}
exitPlanMode={item.exitPlanMode}
codexCommand={item.codexCommand}
allowAllEdits={item.allowAllEdits}
rememberScope={item.rememberScope}
/>
);
return <ElicitationCard key={key} item={item} />;
}
}
@@ -46,7 +46,12 @@ export function ExitPlanModeReview({
return (
<div className="flex flex-col gap-2" data-testid="exit-plan-mode-review">
<div className="text-sm">
{/* text-foreground: the plan body is the card's primary content, so it
renders in normal text color like a regular assistant message —
escaping AlertDescription's muted default (which otherwise washes
the whole plan out). The short lead-in caption above stays muted
for hierarchy, matching the Codex command card. */}
<div className="text-sm text-foreground">
<MessageResponse>{plan}</MessageResponse>
</div>
{rejecting ? (
@@ -0,0 +1,3 @@
import Antigravity from "@lobehub/icons/es/Antigravity";
export const AntigravityIcon = Antigravity;
+9
View File
@@ -0,0 +1,9 @@
// Import the Mono glyph directly instead of the package index. The Kimi index
// barrel pulls in a `Color` component whose transitive `@lobehub/fluent-emoji`
// dependency uses an ESM directory import that vitest can't resolve (it breaks
// AgentCard.test collection). `Mono` is the monochrome `currentColor` glyph the
// other harness icons (Cursor/Claude/…) render anyway, and it only depends on
// React. See node_modules/@lobehub/icons/es/Kimi/index.d.ts.
import Kimi from "@lobehub/icons/es/Kimi/components/Mono";
export const KimiIcon = Kimi;
@@ -0,0 +1,3 @@
import OpenCode from "@lobehub/icons/es/OpenCode";
export const OpenCodeIcon = OpenCode;
@@ -1,129 +0,0 @@
// Tests for ThemeModeMenu — the compact sidebar button that cycles the theme
// system → dark → light on each click.
//
// The icon shows the *current* mode, while the aria-label/title announce the
// *next* mode the click will apply (see nextThemeMode). It hides entirely when
// embedded (the host owns the theme). `next-themes` and `@/lib/embedded` are
// mocked so each test pins the current theme, system theme, and embed state;
// the real themeMode helpers (pure) run unmocked.
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
const setTheme = vi.fn();
let currentTheme: string | undefined;
let systemTheme: string | undefined;
let embedded: boolean;
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: currentTheme, systemTheme, setTheme }),
}));
vi.mock("@/lib/embedded", () => ({
useIsEmbedded: () => embedded,
}));
import { ThemeModeMenu } from "./ThemeModeMenu";
function renderMenu() {
return render(
<TooltipProvider>
<ThemeModeMenu />
</TooltipProvider>,
);
}
beforeEach(() => {
currentTheme = "system";
systemTheme = undefined;
embedded = false;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ThemeModeMenu", () => {
it("renders nothing when embedded", () => {
// WHY: the host owns the theme in embed mode, so the switcher must be a
// no-op and render no button at all.
embedded = true;
const { container } = renderMenu();
expect(container).toBeEmptyDOMElement();
});
it("labels the button with the next mode in the cycle (system → dark)", () => {
// WHY: at "system" the next click applies "dark", so the action label must
// announce "Switch to Dark".
currentTheme = "system";
renderMenu();
expect(screen.getByRole("button", { name: "Switch to Dark" })).toBeInTheDocument();
});
it("clicking from system selects dark", () => {
// WHY: a click must advance one step in the cycle, calling setTheme with
// the previewed next mode rather than the current one.
currentTheme = "system";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(setTheme).toHaveBeenCalledWith("dark");
});
it("clicking from dark selects light", () => {
// WHY: dark's next mode is light — pins the middle hop of the cycle.
currentTheme = "dark";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Light" }));
expect(setTheme).toHaveBeenCalledWith("light");
});
it("clicking from light wraps back to system", () => {
// WHY: light's next mode is system — pins the wrap-around so the cycle
// visits every mode rather than trapping in two states.
currentTheme = "light";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to System" }));
expect(setTheme).toHaveBeenCalledWith("system");
});
it("treats an unknown stored theme as system", () => {
// WHY: a garbage/legacy stored value must normalize to "system", whose
// next mode is dark — so the button still offers "Switch to Dark".
currentTheme = "sepia";
renderMenu();
expect(screen.getByRole("button", { name: "Switch to Dark" })).toBeInTheDocument();
});
it("skips dark when the system theme is dark", () => {
// WHY: at "system" on a dark OS, pinning dark would render identically, so
// the cycle jumps straight to light.
currentTheme = "system";
systemTheme = "dark";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Light" }));
expect(setTheme).toHaveBeenCalledWith("light");
});
it("does not offer light first when the system theme is light", () => {
// WHY: from "system" the cycle's first stop is dark regardless of OS, so a
// light OS still advances to dark before anything else.
currentTheme = "system";
systemTheme = "light";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(setTheme).toHaveBeenCalledWith("dark");
});
it("skips light when an explicit dark theme sits on a light system", () => {
// WHY: dark's next stop is light, but a light OS already renders light, so
// skip the redundant hop and go straight to system. This is the asymmetry
// the system-theme check fixes — `resolvedTheme` would have offered light.
currentTheme = "dark";
systemTheme = "light";
renderMenu();
fireEvent.click(screen.getByRole("button", { name: "Switch to System" }));
expect(setTheme).toHaveBeenCalledWith("system");
});
});
@@ -1,60 +0,0 @@
import { LaptopMinimalIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsEmbedded } from "@/lib/embedded";
import { type ThemeMode, nextThemeMode, normalizeThemeMode } from "./themeMode";
const themeModeLabels: Record<ThemeMode, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
const themeModeIcons: Record<ThemeMode, typeof SunIcon> = {
light: SunIcon,
dark: MoonIcon,
system: LaptopMinimalIcon,
};
/**
* Compact sidebar control that cycles system → dark → light on click.
*
* A single icon button rather than a dropdown. The icon shows the
* current mode — a sun for light, a moon for dark, and a laptop for
* system — while the tooltip and aria-label announce the mode the next
* click will apply (see {@link nextThemeMode}).
*
* @returns Theme cycle button.
*/
export function ThemeModeMenu() {
// Embedded: the host owns the theme and `embed.tsx` forces light, so a theme
// switcher would be a no-op. Hide it.
const isEmbedded = useIsEmbedded();
const { theme, systemTheme, setTheme } = useTheme();
const mode = normalizeThemeMode(theme);
const next = nextThemeMode(mode, systemTheme);
const Icon = themeModeIcons[mode];
const action = `Switch to ${themeModeLabels[next]}`;
if (isEmbedded) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={action}
title={action}
className="rounded-full"
onClick={() => setTheme(next)}
>
<Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{action}</TooltipContent>
</Tooltip>
);
}
+26
View File
@@ -0,0 +1,26 @@
// Unit tests for the minimal toast system: showToast() renders content into a
// mounted <Toaster />, and the dismiss control removes it.
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { showToast, Toaster } from "./toast";
afterEach(cleanup);
describe("Toaster", () => {
it("renders nothing until a toast is shown", () => {
render(<Toaster />);
expect(screen.queryByTestId("toast")).toBeNull();
});
it("shows toast content and dismisses on the close button", () => {
render(<Toaster />);
act(() => showToast(<span>Hello there</span>, { duration: 0 }));
const toast = screen.getByTestId("toast");
expect(toast).toHaveTextContent("Hello there");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("toast")).toBeNull();
});
});
+91
View File
@@ -0,0 +1,91 @@
// A minimal, dependency-free toast system.
//
// Self-contained in the same spirit as KeyboardShortcutsDialog: a module-level
// `showToast()` dispatches a window event, and a single `<Toaster />` (mounted
// once near the app shell) listens and renders a top-center stack. Toasts
// auto-dismiss after a timeout and carry a manual dismiss control.
//
// Content is an arbitrary ReactNode so callers can embed links/actions (e.g.
// a routing <Link>); it renders inside the Toaster, which sits within the
// Router, so links resolve normally.
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { XIcon } from "lucide-react";
const TOAST_EVENT = "omnigent:toast";
const DEFAULT_DURATION_MS = 6000;
interface ToastItem {
id: number;
content: ReactNode;
duration: number;
}
let counter = 0;
/** Show a transient toast. `duration <= 0` keeps it until manually dismissed. */
export function showToast(content: ReactNode, opts?: { duration?: number }): void {
if (typeof window === "undefined") return;
counter += 1;
const detail: ToastItem = {
id: counter,
content,
duration: opts?.duration ?? DEFAULT_DURATION_MS,
};
window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail }));
}
/** Mount once near the app shell; renders the active toast stack. */
export function Toaster() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
useEffect(() => {
const onToast = (e: Event) => {
const item = (e as CustomEvent<ToastItem>).detail;
setToasts((prev) => [...prev, item]);
};
window.addEventListener(TOAST_EVENT, onToast);
return () => window.removeEventListener(TOAST_EVENT, onToast);
}, []);
if (toasts.length === 0) return null;
return (
<div className="pointer-events-none fixed inset-x-0 top-4 z-[100] flex flex-col items-center gap-2 px-4">
{toasts.map((t) => (
<ToastRow key={t.id} item={t} onDismiss={dismiss} />
))}
</div>
);
}
function ToastRow({ item, onDismiss }: { item: ToastItem; onDismiss: (id: number) => void }) {
useEffect(() => {
if (item.duration <= 0) return;
const timer = setTimeout(() => onDismiss(item.id), item.duration);
return () => clearTimeout(timer);
}, [item.id, item.duration, onDismiss]);
return (
<div
role="status"
aria-live="polite"
data-testid="toast"
className="pointer-events-auto flex max-w-full items-center gap-3 rounded-full border border-border bg-card px-4 py-2 text-sm shadow-lg"
>
<span className="min-w-0">{item.content}</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => onDismiss(item.id)}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<XIcon className="size-4" />
</button>
</div>
);
}
+95 -1
View File
@@ -7,7 +7,7 @@
// fetches the full `AgentObject` for a single session via
// `GET /v1/sessions/{sessionId}/agent`.
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authenticatedFetch } from "@/lib/identity";
export interface McpServerSummary {
@@ -46,6 +46,8 @@ export interface Agent {
harness?: string | null;
/** MCP server declarations from the agent spec. Empty when none configured. */
mcp_servers?: McpServerSummary[];
/** Whether the active session's agent bundle can be edited through the UI. */
mcp_servers_editable?: boolean;
/** Guardrails policies declared on the agent. Empty when none configured. */
policies?: PolicySummary[];
/** Terminal names declared in the spec's `terminals:` block, in
@@ -117,6 +119,7 @@ interface AgentObjectWire {
description?: string | null;
harness?: string | null;
mcp_servers?: McpServerSummary[];
mcp_servers_editable?: boolean;
policies?: PolicySummary[];
terminals?: string[];
}
@@ -137,6 +140,7 @@ async function fetchSessionAgent(sessionId: string): Promise<Agent> {
description: json.description,
harness: json.harness ?? null,
mcp_servers: json.mcp_servers,
mcp_servers_editable: json.mcp_servers_editable,
policies: json.policies,
terminals: json.terminals,
};
@@ -155,3 +159,93 @@ export function useSessionAgent(sessionId: string | null) {
staleTime: Infinity,
});
}
export interface UpsertMcpServerInput {
name: string;
transport: "http" | "stdio";
description?: string | null;
url?: string | null;
command?: string | null;
args?: string[];
}
async function parseMutationError(res: Response): Promise<Error> {
try {
const body = (await res.json()) as { error?: { message?: string }; detail?: unknown };
if (body.error?.message) return new Error(body.error.message);
if (typeof body.detail === "string") return new Error(body.detail);
} catch {
// Fall through to the status text.
}
return new Error(`${res.status} ${res.statusText}`);
}
function sessionAgentQueryKey(sessionId: string) {
return ["session-agent", sessionId];
}
function invalidateSessionAgent(queryClient: ReturnType<typeof useQueryClient>, sessionId: string) {
void queryClient.invalidateQueries({ queryKey: sessionAgentQueryKey(sessionId) });
}
export function useCreateMcpServer(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: UpsertMcpServerInput) => {
const res = await authenticatedFetch(
`/v1/sessions/${encodeURIComponent(sessionId)}/agent/mcp-servers`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) throw await parseMutationError(res);
return (await res.json()) as McpServerSummary;
},
onSuccess: () => invalidateSessionAgent(queryClient, sessionId),
});
}
export function useUpdateMcpServer(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
serverName,
payload,
}: {
serverName: string;
payload: UpsertMcpServerInput;
}) => {
const res = await authenticatedFetch(
`/v1/sessions/${encodeURIComponent(sessionId)}/agent/mcp-servers/${encodeURIComponent(
serverName,
)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) throw await parseMutationError(res);
return (await res.json()) as McpServerSummary;
},
onSuccess: () => invalidateSessionAgent(queryClient, sessionId),
});
}
export function useDeleteMcpServer(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serverName: string) => {
const res = await authenticatedFetch(
`/v1/sessions/${encodeURIComponent(sessionId)}/agent/mcp-servers/${encodeURIComponent(
serverName,
)}`,
{ method: "DELETE" },
);
if (!res.ok) throw await parseMutationError(res);
},
onSuccess: () => invalidateSessionAgent(queryClient, sessionId),
});
}
@@ -107,6 +107,24 @@ describe("useAvailableAgents", () => {
description: null,
harness: "pi-native",
},
{
id: "ag_kiro_native",
name: "kiro-native-ui",
description: null,
harness: "kiro-native",
},
{
id: "ag_agy_native",
name: "antigravity-native-ui",
description: null,
harness: "antigravity-native",
},
{
id: "ag_opencode_native",
name: "opencode-native-ui",
description: null,
harness: "opencode-native",
},
{
id: "ag_nessie",
name: "nessie",
@@ -162,6 +180,30 @@ describe("useAvailableAgents", () => {
harness: "pi-native",
skills: [],
},
{
id: "ag_kiro_native",
name: "kiro-native-ui",
display_name: "Kiro",
description: null,
harness: "kiro-native",
skills: [],
},
{
id: "ag_agy_native",
name: "antigravity-native-ui",
display_name: "Antigravity",
description: null,
harness: "antigravity-native",
skills: [],
},
{
id: "ag_opencode_native",
name: "opencode-native-ui",
display_name: "OpenCode",
description: null,
harness: "opencode-native",
skills: [],
},
{
id: "ag_nessie",
name: "nessie",
@@ -337,6 +379,53 @@ describe("useAvailableAgents", () => {
expect(enrichCalls).toEqual(["/v1/sessions/conv_3/agent"]);
});
it("dedupes native built-ins and hides session-discovered native shadows", async () => {
routeFetch({
[BUILTINS_URL]: mockResponse({
object: "list",
data: [
// Stale/non-canonical native row from older local state; it
// resolves by harness but must not compete with the seeded row.
{ id: "ag_stale_kiro", name: "kiro-naitive", harness: "kiro-native" },
{ id: "ag_kiro", name: "kiro-native-ui", harness: "kiro-native" },
],
has_more: false,
}),
[SCAN_URL]: mockResponse({
object: "list",
data: [
// This distinct session-bound id used to enrich into a second
// Kiro row because it did not shadow the built-in by name/id.
{ id: "conv_kiro", agent_id: "ag_session_kiro", agent_name: "kiro-naitive" },
// Legacy failed Kiro attempts used a plain "kiro" agent name and
// no harness; that row must not surface as a custom Kiro picker row.
{ id: "conv_legacy", agent_id: "ag_legacy_kiro", agent_name: "kiro" },
],
has_more: false,
}),
"/v1/sessions/conv_kiro/agent": mockResponse({
id: "ag_session_kiro",
object: "agent",
name: "kiro-naitive",
harness: "kiro-native",
}),
});
const { result } = renderHook(() => useAvailableAgents(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{
id: "ag_kiro",
name: "kiro-native-ui",
display_name: "Kiro",
description: null,
harness: "kiro-native",
skills: [],
},
]);
});
it("collapses same-named custom agents with distinct agent_ids to the newest session's row", async () => {
routeFetch({
[BUILTINS_URL]: mockResponse({ object: "list", data: [], has_more: false }),
+45 -9
View File
@@ -3,6 +3,7 @@ import { authenticatedFetch } from "@/lib/identity";
import { agentRootName } from "@/lib/forkHarness";
import { capitalizeAgentName } from "@/lib/agentLabels";
import {
nativeCodingAgentForAvailableAgent,
nativeCodingAgentForAgentName,
nativeCodingAgentForHarness,
} from "@/lib/nativeCodingAgents";
@@ -40,6 +41,29 @@ function displayNameForAgent(name: string, harness?: string | null): string {
);
}
function dedupeNativeAgents(agents: AvailableAgent[]): AvailableAgent[] {
const result: AvailableAgent[] = [];
const nativeIndex = new Map<string, number>();
for (const agent of agents) {
const nativeAgent = nativeCodingAgentForAvailableAgent(agent);
if (nativeAgent?.key !== "kiro") {
result.push(agent);
continue;
}
const existingIndex = nativeIndex.get(nativeAgent.key);
if (existingIndex === undefined) {
nativeIndex.set(nativeAgent.key, result.length);
result.push(agent);
continue;
}
const existing = result[existingIndex];
if (agent.name === nativeAgent.agentName && existing.name !== nativeAgent.agentName) {
result[existingIndex] = agent;
}
}
return result;
}
/** Wire row of the built-in list, GET /v1/agents. */
interface BuiltinAgentWire {
id: string;
@@ -64,14 +88,16 @@ async function fetchBuiltinAgents(): Promise<AvailableAgent[]> {
const res = await authenticatedFetch("/v1/agents");
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const body = (await res.json()) as { data: BuiltinAgentWire[] };
return body.data.map((a) => ({
id: a.id,
name: a.name,
display_name: displayNameForAgent(a.name, a.harness),
description: a.description ?? null,
harness: a.harness ?? null,
skills: a.skills ?? [],
}));
return dedupeNativeAgents(
body.data.map((a) => ({
id: a.id,
name: a.name,
display_name: displayNameForAgent(a.name, a.harness),
description: a.description ?? null,
harness: a.harness ?? null,
skills: a.skills ?? [],
})),
);
}
/**
@@ -192,6 +218,10 @@ async function fetchAvailableAgents(): Promise<AvailableAgent[]> {
]);
const builtinIds = new Set(builtins.map((a) => a.id));
const builtinNames = new Set(builtins.map((a) => a.name));
const hasKiroBuiltin = builtins.some(
(a) => nativeCodingAgentForAvailableAgent(a)?.key === "kiro",
);
const kiroLegacyNames = new Set(["kiro"]);
// One row per custom base name, newest session first (scan order):
// same-named agent_ids are per-session mints of the same agent, and
// identical-name rows are indistinguishable in the picker anyway.
@@ -203,9 +233,15 @@ async function fetchAvailableAgents(): Promise<AvailableAgent[]> {
// the clone would slip past the shadow check and pollute the picker.
const base = agentRootName(agent.agentName);
if (builtinIds.has(agent.agentId) || builtinNames.has(base)) continue;
if (hasKiroBuiltin && kiroLegacyNames.has(base.toLocaleLowerCase())) continue;
if (!customByName.has(base)) customByName.set(base, agent);
}
const enriched = await Promise.all(Array.from(customByName.values()).map(enrichSessionAgent));
const enriched = (
await Promise.all(Array.from(customByName.values()).map(enrichSessionAgent))
).filter((agent) => {
const nativeKey = nativeCodingAgentForAvailableAgent(agent)?.key;
return nativeKey !== "kiro" || !hasKiroBuiltin;
});
// Built-ins first; custom agents follow in scan order (newest session
// first). NewChatDialog's display-order sort is stable, so unranked
// custom names keep this relative order.
+10 -4
View File
@@ -85,11 +85,17 @@ function getIOSNativeKeyboardInset(): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
const shellBottom =
document.querySelector<HTMLElement>("[data-ios-native].app-shell")?.getBoundingClientRect()
.bottom ?? window.innerHeight;
// Keyboard height = the layout viewport (the full webview, kept keyboard-
// independent by the native shell's `.ignoresSafeArea(.keyboard)`) minus the
// visible visual viewport. Measured against `window.innerHeight`, NOT the
// app-shell: useIOSViewportLock resizes the shell down to the visual viewport
// when the keyboard opens, so an app-shell-relative measurement would always
// read ~0. This value is for fixed, full-viewport overlays (e.g. the mobile
// TerminalsPanel) that the shell-lock does not resize — flow content inside
// the shell is handled by the lock and needs no manual padding.
const layoutBottom = window.innerHeight;
const visibleBottom = viewport.offsetTop + viewport.height;
return Math.max(0, Math.round(shellBottom - visibleBottom));
return Math.max(0, Math.round(layoutBottom - visibleBottom));
}
function isEditableElementFocused(): boolean {
+71
View File
@@ -0,0 +1,71 @@
import { useEffect } from "react";
import { isIOSShell } from "@/lib/nativeBridge";
/**
* Lock the iOS shell to the visual viewport so the soft keyboard can never pan
* the whole document.
*
* The native shell keeps the WKWebView full-height when the keyboard opens
* (`.ignoresSafeArea(.keyboard)`), so the layout viewport (`window.innerHeight`,
* `100lvh`) stays full while the keyboard overlays the bottom. Left alone, the
* composer/inputs sit *behind* the keyboard and WebKit pans the whole document
* up to reveal the focused field hiding the header and letting the user scroll
* the entire page.
*
* `visualViewport.height` *does* track the keyboard here (verified: it drops to
* the area above the keyboard while `innerHeight` stays full), so we publish it
* to `--omnigent-viewport-height`; the app-shell sizes to that (see index.css).
* The focused input is then always within the visible area, WebKit never needs
* to pan, the header stays put, and only the inner scroll panes move. As a
* safety net we also snap any residual document pan back to the top.
*
* No-op off the iOS shell the browser and Electron handle the keyboard via
* normal layout, and the CSS var falls back to `100lvh`.
*/
export function useIOSViewportLock(): void {
useEffect(() => {
if (!isIOSShell()) return;
const viewport = window.visualViewport;
if (!viewport) return;
const root = document.documentElement;
let frame = 0;
// With the shell sized to the visual viewport there is nothing to scroll, so
// counter any pan WebKit applies while revealing a focused field — that pan
// is what moves the whole page (and the absolute header). Wired as its own
// listener too, so it fires the instant WebKit scrolls, not just on the
// rAF-coalesced resize.
const resetPan = () => {
if (viewport.offsetTop !== 0 || window.scrollY !== 0) window.scrollTo(0, 0);
};
const apply = () => {
frame = 0;
root.style.setProperty("--omnigent-viewport-height", `${Math.round(viewport.height)}px`);
resetPan();
};
// Coalesce the burst of resize/scroll events the keyboard animation fires.
const schedule = () => {
if (frame) return;
frame = window.requestAnimationFrame(apply);
};
apply();
viewport.addEventListener("resize", schedule);
viewport.addEventListener("scroll", schedule);
window.addEventListener("scroll", resetPan, { passive: true });
window.addEventListener("orientationchange", schedule);
return () => {
if (frame) window.cancelAnimationFrame(frame);
viewport.removeEventListener("resize", schedule);
viewport.removeEventListener("scroll", schedule);
window.removeEventListener("scroll", resetPan);
window.removeEventListener("orientationchange", schedule);
root.style.removeProperty("--omnigent-viewport-height");
};
}, []);
}
+22
View File
@@ -8,6 +8,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type Permission,
derivePermissionLevel,
getSessionOwner,
grantPermission,
listPermissions,
revokePermission,
@@ -19,6 +20,10 @@ function permissionsKey(sessionId: string) {
return ["permissions", sessionId] as const;
}
function sessionOwnerKey(sessionId: string) {
return ["sessionOwner", sessionId] as const;
}
/** Fetch all permission grants for a session. */
export function usePermissions(sessionId: string | null) {
return useQuery({
@@ -28,6 +33,23 @@ export function usePermissions(sessionId: string | null) {
});
}
/**
* Fetch the owner (the ``user_id`` granted ``LEVEL_OWNER``) of a session.
*
* Returns ``null`` when permissions are disabled (single-user mode), so the
* caller can omit any owner UI. Requires only read access, so it resolves for
* anyone a session is shared with letting the info popover answer "whose
* session is this?" for chats shared into a workspace group. Disabled until a
* sessionId is known.
*/
export function useSessionOwner(sessionId: string | null) {
return useQuery({
queryKey: sessionOwnerKey(sessionId ?? ""),
queryFn: () => getSessionOwner(sessionId!),
enabled: !!sessionId,
});
}
/** Grant or update a permission. Invalidates the permissions list on success. */
export function useGrantPermission(sessionId: string) {
const qc = useQueryClient();
@@ -0,0 +1,112 @@
// ⌘⌥[ toggles the left sidebar, ⌘⌥] the right; matches the physical bracket
// keys (not the glyph ⌥ produces), ignores the bare keys / missing-Alt / Shift
// variants / auto-repeat / AltGraph, fully claims the event, and unbinds on
// unmount.
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSidebarToggleHotkeys } from "./useSidebarToggleHotkeys";
/** Dispatch a keydown that reaches window from body (default: Ctrl+Alt+[). */
function press(
mods: Partial<Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "altKey" | "shiftKey" | "repeat">> = {
ctrlKey: true,
altKey: true,
},
code = "BracketLeft",
): void {
document.body.dispatchEvent(
new KeyboardEvent("keydown", { code, bubbles: true, cancelable: true, ...mods }),
);
}
afterEach(() => vi.restoreAllMocks());
function setup() {
const onToggleLeft = vi.fn();
const onToggleRight = vi.fn();
const utils = renderHook(() => useSidebarToggleHotkeys({ onToggleLeft, onToggleRight }));
return { onToggleLeft, onToggleRight, ...utils };
}
describe("useSidebarToggleHotkeys", () => {
it("Ctrl+Alt+[ toggles only the left sidebar", () => {
const { onToggleLeft, onToggleRight } = setup();
press({ ctrlKey: true, altKey: true }, "BracketLeft");
expect(onToggleLeft).toHaveBeenCalledTimes(1);
expect(onToggleRight).not.toHaveBeenCalled();
});
it("Ctrl+Alt+] toggles only the right sidebar", () => {
const { onToggleLeft, onToggleRight } = setup();
press({ ctrlKey: true, altKey: true }, "BracketRight");
expect(onToggleRight).toHaveBeenCalledTimes(1);
expect(onToggleLeft).not.toHaveBeenCalled();
});
it("Cmd variants also fire (macOS)", () => {
const { onToggleLeft, onToggleRight } = setup();
press({ metaKey: true, altKey: true }, "BracketLeft");
press({ metaKey: true, altKey: true }, "BracketRight");
expect(onToggleLeft).toHaveBeenCalledTimes(1);
expect(onToggleRight).toHaveBeenCalledTimes(1);
});
it("ignores the bare keys, missing-Alt, and Shift variants", () => {
const { onToggleLeft, onToggleRight } = setup();
press({}, "BracketLeft"); // bare [
press({ ctrlKey: true }, "BracketLeft"); // ⌘[ alone = browser Back, not ours
press({ metaKey: true, altKey: true, shiftKey: true }, "BracketRight");
expect(onToggleLeft).not.toHaveBeenCalled();
expect(onToggleRight).not.toHaveBeenCalled();
});
it("ignores other keys held with the modifiers", () => {
const { onToggleLeft, onToggleRight } = setup();
press({ ctrlKey: true, altKey: true }, "Backslash");
press({ metaKey: true, altKey: true }, "Period");
expect(onToggleLeft).not.toHaveBeenCalled();
expect(onToggleRight).not.toHaveBeenCalled();
});
it("ignores auto-repeat (holding the chord doesn't flap the panel)", () => {
const { onToggleLeft } = setup();
press({ ctrlKey: true, altKey: true, repeat: true }, "BracketLeft");
expect(onToggleLeft).not.toHaveBeenCalled();
});
it("ignores AltGraph chords (Ctrl+Alt produced by intl layouts)", () => {
const { onToggleLeft, onToggleRight } = setup();
const altGraph = vi
.spyOn(KeyboardEvent.prototype, "getModifierState")
.mockImplementation((keyArg) => keyArg === "AltGraph");
press({ ctrlKey: true, altKey: true }, "BracketLeft");
press({ ctrlKey: true, altKey: true }, "BracketRight");
expect(onToggleLeft).not.toHaveBeenCalled();
expect(onToggleRight).not.toHaveBeenCalled();
altGraph.mockRestore();
});
it("claims the event (preventDefault + stopPropagation)", () => {
setup();
const ev = new KeyboardEvent("keydown", {
code: "BracketLeft",
ctrlKey: true,
altKey: true,
bubbles: true,
cancelable: true,
});
const stopSpy = vi.spyOn(ev, "stopPropagation");
document.body.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it("unbinds on unmount", () => {
const { onToggleLeft, unmount } = setup();
unmount();
press({ ctrlKey: true, altKey: true }, "BracketLeft");
expect(onToggleLeft).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
// ⌘⌥[ / ⌘⌥] (Ctrl+Alt+[ / Ctrl+Alt+] on Win/Linux) toggle the left
// (Conversations) and right (Workspace) sidebars. Siblings to the session-switch
// (⌘↑/↓) and approve (⌘↵) hotkeys; like them they fire even inside a focused
// text field, so a panel can be collapsed mid-compose.
//
// Why this chord: the bare ⌘[ / ⌘] are the browser's Back/Forward gestures, and
// single ⌘+punctuation combos (e.g. ⌘\) get swallowed by global hotkey utilities
// (Raycast/Rectangle/…) before the page ever sees them. Adding ⌥ dodges both —
// it's not a browser gesture and is essentially never grabbed system-wide — and
// it shares the ⌘⌥ chord with ChatPage's message-nav hotkey. Bind ONCE at the
// app shell, where the sidebar open-state lives.
import { useEffect, useRef } from "react";
export interface SidebarToggleHandlers {
/** Flip the left (Conversations) sidebar. Bound to ⌘/Ctrl + ⌥/Alt + [. */
onToggleLeft: () => void;
/** Flip the right (Workspace) sidebar. Bound to ⌘/Ctrl + ⌥/Alt + ]. */
onToggleRight: () => void;
}
export function useSidebarToggleHotkeys(handlers: SidebarToggleHandlers): void {
// Held in a ref so the bound handler always calls the latest closures without
// re-registering each render.
const latest = useRef(handlers);
latest.current = handlers;
useEffect(() => {
const handler = (e: globalThis.KeyboardEvent): void => {
// Require Cmd/Ctrl AND Alt (the ⌘⌥ message-nav chord) and additionally
// reject Shift, so ⌘⌥⇧ combos stay free for future bindings.
if (!(e.metaKey || e.ctrlKey) || !e.altKey || e.shiftKey) return;
// AltGr often reports as Ctrl+Alt; ignore it so intl-layout typing doesn't
// accidentally toggle sidebars while focused in an editor/composer.
if (e.getModifierState("AltGraph")) return;
// Ignore auto-repeat: holding the chord would flap the panel open/closed.
if (e.repeat) return;
// Match the physical key, not the character: ⌥ turns "[" / "]" into "“" /
// "" on macOS, but e.code is stable across layouts and modifiers.
// Claim the chord: preventDefault drops any default action and
// stopPropagation keeps it from reaching other keydown listeners.
if (e.code === "BracketLeft") {
e.preventDefault();
e.stopPropagation();
latest.current.onToggleLeft();
} else if (e.code === "BracketRight") {
e.preventDefault();
e.stopPropagation();
latest.current.onToggleRight();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}
+78
View File
@@ -483,12 +483,42 @@ describe("inventoryTerminals", () => {
session: "main",
running: true,
};
const kiroPane: TerminalInfo = {
id: "terminal_kiro_main",
name: "kiro",
session: "main",
running: true,
};
const goosePane: TerminalInfo = {
id: "terminal_goose_main",
name: "goose",
session: "main",
running: true,
};
const qwenPane: TerminalInfo = {
id: "terminal_qwen_main",
name: "qwen",
session: "main",
running: true,
};
const kimiPane: TerminalInfo = {
id: "terminal_kimi_main",
name: "kimi",
session: "main",
running: true,
};
const hermesPane: TerminalInfo = {
id: "terminal_hermes_main",
name: "hermes",
session: "main",
running: true,
};
const antigravityPane: TerminalInfo = {
id: "terminal_antigravity_main",
name: "antigravity",
session: "main",
running: true,
};
const bash: TerminalInfo = {
id: "terminal_bash_s1",
name: "bash",
@@ -510,6 +540,13 @@ describe("inventoryTerminals", () => {
expect(inventoryTerminals([cursorPane, bash], true)).toEqual([bash]);
});
it("drops the kiro vendor pane for native Kiro sessions", () => {
// Regression: terminal_kiro_main was missing from AGENT_TERMINAL_IDS,
// so Kiro's Terminal pill opened a shell view with an X close button
// instead of preserving the Chat/Terminal toggle.
expect(inventoryTerminals([kiroPane, bash], true)).toEqual([bash]);
});
it("drops the goose vendor pane for native Goose sessions", () => {
// Regression: terminal_goose_main was missing from AGENT_TERMINAL_IDS, so
// the goose TUI pane leaked into the Shells inventory and (via isShellView)
@@ -519,6 +556,42 @@ describe("inventoryTerminals", () => {
expect(isAgentTerminalKey("terminal:terminal_goose_main")).toBe(true);
});
it("drops the qwen vendor pane for native Qwen sessions", () => {
// Regression: terminal_qwen_main was missing from AGENT_TERMINAL_IDS, so
// clicking Terminal opened the qwen TUI pane as a plain shell (shell-header
// chrome) while hiding the Chat/Terminal pill via isShellView — same failure
// mode as the pi/cursor/goose panes above.
expect(inventoryTerminals([qwenPane, bash], true)).toEqual([bash]);
expect(isAgentTerminalKey("terminal:terminal_qwen_main")).toBe(true);
});
it("drops the kimi vendor pane for native Kimi sessions", () => {
// Regression: terminal_kimi_main was missing from AGENT_TERMINAL_IDS,
// same failure mode as the pi/cursor panes above — leaked into Shells
// and hid the Chat/Terminal pill in Terminal view.
expect(inventoryTerminals([kimiPane, bash], true)).toEqual([bash]);
expect(isAgentTerminalKey("terminal:terminal_kimi_main")).toBe(true);
});
it("drops the hermes vendor pane for native Hermes sessions", () => {
// Regression: terminal_hermes_main was missing from AGENT_TERMINAL_IDS, so
// the hermes TUI pane leaked into the Shells inventory and (via isShellView)
// opened as a plain shell while hiding the Chat/Terminal pill — same failure
// mode as the pi/cursor/goose/qwen panes above.
expect(inventoryTerminals([hermesPane, bash], true)).toEqual([bash]);
expect(isAgentTerminalKey("terminal:terminal_hermes_main")).toBe(true);
});
it("drops the antigravity vendor pane for native Antigravity sessions", () => {
// Regression (#1157): terminal_antigravity_main was missing from
// AGENT_TERMINAL_IDS, so the agy TUI pane leaked into the Shells inventory
// and (via isShellView) hid the Chat/Terminal pill in Terminal view —
// stranding the user with no way back to Chat. Same failure mode as the
// pi/cursor/goose/qwen panes above.
expect(inventoryTerminals([antigravityPane, bash], true)).toEqual([bash]);
expect(isAgentTerminalKey("terminal:terminal_antigravity_main")).toBe(true);
});
it("drops the embedded REPL terminal for terminal-first SDK sessions", () => {
// The REPL terminal backs the pill's Terminal view; listing it in
// the rail reads as a phantom "main" terminal on agents that don't
@@ -553,11 +626,16 @@ describe("isAgentTerminalKey", () => {
expect(isAgentTerminalKey("terminal:terminal_tui_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_claude_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_codex_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_opencode_main")).toBe(true);
// pi-native: missing here is what hid the Chat/Terminal pill in
// Terminal view (isShellView wrongly true) for Pi sessions.
expect(isAgentTerminalKey("terminal:terminal_pi_main")).toBe(true);
// cursor-native: same regression class as pi above.
expect(isAgentTerminalKey("terminal:terminal_cursor_main")).toBe(true);
// kiro-/goose-/qwen-native: same regression class as cursor/pi above.
expect(isAgentTerminalKey("terminal:terminal_kiro_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_goose_main")).toBe(true);
expect(isAgentTerminalKey("terminal:terminal_qwen_main")).toBe(true);
});
it("treats a user shell as not-the-agent-terminal", () => {
+11 -3
View File
@@ -50,9 +50,11 @@ export const PANEL_NO_TERMINAL_KEY = "";
* connection pill's Terminal view, runner-created per session shape:
* the embedded Omnigent REPL (``tui``/``main``) for SDK sessions,
* and the vendor pane (``claude``/``main``, ``codex``/``main``,
* ``pi``/``main``, or ``cursor``/``main``) for native-wrapper sessions.
* These are plumbing, not
* part of the session's shell inventory, and at most one exists per session.
* ``pi``/``main``, ``cursor``/``main``, ``kiro``/``main``, ``goose``/``main``,
* ``qwen``/``main``, ``antigravity``/``main``, or ``kimi``/``main``) for
* native-wrapper sessions.
* These are plumbing, not part of the session's shell inventory, and at most
* one exists per session.
*
* Missing an entry here makes that pane read as a *user shell*: the
* Chat/Terminal pill self-hides in Terminal view (``isShellView``), so the
@@ -63,9 +65,15 @@ export const AGENT_TERMINAL_IDS: ReadonlySet<string> = new Set([
"terminal_tui_main",
"terminal_claude_main",
"terminal_codex_main",
"terminal_opencode_main",
"terminal_pi_main",
"terminal_cursor_main",
"terminal_kiro_main",
"terminal_goose_main",
"terminal_qwen_main",
"terminal_antigravity_main",
"terminal_kimi_main",
"terminal_hermes_main",
]);
/**
+110 -20
View File
@@ -99,6 +99,43 @@
* buttons stay neutral grey (hue 240). See designs/UI/WEB_UI.md (still
* describes the older cobalt direction). */
:root {
/* ---- Inset system -------------------------------------------------------
* Single source of truth for how far content must stay clear of screen
* chrome. The SAME bundle runs in a browser, in Electron, and inside the
* iOS WKWebView shell, so these variables are defined universally and the
* runtime-specific inputs default to zero `<PageScroll>` and the scoped
* rules below consume them without any `isIOSShell()` branching.
*
* Three inputs combine:
* 1. OS safe area `env(safe-area-inset-*)`, nonzero only on notched /
* home-indicator devices (and only when `viewport-fit=cover`).
* 2. Native bar footprint the pixel height of the iOS shell's floating
* server switcher (top) and chat/terminal bar (bottom). The native
* layer owns these dimensions and pushes them over the bridge (see
* src/lib/nativeInsets.ts); they stay 0 off-shell.
* 3. Bar visibility 0|1, owned by the web app (it is what tells the
* shell to show/hide each bar). Folded in via a unitless multiply so a
* hidden bar contributes nothing.
*
* The bottom chat/terminal bar overlays page content, so it feeds the
* content inset. The top server switcher floats within the web header band
* (ChatHeader, --omnigent-header-height) and is narrower than it, so content
* that already clears the header also clears the switcher its size is
* tracked here for symmetry/introspection but is intentionally NOT added to
* the content top inset to avoid double-counting the header band. */
--omnigent-header-height: 3.5rem; /* web ChatHeader (h-14) */
--omnigent-safe-top: env(safe-area-inset-top, 0px);
--omnigent-safe-bottom: env(safe-area-inset-bottom, 0px);
--omnigent-native-top-bar: 0px; /* set by native bridge (px) */
--omnigent-native-bottom-bar: 0px; /* set by native bridge (px) */
--omnigent-top-bar-visible: 0; /* web-owned 0|1 */
--omnigent-bottom-bar-visible: 0; /* web-owned 0|1 */
--omnigent-inset-top: var(--omnigent-safe-top);
--omnigent-inset-bottom: calc(
var(--omnigent-safe-bottom) + var(--omnigent-native-bottom-bar) *
var(--omnigent-bottom-bar-visible)
);
/* Brand palette (light). Hex values are the brand spec, kept verbatim
* for fidelity. --background is white: the lavender brand canvas is
* expressed by the .app-shell gradient (see index.css .app-shell), so
@@ -408,18 +445,51 @@
* bridge marker so normal iOS Safari keeps its existing browser-safe layout. */
@media (width < 48rem) {
[data-ios-native].app-shell {
height: 100vh;
/* Lock the shell to the live visual viewport so the soft keyboard can never
pan the whole document (which hides the header). --omnigent-viewport-
height is published by useIOSViewportLock from visualViewport.height; it
shrinks when the keyboard opens so inputs stay above it and only inner
panes scroll. Falls back to the large viewport before JS runs / on older
WebKit. The two-line pattern keeps the lvh fallback for browsers without
the var. */
height: 100lvh;
min-height: 100vh;
height: var(--omnigent-viewport-height, 100lvh);
min-height: 100lvh;
max-height: 100vh;
min-height: var(--omnigent-viewport-height, 100lvh);
max-height: 100lvh;
max-height: var(--omnigent-viewport-height, 100lvh);
overflow: hidden;
}
/* Slide animation for the mobile drawer. Covers BOTH paths that move the
panel: the edge-swipe drag settles via an inline `transform`, while the
button toggle (and the drag's resting open/closed state) is driven by
Tailwind v4's `translate-x` utilities which animate the `translate`
property, not `transform`. Listing only `transform` (as before) left the
toggle un-transitioned, so tapping the collapse/expand button snapped the
drawer instantly with no animation. Transition both so the toggle slides
too. Suppressed inline (`transition: none`) while a drag is live so the
overlay tracks the finger 1:1. */
[data-ios-native] .conversations-sidebar {
transition: transform 360ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
transition-property: transform, translate;
transition-duration: 360ms;
transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform, translate;
}
/* Leading-edge shadow so the drawer reads as a native layer lifted above the
chat while it slides, instead of a flat sheet. Scoped to the open/dragging
state via `:not([data-collapsed])` (the sidebar sets `data-collapsed` only
when closed) so the full-bleed overlay doesn't cast a sliver along the
screen's left edge while parked off-screen. It's visible only mid-slide and
mid-drag fully open the drawer covers the viewport, so its right edge (and
the shadow) sit past the screen. iOS-only: the web overlay is unchanged. */
[data-ios-native] .conversations-sidebar:not([data-collapsed]) {
box-shadow: 8px 0 28px -6px rgb(0 0 0 / 0.22);
}
.dark [data-ios-native] .conversations-sidebar:not([data-collapsed]) {
box-shadow: 8px 0 30px -4px rgb(0 0 0 / 0.55);
}
[data-ios-native] :is(input, textarea, select, [contenteditable="true"]) {
@@ -431,28 +501,30 @@
}
[data-ios-native] .chat-conversation-content {
padding-top: calc(5rem + env(safe-area-inset-top, 0px));
/* header band + 1.5rem breathing room + OS safe area, sourced from the
shared inset vars (was a hardcoded 5rem). */
padding-top: calc(var(--omnigent-header-height) + 1.5rem + var(--omnigent-inset-top));
}
[data-ios-native] .main-terminal-view {
padding-top: calc(3.25rem + env(safe-area-inset-top, 0px));
padding-top: calc(3.25rem + var(--omnigent-inset-top));
}
[data-ios-native] .chat-scroll-fade {
mask-image: linear-gradient(
to bottom,
transparent calc(48px + env(safe-area-inset-top, 0px)),
black calc(80px + env(safe-area-inset-top, 0px))
transparent calc(48px + var(--omnigent-inset-top)),
black calc(80px + var(--omnigent-inset-top))
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent calc(48px + env(safe-area-inset-top, 0px)),
black calc(80px + env(safe-area-inset-top, 0px))
transparent calc(48px + var(--omnigent-inset-top)),
black calc(80px + var(--omnigent-inset-top))
);
}
[data-ios-native] .chat-composer-form {
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(0.75rem + var(--omnigent-safe-bottom));
}
[data-ios-native] .chat-composer-form.terminal-first-composer-form {
@@ -460,20 +532,22 @@
}
[data-ios-native] .terminal-first-switcher-container {
padding-bottom: calc(0.35rem + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(0.35rem + var(--omnigent-safe-bottom));
}
/* Reserve room for the native Liquid Glass switcher that floats over the web
view (the in-page pill is suppressed in the iOS shell). Fixed height: the
bar's footprint above the home-indicator inset (env). Chat sits 1rem
tighter its composer status line already cushions the gap to the bar. */
view (the in-page pill is suppressed in the iOS shell). Height is the
shared bottom inset the native bar footprint (sourced from the shell, see
nativeInsets.ts) plus the home-indicator safe area so it can never drift
from the bar's real size. Chat sits 1rem tighter: its composer status line
already cushions the gap to the bar. */
[data-ios-native] .omnigent-native-bottom-spacer {
height: calc(3rem + env(safe-area-inset-bottom, 0px));
height: var(--omnigent-inset-bottom);
flex: none;
}
[data-ios-native] .omnigent-native-bottom-spacer--chat {
height: calc(2rem + env(safe-area-inset-bottom, 0px));
height: calc(var(--omnigent-inset-bottom) - 1rem);
}
[data-ios-native] .terminal-first-switcher {
@@ -549,8 +623,8 @@
[data-testid="subagents-panel-drawer"],
[data-testid="todos-panel-drawer"]
) {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-top: var(--omnigent-safe-top);
padding-bottom: var(--omnigent-safe-bottom);
}
}
@@ -702,6 +776,22 @@
font-size: 1em;
}
/* GitHub task lists in chat messages (Streamdown + remark-gfm). remark-gfm tags
each task item `task-list-item` with a disabled <input type="checkbox">, but
Streamdown also applies Tailwind `list-disc`, so a disc renders right next to
the checkbox (bullet + checkbox). Drop the marker per ITEM (not on the whole
`.contains-task-list` list) so a list mixing task and plain items only loses
the bullet on the task items matching GitHub. `list-style-type` is
inherited, so setting it on the <li> overrides the list's `list-disc`. Space
the checkbox from its label. */
[data-streamdown="list-item"].task-list-item {
list-style: none;
}
[data-streamdown="list-item"].task-list-item > input[type="checkbox"] {
margin-right: 0.4em;
vertical-align: -0.075em;
}
/* Flash highlight on nav jump. Duration must match FLASH_DURATION_MS in chatStore.ts. */
@keyframes user-msg-flash {
0% {
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
// We can't use buildAgentBundle directly in jsdom because
// CompressionStream is not available. Instead, test the YAML
// generation logic by importing the module and calling the
// internal config builder. Since it's not exported, we test
// indirectly through buildAgentBundle and inspect the generated
// YAML by mocking the compression + tar layer.
// The module's functions are all private except buildAgentBundle.
// We mock CompressionStream and verify the config.yaml content
// that gets fed to the tar/gzip pipeline.
import type { AgentBundleInput } from "./agentBundle";
// Capture what buildAgentBundle passes to `new File(...)` by
// mocking CompressionStream (not in jsdom) to be a passthrough.
class PassthroughStream {
readable: ReadableStream;
writable: WritableStream;
constructor() {
let controller: ReadableStreamDefaultController;
this.readable = new ReadableStream({
start(c) {
controller = c;
},
});
this.writable = new WritableStream({
write(chunk) {
controller.enqueue(new Uint8Array(chunk));
},
close() {
controller.close();
},
});
}
}
// Install mock before importing buildAgentBundle.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).CompressionStream = PassthroughStream;
// Now import the function (it will use our mock CompressionStream).
const { buildAgentBundle } = await import("./agentBundle");
/** Extract the config.yaml from the raw tar bytes inside the File. */
async function extractConfigYaml(file: File): Promise<string> {
const buf = await file.arrayBuffer();
const tar = new Uint8Array(buf);
// First tar entry: 512-byte header, then content.
// File size is at offset 124, 12 bytes, octal null-terminated.
const sizeStr = new TextDecoder().decode(tar.slice(124, 135)).replace(/\0/g, "");
const size = parseInt(sizeStr, 8);
return new TextDecoder().decode(tar.slice(512, 512 + size));
}
/** Extract AGENTS.md (second tar entry) from the raw tar bytes. */
async function extractAgentsMd(file: File): Promise<string | null> {
const buf = await file.arrayBuffer();
const tar = new Uint8Array(buf);
const size0Str = new TextDecoder().decode(tar.slice(124, 135)).replace(/\0/g, "");
const size0 = parseInt(size0Str, 8);
const blocks0 = Math.ceil(size0 / 512);
const entry1Start = 512 + blocks0 * 512;
if (entry1Start + 512 > tar.length) return null;
const name1 = new TextDecoder()
.decode(tar.slice(entry1Start, entry1Start + 100))
.replace(/\0/g, "");
if (!name1.startsWith("AGENTS.md")) return null;
const size1Str = new TextDecoder()
.decode(tar.slice(entry1Start + 124, entry1Start + 135))
.replace(/\0/g, "");
const size1 = parseInt(size1Str, 8);
return new TextDecoder().decode(tar.slice(entry1Start + 512, entry1Start + 512 + size1));
}
describe("buildAgentBundle", () => {
it("produces a tar.gz file with correct config.yaml for minimal input", async () => {
const input: AgentBundleInput = {
name: "test-agent",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
};
const file = await buildAgentBundle(input);
expect(file.name).toBe("agent.tar.gz");
expect(file.type).toBe("application/gzip");
const yaml = await extractConfigYaml(file);
expect(yaml).toContain("spec_version: 1");
expect(yaml).toContain("name: test-agent");
expect(yaml).toContain("model: claude-sonnet-4-20250514");
expect(yaml).toContain("harness: claude-sdk");
expect(yaml).toContain("web_search");
expect(yaml).toContain("web_fetch");
expect(yaml).not.toContain("instructions:");
expect(yaml).not.toContain("description:");
});
it("includes description when provided", async () => {
const input: AgentBundleInput = {
name: "my-agent",
description: "A helpful assistant",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
};
const yaml = await extractConfigYaml(await buildAgentBundle(input));
expect(yaml).toContain("description: A helpful assistant");
});
it("quotes description with special characters", async () => {
const input: AgentBundleInput = {
name: "my-agent",
description: 'Has: colons and "quotes"',
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
};
const yaml = await extractConfigYaml(await buildAgentBundle(input));
expect(yaml).toContain('description: "Has: colons and \\"quotes\\""');
});
it("includes AGENTS.md when instructions are provided", async () => {
const input: AgentBundleInput = {
name: "my-agent",
instructions: "You are a helpful assistant.",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
};
const file = await buildAgentBundle(input);
const yaml = await extractConfigYaml(file);
expect(yaml).toContain("instructions: AGENTS.md");
const md = await extractAgentsMd(file);
expect(md).toBe("You are a helpful assistant.");
});
it("omits AGENTS.md when no instructions", async () => {
const input: AgentBundleInput = {
name: "my-agent",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
};
const md = await extractAgentsMd(await buildAgentBundle(input));
expect(md).toBeNull();
});
it("includes inline MCP servers (stdio)", async () => {
const input: AgentBundleInput = {
name: "mcp-agent",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
mcpServers: [
{
name: "github",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_TOKEN: "ghp_test" },
},
],
};
const yaml = await extractConfigYaml(await buildAgentBundle(input));
expect(yaml).toContain(" github:");
expect(yaml).toContain(" type: mcp");
expect(yaml).toContain(" command: npx");
expect(yaml).toContain(' args: [-y, "@modelcontextprotocol/server-github"]');
expect(yaml).toContain(" GITHUB_TOKEN: ghp_test");
});
it("includes inline MCP servers (http)", async () => {
const input: AgentBundleInput = {
name: "http-agent",
harness: "claude-sdk",
model: "claude-sonnet-4-20250514",
mcpServers: [
{
name: "search",
transport: "http",
url: "https://mcp.example.com/sse",
headers: { Authorization: "Bearer tok_123" },
},
],
};
const yaml = await extractConfigYaml(await buildAgentBundle(input));
expect(yaml).toContain(" search:");
expect(yaml).toContain(" type: mcp");
expect(yaml).toContain(' url: "https://mcp.example.com/sse"');
expect(yaml).toContain(" Authorization: Bearer tok_123");
});
it("uses different harness and model values", async () => {
const input: AgentBundleInput = {
name: "oai-agent",
harness: "openai-agents",
model: "gpt-4o",
};
const yaml = await extractConfigYaml(await buildAgentBundle(input));
expect(yaml).toContain("harness: openai-agents");
expect(yaml).toContain("model: gpt-4o");
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* Client-side agent bundle builder.
*
* Produces a minimal `.tar.gz` bundle accepted by the server's
* multipart `POST /v1/sessions` endpoint. The bundle contains a
* `config.yaml` and optionally an `AGENTS.md` instructions file.
*/
/** An MCP server to include in the agent bundle. */
export interface MCPServerInput {
name: string;
/** "http" for SSE endpoints, "stdio" for local subprocesses. */
transport: "http" | "stdio";
/** SSE endpoint URL (http transport only). */
url?: string;
/** HTTP headers (http transport only), e.g. {"Authorization": "Bearer ..."} */
headers?: Record<string, string>;
/** Executable to spawn (stdio transport only), e.g. "npx". */
command?: string;
/** Args for the command (stdio transport only). */
args?: string[];
/** Env vars for the subprocess (stdio transport only). */
env?: Record<string, string>;
}
export interface AgentBundleInput {
name: string;
description?: string;
instructions?: string;
/** Harness kind, e.g. "claude-sdk", "openai-agents". */
harness: string;
/** Model identifier, e.g. "claude-sonnet-4-20250514". Required by the omnigent executor. */
model: string;
/** MCP server declarations to include as inline tools entries. */
mcpServers?: MCPServerInput[];
}
/**
* Build a `.tar.gz` agent bundle from the given input fields.
*
* Uses the pako library (already a transitive dep via codemirror) for
* gzip compression and a hand-rolled POSIX tar header for the two
* files. The result is a `File` suitable for `FormData.append`.
*/
export async function buildAgentBundle(input: AgentBundleInput): Promise<File> {
// Build config.yaml content
const lines: string[] = ["spec_version: 1", ""];
lines.push(`name: ${input.name}`);
if (input.description) {
lines.push(`description: ${yamlQuote(input.description)}`);
}
lines.push("");
lines.push("executor:");
lines.push(" type: omnigent");
lines.push(` model: ${input.model}`);
lines.push(" config:");
lines.push(` harness: ${input.harness}`);
lines.push("");
lines.push("tools:");
lines.push(" builtins:");
lines.push(" - web_search");
lines.push(" - web_fetch");
// Inline MCP server declarations (parsed by _parse_inline_mcp_servers).
if (input.mcpServers?.length) {
for (const mcp of input.mcpServers) {
lines.push(` ${mcp.name}:`);
lines.push(" type: mcp");
if (mcp.transport === "stdio") {
if (mcp.command) lines.push(` command: ${yamlQuote(mcp.command)}`);
if (mcp.args?.length) {
lines.push(` args: [${mcp.args.map((a) => yamlQuote(a)).join(", ")}]`);
}
if (mcp.env && Object.keys(mcp.env).length > 0) {
lines.push(" env:");
for (const [k, v] of Object.entries(mcp.env)) {
lines.push(` ${k}: ${yamlQuote(v)}`);
}
}
} else {
if (mcp.url) lines.push(` url: ${yamlQuote(mcp.url)}`);
if (mcp.headers && Object.keys(mcp.headers).length > 0) {
lines.push(" headers:");
for (const [k, v] of Object.entries(mcp.headers)) {
lines.push(` ${k}: ${yamlQuote(v)}`);
}
}
}
}
}
lines.push("");
if (input.instructions) {
lines.push("instructions: AGENTS.md");
lines.push("");
}
const configYaml = lines.join("\n");
// Build tar archive
const files: TarEntry[] = [
{ name: "config.yaml", content: new TextEncoder().encode(configYaml) },
];
if (input.instructions) {
files.push({ name: "AGENTS.md", content: new TextEncoder().encode(input.instructions) });
}
const tarBytes = createTar(files);
const gzipped = await gzip(tarBytes);
return new File([gzipped.buffer as ArrayBuffer], "agent.tar.gz", {
type: "application/gzip",
});
}
// ── Helpers ──────────────────────────────────────────────────────
/** Quote a YAML string value if it contains special characters. */
function yamlQuote(s: string): string {
if (/[:\n"'#{}[\],&*?|>!%@`]/.test(s) || s.trim() !== s) {
return JSON.stringify(s);
}
return s;
}
interface TarEntry {
name: string;
content: Uint8Array;
}
/**
* Create a minimal POSIX tar archive (uncompressed) from file entries.
* Each entry gets a 512-byte header followed by content padded to 512.
* Archive ends with two 512-byte zero blocks.
*/
function createTar(entries: TarEntry[]): Uint8Array {
const blocks: Uint8Array[] = [];
for (const entry of entries) {
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// File name (0..99)
const nameBytes = encoder.encode(entry.name);
header.set(nameBytes.slice(0, 100), 0);
// File mode (100..107) — 0644
writeOctal(header, 100, 8, 0o644);
// Owner ID (108..115)
writeOctal(header, 108, 8, 0);
// Group ID (116..123)
writeOctal(header, 116, 8, 0);
// File size (124..135)
writeOctal(header, 124, 12, entry.content.length);
// Modification time (136..147) — current time
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
// Type flag (156) — '0' for regular file
header[156] = 0x30;
// Magic (257..262) — "ustar\0"
header.set(encoder.encode("ustar\0"), 257);
// Version (263..264) — "00"
header.set(encoder.encode("00"), 263);
// Checksum (148..155) — fill with spaces first, compute, then write
for (let i = 148; i < 156; i++) header[i] = 0x20;
let checksum = 0;
for (let i = 0; i < 512; i++) checksum += header[i];
writeOctal(header, 148, 7, checksum);
header[155] = 0x20; // trailing space per POSIX
blocks.push(header);
// Content blocks (padded to 512-byte boundary)
const contentBlocks = Math.ceil(entry.content.length / 512);
const padded = new Uint8Array(contentBlocks * 512);
padded.set(entry.content);
blocks.push(padded);
}
// End-of-archive marker: two 512-byte zero blocks
blocks.push(new Uint8Array(1024));
// Concatenate all blocks
const totalSize = blocks.reduce((sum, b) => sum + b.length, 0);
const result = new Uint8Array(totalSize);
let offset = 0;
for (const block of blocks) {
result.set(block, offset);
offset += block.length;
}
return result;
}
/** Write a number as null-terminated octal string into a tar header field. */
function writeOctal(header: Uint8Array, offset: number, length: number, value: number): void {
const str = value.toString(8).padStart(length - 1, "0");
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
header.set(bytes.slice(0, length - 1), offset);
header[offset + length - 1] = 0; // null terminator
}
/** Gzip compress bytes using the Compression Streams API. */
async function gzip(data: Uint8Array): Promise<Uint8Array> {
const cs = new CompressionStream("gzip");
const writer = cs.writable.getWriter();
writer.write(data.buffer as ArrayBuffer);
writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
const result = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Shared agent-picker grouping: the built-in vs custom split and the
* preferred display order, used by both the new-session picker
* (NewChatDialog) and the fork/switch picker (ForkSessionDialog) so the
* two surfaces group and order agents identically.
*/
import type { AvailableAgent } from "@/hooks/useAvailableAgents";
import { nativeAgentSortRank } from "@/lib/nativeCodingAgents";
// Built-in agents (by name slug) — the long-lived agents the server ships
// out of the box. Pickers group these first, then a divider, then custom
// (user-registered) agents. GET /v1/agents doesn't yet distinguish the
// two, so this is a frontend allowlist for now.
export const BUILTIN_AGENTS = new Set([
"claude-native-ui", // Claude Code
"codex-native-ui", // Codex
"opencode-native-ui", // OpenCode
"pi-native-ui", // Pi
"cursor-native-ui", // Cursor
"kiro-native-ui", // Kiro
"antigravity-native-ui", // Antigravity
"goose-native-ui", // Goose
"qwen-native-ui", // Qwen Code
"kimi-native-ui", // Kimi
"polly",
"debby",
]);
// Preferred display order for the built-in group. The server returns
// agents newest-registered first (agent_store.list sorts by created_at
// desc), so pin the order users expect; any agent not listed here falls
// after, in server order.
export const AGENT_DISPLAY_ORDER = [
"Claude Code",
"Codex",
"OpenCode",
"Cursor",
"Pi",
"Kiro",
"Antigravity",
"Qwen Code",
"Kimi",
"Polly",
"Debby",
];
function displayRank(name: string): number {
const i = AGENT_DISPLAY_ORDER.indexOf(name);
return i === -1 ? AGENT_DISPLAY_ORDER.length : i;
}
/**
* Sort agents into the picker's canonical order: native coding agents by
* their sort rank first, then by {@link AGENT_DISPLAY_ORDER}. Stable, so
* unranked names keep their incoming (server / scan) relative order.
*
* @param agents - Agents to sort (not mutated; a copy is returned).
*/
export function sortAgentsForDisplay<T extends AvailableAgent>(agents: readonly T[]): T[] {
return [...agents].sort(
(a, b) =>
nativeAgentSortRank(a) - nativeAgentSortRank(b) ||
displayRank(a.display_name) - displayRank(b.display_name),
);
}
/**
* Sort then split agents into the built-in group and the custom group,
* for rendering with a divider between. Built-ins are the
* {@link BUILTIN_AGENTS} slugs; everything else is custom.
*
* @param agents - Agents to group (e.g. the picker's full candidate list).
* @returns ``{ builtins, customs }``, each sorted via
* {@link sortAgentsForDisplay}.
*/
export function partitionAgentsByKind<T extends AvailableAgent>(
agents: readonly T[],
): { builtins: T[]; customs: T[] } {
const sorted = sortAgentsForDisplay(agents);
return {
builtins: sorted.filter((a) => BUILTIN_AGENTS.has(a.name)),
customs: sorted.filter((a) => !BUILTIN_AGENTS.has(a.name)),
};
}
+1
View File
@@ -20,6 +20,7 @@ export const BRAIN_HARNESS_LABELS: Record<string, string> = {
cursor: "Cursor",
pi: "Pi",
antigravity: "Antigravity",
copilot: "Copilot",
};
/**
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { ATTACHMENT_SIZE_LIMITS_MB, classifyAttachment, validateAttachments } from "./attachments";
function makeFile(name: string, type: string, bytes = 10): File {
return new File([new Uint8Array(bytes)], name, { type });
}
const MB = 1024 * 1024;
describe("classifyAttachment", () => {
it("classifies images by MIME", () => {
expect(classifyAttachment(makeFile("a.png", "image/png"))).toBe("image");
expect(classifyAttachment(makeFile("a.jpg", "image/jpeg"))).toBe("image");
});
it("classifies PDF by MIME or extension", () => {
expect(classifyAttachment(makeFile("a.pdf", "application/pdf"))).toBe("pdf");
// Some browsers report an empty type for PDFs — fall back to extension.
expect(classifyAttachment(makeFile("a.pdf", ""))).toBe("pdf");
});
it("classifies text/code, including code files with empty/wrong MIME", () => {
expect(classifyAttachment(makeFile("a.txt", "text/plain"))).toBe("text");
expect(classifyAttachment(makeFile("a.json", "application/json"))).toBe("text");
// .ts reports video/mp2t in some browsers; extension wins.
expect(classifyAttachment(makeFile("a.ts", "video/mp2t"))).toBe("text");
expect(classifyAttachment(makeFile("main.rs", ""))).toBe("text");
expect(classifyAttachment(makeFile("notebook.ipynb", ""))).toBe("text");
// Windows/Excel tags .csv as application/vnd.ms-excel — extension wins.
expect(classifyAttachment(makeFile("data.csv", "application/vnd.ms-excel"))).toBe("text");
});
it("rejects office/binary types", () => {
const pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
expect(classifyAttachment(makeFile("deck.pptx", pptx))).toBeNull();
expect(classifyAttachment(makeFile("a.zip", "application/zip"))).toBeNull();
expect(classifyAttachment(makeFile("a.bin", "application/octet-stream"))).toBeNull();
expect(classifyAttachment(makeFile("a.mp4", "video/mp4"))).toBeNull();
});
});
describe("validateAttachments", () => {
it("accepts supported files within their size limit", () => {
const files = [makeFile("a.png", "image/png"), makeFile("a.pdf", "application/pdf")];
const { accepted, errors } = validateAttachments(files);
expect(accepted).toHaveLength(2);
expect(errors).toHaveLength(0);
});
it("rejects unsupported types with a message", () => {
const pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
const { accepted, errors } = validateAttachments([makeFile("deck.pptx", pptx)]);
expect(accepted).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("deck.pptx");
});
it("rejects files over their per-type size limit", () => {
const bigImage = makeFile("huge.png", "image/png", ATTACHMENT_SIZE_LIMITS_MB.image * MB + 1);
const { accepted, errors } = validateAttachments([bigImage]);
expect(accepted).toHaveLength(0);
expect(errors[0]).toContain("too large");
});
it("partitions a mixed batch into accepted + errors", () => {
const ok = makeFile("a.png", "image/png");
const badType = makeFile("a.zip", "application/zip");
const tooBig = makeFile("big.pdf", "application/pdf", ATTACHMENT_SIZE_LIMITS_MB.pdf * MB + 1);
const { accepted, errors } = validateAttachments([ok, badType, tooBig]);
expect(accepted).toEqual([ok]);
expect(errors).toHaveLength(2);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Client-side attachment validation: which files can be attached, and how
* large each type may be.
*
* This mirrors the authoritative server-side checks in
* omnigent/runtime/content_resolver.py (`attachment_upload_limit`) and the
* upload route (415 for unsupported types, 413 for oversized). Keeping a
* copy here lets us reject a bad file at paste/drop/pick time before a
* slow upload with a friendly message. The server still enforces; this is
* UX only. Keep the limits in sync with the Python constants.
*/
/** Per-type upload size limits, in megabytes. Mirrors the server caps. */
export const ATTACHMENT_SIZE_LIMITS_MB = {
image: 5,
pdf: 20,
text: 10,
} as const;
export type AttachmentCategory = keyof typeof ATTACHMENT_SIZE_LIMITS_MB;
// Text-bearing application/* MIME types (the rest of the text-like surface
// is text/*). Mirrors _TEXT_LIKE_APPLICATION_MIMES on the server.
const TEXT_LIKE_APPLICATION_MIMES = new Set([
"application/json",
"application/javascript",
"application/jsonl",
"application/x-ndjson",
"application/x-ipynb+json",
]);
// Text/code extensions whose browser-reported MIME type is often empty or
// wrong (e.g. a .ts file reports video/mp2t, .rs reports nothing). Mirrors
// the code entries in _EXTRA_MIME_TYPES on the server so we accept the same
// files the backend resolves to a text/* type.
const TEXT_CODE_EXTENSIONS = new Set([
".txt",
".log",
".md",
".markdown",
".csv",
".json",
".jsonl",
".ndjson",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".env",
".lock",
".proto",
".graphql",
".gql",
".html",
".htm",
".xml",
".css",
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".py",
".rb",
".go",
".rs",
".java",
".kt",
".scala",
".swift",
".c",
".h",
".cc",
".cpp",
".hpp",
".cs",
".php",
".pl",
".r",
".jl",
".lua",
".ex",
".exs",
".erl",
".hs",
".clj",
".dart",
".vue",
".svelte",
".sh",
".bash",
".zsh",
".fish",
".sql",
".tf",
".hcl",
".gradle",
".dockerfile",
".ipynb",
]);
function extensionOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot).toLowerCase() : "";
}
/**
* Classify a file into an attachment category, or `null` if its type is not
* supported (e.g. pptx, docx, xlsx, zip, binaries). Uses the browser MIME
* type first, falling back to the filename extension for code/text files
* whose MIME is unreliable.
*/
export function classifyAttachment(file: File): AttachmentCategory | null {
const type = file.type || "";
const ext = extensionOf(file.name || "");
if (type.startsWith("image/")) return "image";
if (type === "application/pdf" || ext === ".pdf") return "pdf";
if (
type.startsWith("text/") ||
TEXT_LIKE_APPLICATION_MIMES.has(type) ||
TEXT_CODE_EXTENSIONS.has(ext)
) {
return "text";
}
return null;
}
export interface AttachmentValidation {
/** Files that passed type + size checks. */
accepted: File[];
/** Human-readable rejection messages, one per rejected file. */
errors: string[];
}
/**
* Split *files* into accepted attachments and rejection messages. A file is
* rejected when its type is unsupported, or when it exceeds the per-type
* size limit.
*/
export function validateAttachments(files: File[]): AttachmentValidation {
const accepted: File[] = [];
const errors: string[] = [];
for (const file of files) {
const name = file.name || "file";
const category = classifyAttachment(file);
if (category === null) {
errors.push(
`"${name}" can't be attached — only images, PDF, and text/code files are supported.`,
);
continue;
}
const limitMb = ATTACHMENT_SIZE_LIMITS_MB[category];
if (file.size > limitMb * 1024 * 1024) {
errors.push(`"${name}" is too large — the limit for ${category} files is ${limitMb} MB.`);
continue;
}
accepted.push(file);
}
return { accepted, errors };
}
+25
View File
@@ -1314,6 +1314,31 @@ describe("BlockStream — status events", () => {
}
});
it("output_item.done error event preserves persisted ids", () => {
const blocks = reduce([
{
type: "error",
source: "execution",
toolName: null,
error: {
code: "kiro_native_prompt_not_recorded",
message: "Kiro did not accept this web message.",
},
itemId: "err_kiro",
responseId: "resp_kiro_failed_input",
},
]);
const err = blocks.find((b) => b.type === "error");
expect(err).toBeDefined();
if (err && err.type === "error") {
expect(err.ctx.itemId).toBe("err_kiro");
expect(err.ctx.responseId).toBe("resp_kiro_failed_input");
expect(err.message).toBe("Kiro did not accept this web message.");
expect(err.code).toBe("kiro_native_prompt_not_recorded");
}
});
it("retry event → RetryBlock with attempt/max/delay fields", () => {
const blocks = reduce([
{ type: "response_created", response: makeResponse() },
+1 -1
View File
@@ -757,7 +757,7 @@ function* processEvent(state: ReducerState, event: StreamEvent): Generator<AnyBl
// shows just `[llm]` with no hint as to what went wrong).
yield {
type: "error",
ctx: ctx(state),
ctx: ctx(state, event.itemId ?? null, event.responseId ?? null),
message: event.error.message,
source: event.source,
code: event.error.code,
+7
View File
@@ -0,0 +1,7 @@
export type ChangedSort = "alpha" | "recent" | "size" | "type";
const VALID_SORTS = new Set<ChangedSort>(["alpha", "recent", "size", "type"]);
export function isValidSort(value: string): value is ChangedSort {
return VALID_SORTS.has(value as ChangedSort);
}
+4
View File
@@ -359,6 +359,10 @@ export interface ErrorEvent {
source: string;
toolName: string | null;
error: ErrorInfo;
/** Server-assigned item id when parsed from `response.output_item.done`. */
itemId?: string;
/** Server-assigned response id when parsed from `response.output_item.done`. */
responseId?: string;
}
// ── Compaction ───────────────────────────────────────────
+20 -5
View File
@@ -20,10 +20,12 @@ describe("filesPanelPreferences", () => {
});
it("round-trips a written preference", () => {
writeFilesPanelPreferences({ changedOnly: true });
// The written value must come back — proves the write serialized and the
// read parsed/validated the field.
expect(readFilesPanelPreferences()).toEqual({ changedOnly: true });
writeFilesPanelPreferences({ changedOnly: true, sort: "alpha", collapsed: false });
expect(readFilesPanelPreferences()).toEqual({
changedOnly: true,
sort: "alpha",
collapsed: false,
});
});
it("falls back to defaults on malformed JSON", () => {
@@ -43,6 +45,19 @@ describe("filesPanelPreferences", () => {
// A record present but with a non-boolean changedOnly must default the
// field rather than pass a garbage value through to the panel.
localStorage.setItem(STORAGE_KEY, JSON.stringify({ changedOnly: "yes" }));
expect(readFilesPanelPreferences()).toEqual({ changedOnly: false });
expect(readFilesPanelPreferences()).toEqual({
changedOnly: false,
sort: "recent",
collapsed: false,
});
});
it("defaults sort when the stored value is invalid", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ changedOnly: true, sort: "bogus" }));
expect(readFilesPanelPreferences()).toEqual({
changedOnly: true,
sort: "recent",
collapsed: false,
});
});
});
+14
View File
@@ -13,9 +13,15 @@
// the last choice instead of the hardcoded default. A deep-link ?view= URL
// param overrides the stored preference transiently without mutating it.
import { type ChangedSort, isValidSort } from "@/lib/changedSort";
export interface FilesPanelPreferences {
/** true = changed-files-only flat list, false = full folder tree ("All"). */
changedOnly: boolean;
/** Sort order for the changed-files flat list. */
sort: ChangedSort;
/** true = the panel header is collapsed (content hidden). */
collapsed: boolean;
}
const STORAGE_KEY = "omnigent:files-panel-preferences";
@@ -24,6 +30,8 @@ const STORAGE_KEY = "omnigent:files-panel-preferences";
// the working folder, not just the changed subset.
export const DEFAULT_FILES_PANEL_PREFERENCES: FilesPanelPreferences = {
changedOnly: false,
sort: "recent",
collapsed: false,
};
/**
@@ -46,6 +54,12 @@ export function readFilesPanelPreferences(): FilesPanelPreferences {
typeof p.changedOnly === "boolean"
? p.changedOnly
: DEFAULT_FILES_PANEL_PREFERENCES.changedOnly,
sort:
typeof p.sort === "string" && isValidSort(p.sort)
? p.sort
: DEFAULT_FILES_PANEL_PREFERENCES.sort,
collapsed:
typeof p.collapsed === "boolean" ? p.collapsed : DEFAULT_FILES_PANEL_PREFERENCES.collapsed,
};
} catch {
return DEFAULT_FILES_PANEL_PREFERENCES;
+22 -6
View File
@@ -18,6 +18,9 @@ describe("harnessFamily", () => {
["openai-agents", "openai"],
["openai-agents-sdk", "openai"],
["agents_sdk", "openai"],
["antigravity-native", "gemini"],
["native-antigravity", "gemini"],
["antigravity", "gemini"],
])("maps %s → %s", (harness, family) => {
expect(harnessFamily(harness)).toBe(family);
});
@@ -38,12 +41,18 @@ describe("isNativeHarness", () => {
["native-codex", true],
["pi-native", true],
["native-pi", true],
// Antigravity-native spellings are native too — aligned with Python
// NATIVE_HARNESSES (the in-process `antigravity` SDK harness is NOT).
["antigravity-native", true],
["native-antigravity", true],
["claude-sdk", false],
["claude_sdk", false],
["openai-agents", false],
["codex", false],
// The SDK `pi` harness is in-process, not a native CLI wrapper.
["pi", false],
// The in-process Antigravity SDK harness is likewise not native.
["antigravity", false],
[null, false],
])("classifies %s as native=%s", (harness, expected) => {
expect(isNativeHarness(harness as string | null)).toBe(expected);
@@ -54,12 +63,17 @@ describe("forkTargetCarriesHistory", () => {
// SDK targets always carry history as context, regardless of source or
// family — including native → SDK and cross-family. A false here would
// wrongly hide a fully-supported switch from the picker.
it.each([["claude-sdk"], ["claude_sdk"], ["codex"], ["openai-agents"], ["agents_sdk"]])(
"SDK target %s carries history",
(target) => {
expect(forkTargetCarriesHistory(target)).toBe(true);
},
);
it.each([
["claude-sdk"],
["claude_sdk"],
["codex"],
["openai-agents"],
["agents_sdk"],
// antigravity is the Gemini-family SDK target.
["antigravity"],
])("SDK target %s carries history", (target) => {
expect(forkTargetCarriesHistory(target)).toBe(true);
});
// Native targets carry from ANY source: the runner clones the source's
// native transcript when the source is same-family native, else rebuilds
@@ -77,6 +91,8 @@ describe("forkTargetCarriesHistory", () => {
// still be offered, or the fork/switch-agent pickers silently drop it.
["pi-native"],
["native-pi"],
["antigravity-native"],
["native-antigravity"],
])("native target %s carries history", (target) => {
expect(forkTargetCarriesHistory(target)).toBe(true);
});
+19 -3
View File
@@ -19,7 +19,9 @@
// and tests/e2e/test_host_cross_family_fork_e2e.py).
/** Provider family a harness consumes, or null when unknown. */
export function harnessFamily(harness: string | null | undefined): "anthropic" | "openai" | null {
export function harnessFamily(
harness: string | null | undefined,
): "anthropic" | "openai" | "gemini" | null {
if (!harness) return null;
switch (harness) {
case "claude-native":
@@ -34,12 +36,24 @@ export function harnessFamily(harness: string | null | undefined): "anthropic" |
case "openai-agents-sdk":
case "agents_sdk":
return "openai";
// Antigravity is Gemini-family: the native CLI (`antigravity-native`)
// and the in-process SDK (`antigravity`, plus reversed spellings) all
// consume Gemini models.
case "antigravity-native":
case "native-antigravity":
case "antigravity":
return "gemini";
default:
return null;
}
}
/** Whether a harness is a native CLI harness (Claude Code / Codex / Pi). */
/**
* Whether a harness is a native CLI harness (Claude Code / Codex / Pi /
* Antigravity). Mirrors Python `NATIVE_HARNESSES` (`omnigent/harness_aliases.py`)
* including both native-antigravity spellings (the in-process `antigravity`
* SDK harness is NOT native) so both sides classify the same set.
*/
export function isNativeHarness(harness: string | null | undefined): boolean {
return (
harness === "claude-native" ||
@@ -47,7 +61,9 @@ export function isNativeHarness(harness: string | null | undefined): boolean {
harness === "codex-native" ||
harness === "native-codex" ||
harness === "pi-native" ||
harness === "native-pi"
harness === "native-pi" ||
harness === "antigravity-native" ||
harness === "native-antigravity"
);
}
+49
View File
@@ -76,6 +76,22 @@ interface NativeShellApi {
setViewMode?: (params: NativeViewModeParams) => void;
/** Subscribe to taps on the native switcher; returns an unsubscribe. */
onViewModeChanged?: (callback: (mode: NativeViewMode) => void) => () => void;
/**
* Subscribe to the footprint (CSS px, excluding the OS safe area) of the
* native floating bars. The shell pushes this whenever it changes and
* immediately on subscribe, since it caches the last value so the web
* layer can fold the real bar dimensions into its inset variables instead of
* hardcoding them. Absent on older shells. Returns an unsubscribe.
*/
onNativeInsets?: (callback: (insets: NativeInsets) => void) => () => void;
}
/** Footprints (CSS px) of the native floating bars, reported by the shell. */
export interface NativeInsets {
/** Server switcher pill height + its top padding. */
topBar: number;
/** Chat/Terminal bar capsule height + its bottom padding. */
bottomBar: number;
}
export type NativeViewMode = "chat" | "terminal";
@@ -264,11 +280,25 @@ export async function setBadgeCount(count: number): Promise<void> {
}
}
/**
* Set one of the inset-system CSS variables on the document root. Visibility of
* the native bars is web-owned (the web app is what shows/hides them), so the
* setters below fold it into `--omnigent-*-bar-visible`; the bars' size comes
* from the native bridge (see {@link onNativeInsets} / nativeInsets.ts). Both
* combine in `--omnigent-inset-*` (index.css). Harmless off-shell the size
* vars stay 0 there, so a stray visibility flag contributes nothing.
*/
function setInsetVar(name: string, value: string): void {
if (typeof document === "undefined") return;
document.documentElement.style.setProperty(name, value);
}
/**
* Inform a native shell that its server switcher should hide. Older shells
* simply lack this optional method, so this degrades to a no-op.
*/
export function setNativeServerSwitcherHidden(hidden: boolean): void {
setInsetVar("--omnigent-top-bar-visible", hidden ? "0" : "1");
const native = nativeApi();
const setter = native?.setServerSwitcherHidden ?? native?.setSidebarOpen;
if (!setter) return;
@@ -292,6 +322,7 @@ export function setNativeSidebarOpen(open: boolean): void {
* caller renders its own in-page pill there.
*/
export function setNativeViewMode(params: NativeViewModeParams): void {
setInsetVar("--omnigent-bottom-bar-visible", params.visible ? "1" : "0");
const native = nativeApi();
if (!native?.setViewMode) return;
try {
@@ -317,6 +348,24 @@ export function onNativeViewModeChanged(callback: (mode: NativeViewMode) => void
}
}
/**
* Subscribe to the native bars' footprint from the shell. The shell pushes the
* current value immediately on subscribe (it caches the last emit), then again
* on any change. Returns an unsubscribe; a no-op outside a shell that reports
* insets (Electron, plain browser, older iOS shells), where the bars don't
* exist and the inset CSS vars stay 0.
*/
export function onNativeInsets(callback: (insets: NativeInsets) => void): () => void {
const native = nativeApi();
if (!native?.onNativeInsets) return () => {};
try {
return native.onNativeInsets(callback);
} catch (err) {
console.warn("[nativeBridge] native onNativeInsets failed:", err);
return () => {};
}
}
/**
* Fetch the title-bar server picker data from the Electron shell: the
* window's current server origin plus the recently-connected server list.
+57
View File
@@ -12,6 +12,22 @@ describe("nativeCodingAgentForHarness", () => {
expect(nativeCodingAgentForHarness("pi-native")?.key).toBe("pi");
});
it("resolves the canonical opencode-native harness", () => {
expect(nativeCodingAgentForHarness("opencode-native")?.key).toBe("opencode");
});
it("resolves the canonical qwen-native harness", () => {
const agent = nativeCodingAgentForHarness("qwen-native");
expect(agent?.key).toBe("qwen");
expect(agent?.displayName).toBe("Qwen Code");
});
it("folds the reversed native-qwen alias to the qwen-native spec", () => {
expect(nativeCodingAgentForHarness("native-qwen")).toBe(
nativeCodingAgentForHarness("qwen-native"),
);
});
// The server's harness_kind returns the raw executor.config.harness, so a
// `native-pi` agent must fold to the same spec — else fork/switch into it
// would miss the terminal-first wrapper labels and render as chat.
@@ -19,8 +35,33 @@ describe("nativeCodingAgentForHarness", () => {
expect(nativeCodingAgentForHarness("native-pi")).toBe(nativeCodingAgentForHarness("pi-native"));
});
it("resolves Kiro and folds the reversed native-kiro alias", () => {
const kiro = nativeCodingAgentForHarness("kiro-native");
expect(kiro).toMatchObject({
key: "kiro",
displayName: "Kiro",
harness: "kiro-native",
wrapperLabel: "kiro-native-ui",
});
expect(nativeCodingAgentForHarness("native-kiro")).toBe(kiro);
});
it("resolves the canonical antigravity-native harness", () => {
expect(nativeCodingAgentForHarness("antigravity-native")?.key).toBe("antigravity");
});
// Same reversed-alias contract as native-pi: `native-antigravity` must
// fold to the canonical antigravity-native spec.
it("folds the reversed native-antigravity alias to the antigravity-native spec", () => {
expect(nativeCodingAgentForHarness("native-antigravity")).toBe(
nativeCodingAgentForHarness("antigravity-native"),
);
});
it("leaves unknown / non-native harnesses unresolved", () => {
expect(nativeCodingAgentForHarness("claude-sdk")).toBeUndefined();
// The in-process Antigravity SDK harness is not a native CLI wrapper.
expect(nativeCodingAgentForHarness("antigravity")).toBeUndefined();
expect(nativeCodingAgentForHarness(null)).toBeUndefined();
expect(nativeCodingAgentForHarness(undefined)).toBeUndefined();
});
@@ -33,4 +74,20 @@ describe("nativeWrapperLabelsForAgent", () => {
[WRAPPER_LABEL_KEY]: "pi-native-ui",
});
});
it("stamps terminal-first labels for a native-antigravity agent", () => {
expect(nativeWrapperLabelsForAgent({ name: "my-agy", harness: "native-antigravity" })).toEqual({
[UI_MODE_LABEL_KEY]: UI_MODE_TERMINAL_VALUE,
[WRAPPER_LABEL_KEY]: "antigravity-native-ui",
});
});
it("stamps terminal-first labels for an opencode-native agent", () => {
expect(
nativeWrapperLabelsForAgent({ name: "my-opencode", harness: "opencode-native" }),
).toEqual({
[UI_MODE_LABEL_KEY]: UI_MODE_TERMINAL_VALUE,
[WRAPPER_LABEL_KEY]: "opencode-native-ui",
});
});
});
+89 -4
View File
@@ -4,7 +4,18 @@ export const WRAPPER_LABEL_KEY = "omnigent.wrapper";
export const UI_MODE_LABEL_KEY = "omnigent.ui";
export const UI_MODE_TERMINAL_VALUE = "terminal";
export type NativeCodingAgentIconKind = "claude" | "codex" | "pi" | "cursor" | "goose";
export type NativeCodingAgentIconKind =
| "claude"
| "codex"
| "opencode"
| "pi"
| "cursor"
| "kiro"
| "goose"
| "qwen"
| "antigravity"
| "kimi"
| "hermes";
export type NativeCodingAgentCapability = "permissionMode" | "approvalMode";
export interface NativeCodingAgentSpec {
@@ -39,6 +50,16 @@ export const NATIVE_CODING_AGENTS = [
sortRank: 20,
capabilities: ["approvalMode"],
},
{
key: "opencode",
agentName: "opencode-native-ui",
harness: "opencode-native",
wrapperLabel: "opencode-native-ui",
displayName: "OpenCode",
iconKind: "opencode",
sortRank: 25,
capabilities: ["approvalMode"],
},
{
key: "cursor",
agentName: "cursor-native-ui",
@@ -57,6 +78,29 @@ export const NATIVE_CODING_AGENTS = [
iconKind: "pi",
sortRank: 40,
},
{
key: "kiro",
agentName: "kiro-native-ui",
harness: "kiro-native",
wrapperLabel: "kiro-native-ui",
displayName: "Kiro",
iconKind: "kiro",
sortRank: 50,
},
{
// Antigravity's native CLI (Gemini-family). Mirrors the server's
// canonical `antigravity-native` harness and the `antigravity-native-ui`
// wrapper the runner keys off to boot the terminal. Added ALONGSIDE the
// upstream in-process `antigravity` SDK harness (see BRAIN_HARNESS_LABELS
// in agentLabels.ts) — they are distinct rows.
key: "antigravity",
agentName: "antigravity-native-ui",
harness: "antigravity-native",
wrapperLabel: "antigravity-native-ui",
displayName: "Antigravity",
iconKind: "antigravity",
sortRank: 45,
},
{
key: "goose",
agentName: "goose-native-ui",
@@ -64,7 +108,42 @@ export const NATIVE_CODING_AGENTS = [
wrapperLabel: "goose-native-ui",
displayName: "Goose",
iconKind: "goose",
sortRank: 50,
sortRank: 60,
},
{
// qwen has no brand glyph yet, so it falls back to the generic bot icon
// (see AgentCard.iconForAgent / SubagentsPanel) — the `iconKind: "qwen"`
// intentionally matches no icon branch. Auth/approval surface in the
// embedded terminal, so no capability flags are declared here.
key: "qwen",
agentName: "qwen-native-ui",
harness: "qwen-native",
wrapperLabel: "qwen-native-ui",
displayName: "Qwen Code",
iconKind: "qwen",
sortRank: 60,
},
{
key: "kimi",
agentName: "kimi-native-ui",
harness: "kimi-native",
wrapperLabel: "kimi-native-ui",
displayName: "Kimi",
iconKind: "kimi",
sortRank: 70,
},
{
// hermes has no brand glyph yet, so it falls back to the generic bot icon
// (see AgentCard.iconForAgent / SubagentsPanel) — the `iconKind: "hermes"`
// intentionally matches no icon branch. Auth/approval surface in the
// embedded terminal, so no capability flags are declared here.
key: "hermes",
agentName: "hermes-native-ui",
harness: "hermes-native",
wrapperLabel: "hermes-native-ui",
displayName: "Hermes",
iconKind: "hermes",
sortRank: 80,
},
] as const satisfies readonly NativeCodingAgentSpec[];
@@ -79,12 +158,18 @@ const BY_WRAPPER: Map<string, NativeCodingAgentSpec> = new Map(
);
// Reversed harness spellings that fold to a canonical native `harness`.
// Mirrors omnigent.harness_aliases on the server: only `native-pi` is a
// supported reversed alias (claude/codex use the canonical form).
// Mirrors omnigent.harness_aliases.NATIVE_HARNESSES on the server, which
// accepts both the canonical and reversed native spellings (claude/codex
// only use the canonical form, so they need no reversed entry here).
const HARNESS_ALIASES: Record<string, string> = {
"native-pi": "pi-native",
"native-cursor": "cursor-native",
"native-kiro": "kiro-native",
"native-antigravity": "antigravity-native",
"native-goose": "goose-native",
"native-qwen": "qwen-native",
"native-kimi": "kimi-native",
"native-hermes": "hermes-native",
};
export function nativeCodingAgentForAgentName(
+27
View File
@@ -0,0 +1,27 @@
// Applies the native iOS shell's reported bar footprints to the inset CSS
// variables the layout consumes.
//
// The iOS shell pushes the pixel height of its floating server switcher (top)
// and chat/terminal bar (bottom) over the bridge — the one piece of the inset
// system that CSS/JS cannot compute on its own. This module writes those onto
// `--omnigent-native-top-bar` / `--omnigent-native-bottom-bar`; index.css folds
// them (together with the web-owned visibility flags and `env(safe-area-*)`)
// into `--omnigent-inset-top` / `--omnigent-inset-bottom`, which `<PageScroll>`
// and the scoped iOS rules read.
//
// No-op off the iOS shell: the size vars keep their `0px` defaults, so the same
// inset variables resolve to plain `env(safe-area-*)` (browser, Electron).
import { onNativeInsets } from "@/lib/nativeBridge";
/**
* Start mirroring native bar footprints into the inset CSS variables. Call once
* at app startup. Returns an unsubscribe (a no-op outside the iOS shell).
*/
export function initNativeInsets(): () => void {
return onNativeInsets(({ topBar, bottomBar }) => {
const root = document.documentElement.style;
root.setProperty("--omnigent-native-top-bar", `${topBar}px`);
root.setProperty("--omnigent-native-bottom-bar", `${bottomBar}px`);
});
}
+40
View File
@@ -65,6 +65,46 @@ describe("coercePolicyParams", () => {
});
});
it("parses browser number input notation for integer fields", () => {
const result = coercePolicyParams(
["threshold"],
{ threshold: { type: "integer" } },
{ threshold: "1e2" },
);
expect(result).toEqual({
ok: true,
params: { threshold: 100 },
});
});
it("rejects decimal and non-numeric input for integer fields", () => {
const decimal = coercePolicyParams(
["threshold"],
{ threshold: { type: "integer" } },
{ threshold: "12.9" },
);
expect(decimal.ok).toBe(false);
if (!decimal.ok) expect(decimal.error).toContain("integer");
const invalid = coercePolicyParams(
["threshold"],
{ threshold: { type: "integer" } },
{ threshold: "abc" },
);
expect(invalid.ok).toBe(false);
if (!invalid.ok) expect(invalid.error).toContain("integer");
});
it("rejects partial numeric strings for number fields", () => {
const result = coercePolicyParams(
["ratio"],
{ ratio: { type: "number" } },
{ ratio: "1.2abc" },
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("number");
});
it("passes string and unmapped types through verbatim", () => {
const result = coercePolicyParams(
["state_key"],
+16 -2
View File
@@ -40,9 +40,23 @@ export function coercePolicyParams(
if (raw === undefined || raw === "") continue;
const type = properties[key]?.type;
if (type === "integer") {
params[key] = parseInt(raw, 10);
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return {
ok: false,
error: `${key} must be an integer, e.g. 20`,
};
}
params[key] = value;
} else if (type === "number") {
params[key] = parseFloat(raw);
const value = Number(raw);
if (!Number.isFinite(value)) {
return {
ok: false,
error: `${key} must be a number, e.g. 0.5`,
};
}
params[key] = value;
} else if (type === "boolean") {
params[key] = raw === "true";
} else if (type === "array") {
+30
View File
@@ -452,6 +452,36 @@ describe("response.output_item.done (message)", () => {
});
});
describe("response.output_item.done (error)", () => {
it("lifts persisted error items for live transcript rendering", () => {
const out = parse("response.output_item.done", {
type: "response.output_item.done",
item: {
id: "err_kiro",
type: "error",
status: "completed",
response_id: "resp_kiro_failed_input",
source: "execution",
code: "kiro_native_prompt_not_recorded",
message: "Kiro did not accept this web message.",
},
});
expect(out).toHaveLength(1);
expect(out[0]).toEqual({
type: "error",
source: "execution",
toolName: null,
error: {
code: "kiro_native_prompt_not_recorded",
message: "Kiro did not accept this web message.",
},
itemId: "err_kiro",
responseId: "resp_kiro_failed_input",
});
});
});
describe("response.output_item.done (slash_command)", () => {
// parseOutputItem returns null for unknown item.types; without
// these cases the live UI silently drops every Skill invocation.
+43
View File
@@ -417,6 +417,49 @@ export async function createSession(
return sessionFromWire(await readJsonOrThrow<SessionResponseWire>(res));
}
/**
* Create a session with an inline agent bundle via multipart
* `POST /v1/sessions`.
*
* The server creates a session-scoped agent from the uploaded bundle
* (a `.tar.gz` containing `config.yaml` and optionally `AGENTS.md`)
* and binds it to the new session in one atomic operation. Session
* metadata (host, workspace, labels, etc.) is sent as a JSON string
* in the `metadata` form part.
*
* @param bundle - The agent bundle as a `File` (`.tar.gz`).
* @param metadata - Session-level metadata (host_id, workspace, labels, etc.).
* @returns The created session's id.
*/
export async function createBundledSession(
bundle: File,
metadata: {
host_id?: string;
host_type?: string;
workspace?: string;
labels?: Record<string, string>;
terminal_launch_args?: string[];
git?: { branch_name: string; base_branch?: string };
} = {},
): Promise<{ id: string }> {
const form = new FormData();
form.append("metadata", JSON.stringify(metadata));
form.append("bundle", bundle);
const res = await authenticatedFetch("/v1/sessions", {
method: "POST",
body: form,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create bundled session: ${res.status} ${text}`);
}
// The multipart response uses `session_id` (CreatedSessionResponse),
// while the JSON path uses `id` (SessionResponse). Normalize to `id`
// so callers don't need to care which path was taken.
const body = (await res.json()) as { session_id: string };
return { id: body.session_id };
}
/**
* Fork (clone) a session into a new one via
* POST /v1/sessions/{source_id}/fork.
+14
View File
@@ -923,6 +923,20 @@ function parseOutputItem(data: Record<string, unknown>): StreamEvent | null {
} satisfies MessageDone;
}
if (itemType === "error") {
return {
type: "error",
source: String(rec.source ?? ""),
toolName: null,
error: {
code: String(rec.code ?? ""),
message: String(rec.message ?? ""),
},
itemId,
responseId,
} satisfies ErrorEvent;
}
if (itemType === "slash_command") {
// Coerce a missing ``output`` (server-side exclude_none) to null
// so downstream code branches on a single shape.
+5
View File
@@ -10,6 +10,7 @@ import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider";
import { resolveServerInfo, type ServerInfo } from "./lib/capabilities";
import { CapabilitiesProvider } from "./lib/CapabilitiesContext";
import { resolveIdentity } from "./lib/identity";
import { initNativeInsets } from "./lib/nativeInsets";
import { initChatStore } from "./store/chatStore";
import "./index.css";
@@ -35,6 +36,10 @@ initChatStore(queryClient);
// routes know who's making the request.
void resolveIdentity();
// Mirror the iOS shell's native bar footprints into the inset CSS variables.
// No-op off the iOS shell (the inset vars stay at their env()-only defaults).
initNativeInsets();
// Probe /v1/info BEFORE the first render so the route table knows
// whether to mount accounts routes. The probe is unauthed and the
// failure path resolves to "accounts off" — so even a stalled or
+82 -7
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatStore } from "@/store/chatStore";
@@ -76,7 +76,9 @@ describe("Composer slash-command menu", () => {
});
it("highlights the first match as soon as the menu opens", () => {
render(<Composer {...composerProps()} />);
// /compact is native-wrapper-only (#1139); render a native session so it
// appears as the first built-in and is the default highlight.
render(<Composer {...composerProps({ isNativeWrapper: true })} />);
fireEvent.change(textarea(), { target: { value: "/" } });
// Built-ins are inserted first, so "/compact" tops the list and is the
// default highlight — the crux of the fix (was -1 / nothing selected).
@@ -148,7 +150,9 @@ describe("Composer slash-command menu", () => {
});
it("ArrowDown moves the highlight to the next match", () => {
render(<Composer {...composerProps()} />);
// /compact is native-wrapper-only (#1139); render a native session so the
// first built-in is "/compact" and ArrowDown advances to "/context".
render(<Composer {...composerProps({ isNativeWrapper: true })} />);
const ta = textarea();
fireEvent.change(ta, { target: { value: "/" } });
expect(activeRow()?.textContent).toContain("/compact");
@@ -417,6 +421,75 @@ describe("Composer slash-command submit routing", () => {
});
});
describe("AgentPicker trigger label", () => {
beforeEach(() => {
useChatStore.setState({
conversationId: "conv_test",
skills: [],
selectedModel: null,
selectedEffort: null,
llmModel: null,
codexModelOptions: [],
nativeVendorOwnsModel: false,
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("shows the model in the foreground and effort muted (model/effort swapped into the trigger)", () => {
useChatStore.setState({ selectedModel: "opus", selectedEffort: "high" });
renderWithTooltips(
<Composer
{...composerProps({
agents: [{ id: "a1", name: "claude" }],
selectedAgentId: "a1",
modelPickerKind: "claude",
showModels: true,
})}
/>,
);
const trigger = screen.getByTestId("agent-picker-trigger");
expect(trigger).toHaveTextContent("Opus");
expect(trigger).toHaveTextContent("High");
// The harness identity ("Claude") is NOT in the trigger anymore — it
// moved to the status tray below.
expect(trigger).not.toHaveTextContent("Claude");
// Model black, effort grey.
expect(within(trigger).getByText("Opus")).toHaveClass("text-foreground");
expect(within(trigger).getByText("High")).toHaveClass("text-muted-foreground");
});
it("still renders an enabled trigger when the model/effort label is unresolved", () => {
// Regression guard: a claude-native session before the snapshot fills
// llmModel/selectedEffort has no model label, no effort label, and no
// agent switcher — but CLAUDE_NATIVE_MODELS still gives the dropdown
// model rows to switch (hasPickerActions). The trigger must NOT vanish
// (which would also take the model dropdown and the bare-`/model` path
// with it); it falls back to a stable identity label and stays enabled.
useChatStore.setState({ selectedModel: null, selectedEffort: null, llmModel: null });
renderWithTooltips(
<Composer
{...composerProps({
agents: [{ id: "a1", name: "claude" }],
selectedAgentId: "a1",
modelPickerKind: "claude",
showModels: true,
showEffort: false,
})}
/>,
);
const trigger = screen.getByTestId("agent-picker-trigger");
expect(trigger).toBeVisible();
// Enabled because there are still model rows to switch — so the dropdown
// (and the bare-`/model` open path) stays reachable.
expect(trigger).toBeEnabled();
// Falls back to the bound agent's display label rather than rendering empty.
expect(trigger).toHaveTextContent("Claude");
});
});
describe("Composer effort slash-command visibility", () => {
beforeEach(() => {
useChatStore.setState({ conversationId: "conv_test", skills: [] });
@@ -428,13 +501,15 @@ describe("Composer effort slash-command visibility", () => {
});
it("omits /effort from suggestions when effort controls are hidden", () => {
render(<Composer {...composerProps({ showEffort: false })} />);
// /compact is native-wrapper-only (#1139); render a native session so it
// stays present as the control row used to anchor this assertion.
render(<Composer {...composerProps({ showEffort: false, isNativeWrapper: true })} />);
fireEvent.change(textarea(), { target: { value: "/" } });
// Row testids — the active entry ("/compact") also renders in the
// detail card, so a text query would double-match.
// Row testids — /compact is hidden for non-native-wrapper sessions,
// so verify /context is present instead.
expect(screen.queryByTestId("slash-menu-item-effort")).toBeNull();
expect(screen.getByTestId("slash-menu-item-compact")).toBeInTheDocument();
expect(screen.getByTestId("slash-menu-item-context")).toBeInTheDocument();
});
it("shows /model in suggestions for in-process and picker-backed native sessions", () => {

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