Compare commits

...

14 Commits

Author SHA1 Message Date
Tomu Hirata d70a901b3b fix(cli): pass auth headers to session export HTTP client 2026-07-07 13:47:01 +09:00
Tomu Hirata 24f4ccc1be feat(cli): switch session export to use server API via --server 2026-07-06 19:43:51 +09:00
Tomu Hirata 18e8e7a66c fix(test): rename l -> line to fix E741 ambiguous variable name 2026-07-06 19:39:43 +09:00
Tomu Hirata ed7ea61b44 test(cli): add unit tests for omni session export 2026-07-06 19:36:51 +09:00
Tomu Hirata c3f46eaef7 feat(cli): add omni session export --id <session_id> command
Closes #1623
2026-07-06 18:17:55 +09:00
Tomu Hirata 5315349c83 fix(members): show friendly message in single-user/header mode (#2013)
* fix(members): show friendly message in single-user/header mode instead of auth error

In plain header mode (no accounts, no OIDC), the /auth/users endpoint
does not exist, causing the Members page to show a misleading error.
Add an early return after all hooks when accounts_enabled is false and
login_url is null, rendering a "not available in single-user mode" message.

* fix(members): skip fetch and show not-available message in single-user mode

- Derive isSingleUser from server_version (non-null on a live server,
  null on the _OFF probe-failure sentinel) to distinguish real
  single-user header mode from a transient /v1/info failure.
- Gate the useEffect on isSingleUser so the identity probe and
  /auth/users fetch are skipped entirely in that mode.
- Add a test case asserting the message renders and listUsers is
  never called; update mock to expose login_url + server_version
  so OIDC and single-user cases are distinguishable.
2026-07-06 07:38:14 +00:00
Pat Sukprasert b3e220ba97 fix(harness-bench): classify full-server token-provisioning failures + document transport coverage (#1994)
* fix(harness-bench): classify token-provisioning failures as infra skips

A full-server run over the SDK harnesses exposed a false-drift: codex and pi
fail basic_turn on that transport with a provider/gateway token-provisioning
error ("provider auth command `sh` produced an empty token"; "could not fetch
a gateway token"), which infra_failure_reason did not recognize — so the turn
read as UNSUPPORTED and drifted (!!✓>✗) against the SUPPORTED declaration.

That is an environment/auth gap in the full-server driver's spawn path, not a
capability the harness lacks. Add the token-provisioning phrasings to the infra
markers (with a dedicated skip reason), so such a failure is reported SKIPPED —
matching how a 403 / connectivity error is already handled — instead of a false
capability drift. claude-sdk on full-server is unaffected: it completes the
full matrix (Tool calling + Policy DENY both SUPPORTED and enforced).

Extends the infra-classification test with the codex/pi token-provisioning
messages. Offline 50 passed / 14 skipped, ruff clean.

* docs(harness-bench): document which transport exercises Tool calling / Policy DENY

A default `--profile oss` run shows `·` for Tool calling and Policy DENY, which
reads as "untested" but is really a transport limitation: those two dimensions
only get a real verdict on `full-server` (sdk-inproc harnesses dispatch tools
internally; native-tui isn't wired for them yet). Add a transport-vs-dimension
coverage table, the `--transport full-server` recipe, and the live-verified
result (claude-sdk: Tool calling ✓, Policy DENY ✓ enforced). Record the codex/pi
full-server gateway-auth gap and the native-tui tool/policy gap as open items.

* fix(harness-bench): accurate skip message for a native harness on full-server

Under --transport full-server, a native profile was rejected with "transport
'native-tui' not supported by the 'sdk-inproc' driver" — misleading, since it
is the full-server driver rejecting it and the fix is to use native-tui.
FullServerDriver.unavailable now rejects native profiles itself with an
accurate message ("... is a native-tui harness; ... use --transport
native-tui") and only borrows the SDK driver's CLI gate, not its
sdk-inproc-specific transport check.

Add a test asserting the message names native-tui and never sdk-inproc.

Context: verified on the oss profile that all four SDK harnesses (claude-sdk,
codex, pi, openai-agents) complete the full matrix on full-server with Tool
calling and Policy DENY both SUPPORTED and enforced. The codex "timeout" seen
earlier was a transient cold-start flake under sequential load (codex completes
a basic turn in ~15s solo), not a hang and not an auth failure once the local
Databricks profile was re-authed — no code change needed for it.

Offline 52 passed / 14 skipped, ruff clean.
2026-07-06 15:36:31 +08:00
Tomu Hirata e8313ac5d0 fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout (#1998)
* fix(server): signal SSE streams to exit on shutdown, reduce graceful timeout

Ctrl-C would hang for up to 30 s because open SSE session streams waited
for their next heartbeat (15 s cadence) before discovering the server was
going away.  After the timeout, uvicorn force-cancelled them, producing
spurious "Exception in ASGI application / CancelledError: timeout graceful
shutdown exceeded" tracebacks.

Fix by broadcasting the end-of-stream sentinel to every subscriber queue
in the lifespan shutdown handler (session_stream.shutdown_all()), so SSE
generators return cleanly without waiting for a heartbeat tick.  The
graceful-shutdown window is also reduced from 30 s to 5 s: SSE connections
now drain on their own; the remaining window is sized for WebSocket tunnel
teardown, which is fast.

* fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E

label events share the PR-number concurrency key, so applying automerge
mid-run triggered a new workflow run that immediately canceled the
in-progress suite (cancel-in-progress: true), leaving no E2E result.

e2e-ui.yml and integration.yml already removed these trigger types for the
same reason. Remove labeled/unlabeled from e2e.yml and drop the now-
unnecessary gate `if: github.event.label.name != 'automerge'` condition.

* Revert "fix(ci): drop labeled/unlabeled from e2e.yml to prevent automerge label from canceling running E2E"

This reverts commit f198528373.

* fix(server): move shutdown_all() into Server.shutdown override before graceful wait

The lifespan finally block runs AFTER uvicorn's graceful-shutdown timer
has already expired and force-cancelled in-flight tasks, so calling
shutdown_all() there was a no-op.

Move the call into a uvicorn.Server subclass (_ShutdownSignalingServer)
that overrides shutdown(): the sentinel is broadcast to all SSE subscriber
queues before asyncio.wait_for(_wait_tasks_to_complete(), ...) starts, so
generators exit cleanly within the graceful window instead of being
force-cancelled.

Also clean up session_stream.shutdown_all(): remove the contextlib.suppress
guard (queues are unbounded asyncio.Queue(), so QueueFull is unreachable).

* fix(ci): drop labeled/unlabeled from e2e.yml to stop automerge label canceling running E2E

Applying the automerge label mid-run triggered a new workflow run sharing
the same PR-number concurrency key. With cancel-in-progress: true, that
killed the running suite, leaving no E2E result on the PR.

e2e-ui.yml and integration.yml already removed labeled/unlabeled for the
same reason. Remove them from e2e.yml and drop the now-dead gate condition
`if: github.event.label.name != 'automerge'`.

* fix(server): yield event-loop turn after shutdown_all() before closing transports

Without this pause, generators receive _DONE but cannot run until
super().shutdown() calls connection.shutdown()/transport.close() — at
which point they try to flush "data: [DONE]\n\n" to an already-closing
transport.  Writing to a closing transport leaves connections open past
the graceful window, which prevents clear_local_server_record() from
running and leaves the port bound.

One asyncio.sleep(0) turn lets generators consume _DONE, flush their
final chunk, and exit before the transports are torn down.

* fix(server): catch KeyboardInterrupt, use SO_REUSEADDR in port probe

Two issues introduced by the faster shutdown:

1. KeyboardInterrupt now propagates from Server.run() to Click (since we
   dropped the uvicorn.run() wrapper that swallowed it), printing
   "Aborted!" and exiting non-zero.  Add except KeyboardInterrupt: pass
   to match uvicorn.run()'s original behaviour.

2. pick_local_port() probed with a plain socket (no SO_REUSEADDR), which
   fails on macOS/BSD when recently closed connections are still in
   TIME_WAIT with local address 127.0.0.1:6767.  The server's listening
   socket is already gone, and uvicorn would bind fine (it uses
   SO_REUSEADDR), so the probe socket must match.

* revert unrelated e2e.yml change from branch history

* test(cli): update server tests to mock uvicorn.server.Server.run instead of uvicorn.run

The server command now uses uvicorn.Config + _ShutdownSignalingServer(config).run()
rather than uvicorn.run(), so the four tests that monkeypatched uvicorn.run to skip
the blocking server loop were no longer intercepting anything — the real Server.run()
was called, binding to the test port and hanging.

Switch to patching uvicorn.server.Server.run (which _ShutdownSignalingServer inherits)
and capture the same kwarg fields via self.config attributes.
2026-07-06 07:28:28 +00:00
Daniel Lok 5508060e99 feat(doc-sync): label site PRs with release version and assign reviewer (#2002)
Staged omnigent-site doc PRs all target the per-minor X.Y-docs branch and
carried only the automated-docs label, so maintainers couldn't filter them
by the release they'll ship in. Derive vX.Y.Z from omnigent/version.py in
the existing "Resolve docs branch" step and apply it as a label on both the
create and update paths (backfilling PRs opened before the label existed).

Also add the resolved reviewer as an assignee alongside the review request,
so the PR is filterable by assignee from the site's PR list. The two calls
are independent and best-effort — GitHub rejects non-collaborators with 422,
which stays tolerated as before.

Co-authored-by: Isaac
2026-07-06 14:56:15 +08:00
Tomu Hirata 2a1d793815 fix(ci): isolate label-event concurrency in e2e.yml to prevent automerge canceling running suite (#2011)
Label events share the same PR-number concurrency key as code-push events.
With cancel-in-progress: true, applying automerge mid-run fired a new
workflow run that immediately killed the in-progress E2E suite.

Two-part fix:
- Append the label name to the concurrency key for label events (other
  events get the suffix '-run'), so each label gets its own isolated slot
  and can never preempt a synchronize/push run.
- Add an if: on the gate job to short-circuit for label events that are not
  skip-security-scan (e.g. automerge): those runs exit immediately in their
  isolated slot rather than spinning up the full suite.

labeled/unlabeled stay in the trigger: they are the fallback recovery path
for skip-security-scan (rerun-security-gate-run.yml calls this out on line 105).
2026-07-06 06:54:19 +00:00
Tomu Hirata e5bd7cc0f3 fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers (#1996)
* fix(triage): prioritise load over LLM rank when assigning issues and PR reviewers

LLM rank was the primary sort key, so the first owner listed in areas.json
always won even when their open-issue/review load was far higher than other
eligible owners. Swap to (load, rank, login) so load is the primary signal
and LLM rank only breaks ties within the same load bucket.

* test(triage): update cases 17-19 and stale comment for load-primary sort order

Cases 17-19 previously asserted rank-primary / load-secondary behaviour.
Update them (and their descriptions) to reflect the new load-primary ordering.
Also fix a stale block comment in issue-triage.yml that still said
"rank primary, load secondary".

* ci: re-trigger E2E (previous run canceled by automerge label event)
2026-07-06 14:49:50 +09:00
Serena Ruan 427c3b4441 fix(claude-native): stop false "terminal not ready" on mid-turn inject (#2001)
Injecting a web-UI message while Claude Code is mid-turn grows the footer
with running-state rows (a ○ Explore subagent line, extra spinners) that
push the ❯ input glyph to the 6th non-empty line from the bottom — one
past the readiness gate's 5-line scan window. The gate then times out and
the web UI renders a spurious "did not become ready" runtime-error card,
even though the terminal is healthy and the prompt is on screen.

Widening the window alone would resurrect the scrollback false positive
(an echoed ❯ sits at the same depth). Distinguish them structurally: the
live input box always renders a ──── box rule directly below ❯, which a
scrollback echo never has. Keep the 5-line fast path, and additionally
trust a glyph in a wider 8-line window only when a box rule sits below it.

Co-authored-by: Isaac
2026-07-06 13:46:34 +08:00
Daniel Lok 6e8fc19663 Update CHANGELOG for version 0.4.0 release (#2000)
Added release notes for version 0.4.0.
2026-07-06 13:33:11 +08:00
Serena Ruan 9125532066 docs: add client-side queue + steer design (#1999)
Design for a client-side message queue (edit / delete / steer / reorder)
before POST, with auto-flush-on-idle and per-harness steer semantics for
both SDK and native harnesses.

Co-authored-by: Isaac
2026-07-06 13:17:45 +08:00
20 changed files with 952 additions and 124 deletions
+6 -7
View File
@@ -210,16 +210,15 @@ module.exports = async ({ github, context, core }) => {
}
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
// Helper: take the N most-preferred from a list. Sort key is (rank, load,
// random): LLM area-fit rank first (lower = better; Infinity for unranked, so
// an all-unranked list -- no rank file -- sorts purely by load, i.e. today's
// behavior), then fewest open review requests, then a pre-rolled random value
// to break any remaining same-rank-same-load tie. The `!==` guards avoid
// subtracting two Infinities (which would be NaN).
// Helper: take the N most-preferred from a list. Sort key is (load, rank,
// random): fewest open review requests first so workload stays balanced;
// LLM area-fit rank breaks ties within the same load bucket; a pre-rolled
// random value breaks any remaining tie. The `!==` guards avoid subtracting
// two Infinities (which would be NaN).
const takeLowest = (list, n) => {
const keyed = list.map((u) => ({ u, r: rankOf(u), l: loadOf(u), j: Math.random() }));
keyed.sort((a, b) =>
a.r !== b.r ? a.r - b.r : a.l !== b.l ? a.l - b.l : a.j - b.j
a.l !== b.l ? a.l - b.l : a.r !== b.r ? a.r - b.r : a.j - b.j
);
return keyed.slice(0, n).map((x) => x.u);
};
+12 -14
View File
@@ -285,41 +285,39 @@ function assert(name, cond, detail) {
assert("capped overflow is warned",
r.warnings.some((w) => /capping push-down/.test(w)), JSON.stringify(r.warnings));
// 17. LLM ranking overrides load within the candidate pool: dhruv0811 has the
// lowest load (would win on load alone), but the rank prefers dbczumar, an
// inner owner -- so dbczumar is chosen.
// 17. Load beats LLM rank: dhruv0811 has the lowest load (0) and wins even
// though the rank prefers dbczumar (rank 0 but load 1).
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
rank: ["dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
});
assert("LLM rank beats load within the area pool",
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r));
assert("load beats LLM rank within the area pool",
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
// 18. Allowlist enforcement: a rank naming someone who does NOT own the touched
// area (PattaraS is a maintainer + pool member, but not an inner owner) is
// ignored for that entry; the ranking only reorders actual candidates, so
// the next ranked inner owner (dbczumar) wins -- never PattaraS.
// ignored; the ranking only reorders actual candidates. Load is primary, so
// dhruv0811 (load 0) wins over dbczumar (load 1) -- never PattaraS.
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1, PattaraS: 0 },
rank: ["PattaraS", "dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
});
assert("LLM rank cannot route outside the area owners",
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]) && !r.added.includes("PattaraS"),
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]) && !r.added.includes("PattaraS"),
JSON.stringify(r));
// 19. Unranked candidates (rank omits them) sort after ranked ones but still by
// load: rank lists only SabhyaC26 (highest load); the rest are unranked, so
// SabhyaC26 -- despite load 5 -- is preferred because a finite rank beats
// Infinity. Confirms the rank-primary / load-secondary ordering.
// 19. Load is primary even when only one candidate is ranked: rank lists only
// SabhyaC26 (load 5); dhruv0811 is unranked but has load 0, so dhruv0811
// wins. Confirms the load-primary / rank-secondary ordering.
r = await run({
files: ["omnigent/inner/foo.py"],
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
rank: ["SabhyaC26"],
});
assert("a ranked high-load owner beats unranked low-load owners",
JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r));
assert("unranked low-load owner beats ranked high-load owner",
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
// 20. Adoption still overrides the LLM rank: a linked-issue maintainer assignee
// (TomeHirata) is adopted as reviewer even when the rank prefers someone
+39 -19
View File
@@ -201,25 +201,30 @@ jobs:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# Derive the per-minor docs staging branch from the runtime version. main
# carries X.Y.Z.dev0, so 0.5.0.dev0 → "0.5-docs". All docs for the 0.5 line
# (incl. patches) stage on this one branch until release publishes it.
# Derive the per-minor docs staging branch and the release version from the
# runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs"
# and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the
# one branch until release publishes it; the vX.Y.Z label lets maintainers
# filter the staged PRs by the release they'll ship in.
- name: Resolve docs branch
id: docsbranch
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
run: |
set -euo pipefail
minor="$(python3 - <<'PYEOF'
import pathlib, re
python3 - <<'PYEOF'
import os, pathlib, re
text = pathlib.Path("omnigent/version.py").read_text()
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)', text)
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text)
if not m:
raise SystemExit("could not parse X.Y from omnigent/version.py")
print(f"{m.group(1)}.{m.group(2)}")
raise SystemExit("could not parse X.Y.Z from omnigent/version.py")
major, minor, patch = m.groups()
branch = f"{major}.{minor}-docs"
version = f"v{major}.{minor}.{patch}"
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
fh.write(f"branch={branch}\n")
fh.write(f"version={version}\n")
print(f"::notice::Docs stage on branch {branch} (release {version})")
PYEOF
)"
echo "branch=${minor}-docs" >> "$GITHUB_OUTPUT"
echo "::notice::Docs stage on branch ${minor}-docs"
- name: Set up Python
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
@@ -635,6 +640,7 @@ jobs:
PR_NUMBER: ${{ steps.plan.outputs.pr }}
REVIEWER: ${{ steps.sitepr.outputs.reviewer }}
DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }}
VERSION_LABEL: ${{ steps.docsbranch.outputs.version }}
run: |
set -euo pipefail
BRANCH="auto/docs/pr-${PR_NUMBER}"
@@ -685,17 +691,27 @@ jobs:
# bot commits.
git push --force "$PUSH_URL" "$BRANCH"
# The vX.Y.Z label marks which release the staged docs will ship in, so
# maintainers can filter the site PRs by release. Ensure it exists (with
# automated-docs) before applying it below.
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
--description "Automated documentation update" 2>/dev/null || true
gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \
--description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --body-file /tmp/site_pr_body.md || true
# --add-label backfills PRs opened before the label existed; it's a no-op
# when already present.
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \
--add-label "automated-docs" --add-label "$VERSION_LABEL" \
--body-file /tmp/site_pr_body.md || true
echo "Updated site PR #$EXISTING."
else
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
--description "Automated documentation update" 2>/dev/null || true
if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \
--title "docs: document ${CODE_REPO}#${PR_NUMBER}" \
--label automated-docs --body-file /tmp/site_pr_body.md; then
--label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
echo "Opened site PR for $BRANCH."
@@ -704,13 +720,17 @@ jobs:
fi
fi
# Always attempt the review request, decoupled from PR creation so a
# non-addable reviewer can't fail the open. GitHub returns 422 for users it
# can't add (non-collaborators / concealed org members); tolerate it — the
# reviewer is also @-mentioned in the body as a durable fallback ping.
# Always attempt the review request + assignment, decoupled from PR creation
# so a non-addable reviewer can't fail the open. GitHub returns 422 for users
# it can't add (non-collaborators / concealed org members); tolerate it — the
# reviewer is also @-mentioned in the body as a durable fallback ping. The two
# calls are independent so one failing doesn't skip the other. Assigning makes
# the PR filterable by assignee from the site's PR list.
if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \
|| echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body."
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \
|| echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body."
fi
- name: Note draft skipped (no site token)
+13 -6
View File
@@ -19,6 +19,9 @@ on:
schedule:
- cron: "0 9 * * *"
pull_request:
# labeled/unlabeled: kept for the skip-security-scan recovery path
# (rerun-security-gate-run.yml falls back to this trigger). The concurrency
# group key isolates label events so they never cancel a code-push run.
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
paths-ignore: ['web/**', 'tests/e2e_ui/**']
workflow_dispatch:
@@ -34,8 +37,9 @@ on:
concurrency:
# 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 }}
# by SHA so each merge to `main` gets its own run. Label events append the
# label name so they get an isolated slot and never cancel a code-push run.
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name || 'run' }}
cancel-in-progress: true
permissions:
@@ -54,11 +58,14 @@ env:
jobs:
# Security gate: untrusted PRs wait on the deterministic scan
# (security-gate.yml); trusted authors and non-PR events pass instantly.
# Skip when the automerge label is applied/removed -- safe to short-circuit
# here because every non-gate job is transitively downstream of gate, so
# no skipped check-run can overwrite an existing result on this SHA.
# Short-circuit for label events that aren't skip-security-scan (e.g.
# automerge): those run in their own isolated concurrency slot (above) and
# don't need the full suite — just exit fast.
gate:
if: github.event.label.name != 'automerge'
if: >-
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'skip-security-scan'
uses: ./.github/workflows/security-gate.yml
# Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by
+8 -8
View File
@@ -503,10 +503,10 @@ jobs:
maintainer_assigned=true
fi
# Otherwise, assign an owner for P0/P1 issues: the LLM's top-ranked area
# owner, breaking ties by open-assigned-issue load (fairness). Symmetric
# with the PR reviewer path (rank primary, load secondary). Skipped if
# the maintainer-author was already assigned above.
# Otherwise, assign an owner for P0/P1 issues: the least-loaded area
# owner, with LLM rank as a tiebreaker (load primary, rank secondary).
# Symmetric with the PR reviewer path. Skipped if the maintainer-author
# was already assigned above.
priority=$(jq -r '.priority // empty' /tmp/triage_result.json)
if [ "$maintainer_assigned" = "false" ] && { [ "$priority" = "P0-critical" ] || [ "$priority" = "P1-high" ]; }; then
# Open-issue load per candidate (fewest assigned open issues wins ties).
@@ -533,12 +533,12 @@ jobs:
if a.get("login"):
load[a["login"]] += 1
# Sort by (rank, load, login): LLM rank first, then fewest open issues,
# then a stable alphabetical tie-break (deterministic, unlike a random
# one — matches the previous round-robin's determinism guarantee).
# Sort by (load, rank, login): fewest open assigned issues first so
# the workload stays balanced; LLM rank breaks ties within the same
# load bucket; alphabetical login is the final deterministic tiebreak.
candidates = sorted(
candidates,
key=lambda u: (rank_of.get(u, float("inf")), load[u], u),
key=lambda u: (load[u], rank_of.get(u, float("inf")), u),
)
assignee = candidates[0] if candidates else ""
if assignee:
+4
View File
@@ -5,6 +5,10 @@ generated at release time from each PR's `## Changelog` section, tagged by the
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
website under `/releases`.
## [v0.4.0] — 2026-07-03
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.4.0>
## [v0.3.0] — 2026-06-26
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.3.0>
+150
View File
@@ -0,0 +1,150 @@
# Queue + steer design
Client-side message queue with edit / delete / steer / reorder, for both SDK and
native harnesses.
## 1. Motivation
Today every message is **POSTed the moment the user hits send** — including
follow-ups typed while the agent is still working — and rendered immediately as an
optimistic bubble. The runner buffers a mid-turn message behind the active turn
and delivers it later, but the UI has already committed it. Problems:
- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the
user can't take back or fix a follow-up they queued in a hurry.
- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a
normal send — the user can't tell it's waiting behind the active turn, or when
it will be picked up.
- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up
while the agent is working" — behaves differently per harness (mid-turn steer
for live-queue SDKs, next-turn for everyone else) with no signal telling the
user which they'll get.
The redesign fixes all three by holding the message in a **client-side queue
before it is POSTed**: the user can edit / delete / reorder while it waits, sees
it explicitly as "queued", and controls when it's sent (auto-flush on idle, or
steer now).
## 2. Proposal
Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a
message is only sent to the server when it's flushed or steered.
```
type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble
(strip = "not yet sent, still editable"; bubble = "sent, in flight")
```
### Queue behavior
- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same
signal for SDK and native.
- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of
the queue as the next turn. Type-ahead "just works" without any click.
- Persist the queue in `localStorage` (keyed by session) so it survives a hard
refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.)
### Per-message actions
| Action | Behavior |
|--------|----------|
| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh |
| **Delete** | drop the message from the queue |
| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it |
| **Reorder** *(optional, follow-up)* | client-side drag to reorder the queue |
### Promote-to-bubble rule
Promote a message from the strip into a normal chat bubble **as soon as it is
POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent
there's no longer anything to edit / delete / steer / reorder, so the strip has
no reason to hold it.
The gap between (a) sent to server and (b) consumed by the agent becomes an
**implementation detail** the user need not see — because the strip no longer
represents server state, only the still-editable client buffer. This removes the
consume-timing dependency entirely.
### What "steer" means per harness
Steer always POSTs immediately; how it lands depends on the harness:
| Harness | Steer delivery | Mid-turn? |
|---------|----------------|-----------|
| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic |
| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn |
| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic |
| **claude-native** (and paste-based natives) | runner drains → `send-keys` into the **live pane**; the app treats the paste as a steer | ⚠️ best-effort (drain-vs-response race) |
> **TODO:** sanity-check the remaining harnesses (cursor-native, pi-native,
> qwen-native, opencode-native, goose-native, hermes-native, kimi-native,
> antigravity-native, kiro-native, …) — confirm whether each is deterministic
> (`turn/steer`-style RPC) or best-effort (paste into live pane) before relying on
> steer behavior.
**No runner change is required for native steer** — native `run_turn` clears the
turn right after the paste, so the drain fires the next message quickly and it
lands in the live pane, where the native app does its own steering. Frame the UX
honestly: *"send now; the agent folds it into current work if it can"* — which is
exactly how native type-ahead already feels. Do **not** promise deterministic
mid-turn for paste-based natives.
**Steer is not interrupt.** In every case above, steer *does not cancel* the
running turn — the message is folded in at the agent's next natural breakpoint
(after the current tool/step completes), the same feel as steering native Claude
by typing while it works. For SDK, `enqueue_session_message` adds the message to
the running session's queue; the SDK surfaces it at its next turn-boundary — no
teardown. This is distinct from the **Interrupt** button, which really does
cancel the turn (`turn.cancel()`).
### Edges to handle
| Edge | Rule |
|------|------|
| POST fails after promote | revert the bubble to the queue (or error-badge it) |
| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed |
| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render |
## 3. Appendix — lifecycle & topology
### Component topology
```
┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐
│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │
│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │
└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │
└──────────┘ │ native: →app ───┼─► tmux / RPC
└─────────────────┘
```
The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out**
of it to a real app (native). It does **not** live in the runner process.
### Busy/idle signal (drives the queue)
| Harness | "running" from | "idle" from |
|---------|----------------|-------------|
| SDK | `response.created``_live_response_id` set | `response.completed` / stream-end |
| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) |
Both surface to the client as the same `sessionStatus` field, seeded from the
snapshot on bind (correct after refresh, across tabs).
### Live-injection gate (SDK steer)
```python
_can_forward = (
not _native # native uses paste / turn-steer, not this path
and not _awaiting_approval # don't steer a turn parked on a human gate
and conversation_id in _live_response_id # a response is actually streaming
)
```
### Native decoupling (why paste-steer works)
Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the
agent finishes). `_active_turns` clears immediately, so the buffer drains the
next message quickly and it pastes into the still-live pane — the native app then
decides to steer it. `_native_pane_status` is the reliable liveness signal for a
long autonomous native turn (since `_active_turns` clears early).
+42
View File
@@ -311,6 +311,39 @@ deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives
message rather than streaming deltas: they declare `streaming=False`
`UNSUPPORTED`, matching what the probe observes.
## Which transport exercises which dimension
Not every dimension is observable on every transport, so a `·` (SKIPPED) in a
default run often means "this transport can't exercise it here," not "the
harness lacks it." Two dimensions in particular only get a real verdict on the
`full-server` transport:
| Dimension | sdk-inproc | full-server | native-tui |
|---|---|---|---|
| Basic turn, Streaming, Model override, Interrupt | ✓ | ✓ | ✓ |
| **Tool calling** | · (harness dispatches tools internally) | ✓ (server-dispatched builtin) | · (not yet wired) |
| **Policy DENY** | · (wrap-direct: no tool-call policy hook) | ✓ (spec-baked deny, enforced) | · (not yet wired) |
So to see Tool calling and Policy DENY actually proven, run the SDK harnesses
over `full-server`:
```
python -m tests.harness_bench --harness claude-sdk --profile oss --transport full-server
```
Live-verified: `claude-sdk` completes the full matrix on `full-server`
Tool calling `✓` and Policy DENY `✓` (the deny is delivered and the blocked
call does not stall the turn). The default `--profile oss` run shows `·` for
those two columns only because it uses `sdk-inproc` (for SDK harnesses) and
`native-tui` (for natives), neither of which routes a tool call through a
server policy evaluation.
`full-server` covers **SDK harnesses only** — it registers the harness via an
agent bundle, which is the SDK-wrap path; native harnesses need the host-daemon
provisioning the `native-tui` driver owns. So Tool calling / Policy DENY for
native harnesses remain genuinely unwired (a follow-up), distinct from the
sdk-inproc `·` which is a transport limitation with `full-server` as the answer.
## Open items
- Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps
@@ -319,3 +352,12 @@ message rather than streaming deltas: they declare `streaming=False` →
an exported CSV so the sheet stays canonical during transition.
- Native transport drivers are the larger half of the work; sequence them by
which harnesses matter most for the matrix.
- `full-server` cannot yet provision codex / pi gateway auth (their basic turn
fails with an empty/absent gateway token), so Tool calling / Policy DENY are
only live-proven on `claude-sdk` today; wiring codex/pi full-server auth would
extend that coverage. (The token-provisioning failure is classified as a SKIP,
not a false capability drift.)
- Tool calling / Policy DENY on the `native-tui` transport are unwired — native
tool calls are the vendor's own and a native deny is a vendor permission
decision, not a server-dispatched `function_call_output`; observing them needs
new driver work.
+43 -1
View File
@@ -124,6 +124,10 @@ _TMUX_SEND_TIMEOUT_S = 5.0
# The glyph persists while Claude is busy responding, so its presence
# means "input box mounted" (not "idle"), which is what injection needs.
_CLAUDE_PROMPT_GLYPH = ""
# Box-drawing glyphs Claude Code's input-box frame is made of. A line of
# these below ```` marks the live input box (see ``_is_box_rule``),
# distinguishing it from a bare prompt echoed into scrollback.
_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍")
# How many trailing non-empty lines to scan for the prompt glyph. The
# input box sits near the bottom of the pane; scanning only the tail
# avoids false positives from the glyph appearing in scrollback output.
@@ -131,6 +135,12 @@ _CLAUDE_PROMPT_GLYPH = ""
# people's statuslines run ~3 lines — so the ```` row isn't the last
# non-empty line.
_PROMPT_SCAN_TAIL_LINES = 5
# Injecting a message mid-turn grows the footer with running-state rows
# (a ``○ Explore …`` subagent line, extra spinners) that push ```` above
# the window above. We trust a glyph this deep only when it's framed by a
# box rule (the live input box), so this wider window can't false-match a
# bare ```` echoed into scrollback output.
_PROMPT_SCAN_TAIL_LINES_FRAMED = 8
_CLAUDE_READY_POLL_INTERVAL_S = 0.15
_PASTE_SETTLE_S = 0.1 # let the TUI commit a paste before the separate submit Enter
# How long to wait for the pasted draft to visibly land in Claude's
@@ -2831,11 +2841,43 @@ def _claude_prompt_rendered(pane: str) -> bool:
positives from the glyph appearing in scrollback (e.g. echoed in a
prior response), since the live input box always sits at the bottom.
A mid-turn injection grows the footer with running-state rows (a
``○ Explore …`` subagent line, extra spinners) that can push ````
past that window. To reach it without also matching a scrollback
echo, a glyph in the wider :data:`_PROMPT_SCAN_TAIL_LINES_FRAMED`
window counts only when it's framed by a box rule — the ``────``
closing line the live input box always renders below ```` but a
bare echoed prompt never has.
:param pane: Captured pane text from :func:`_capture_pane`.
:returns: ``True`` when the input box appears mounted.
"""
non_empty = [line for line in pane.splitlines() if line.strip()]
return any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:])
if any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:]):
return True
# Deeper in the tail, trust the glyph only when a box rule sits below
# it — the live input box's closing frame, absent from scrollback.
tail = non_empty[-_PROMPT_SCAN_TAIL_LINES_FRAMED:]
for idx, line in enumerate(tail):
if _CLAUDE_PROMPT_GLYPH in line and any(_is_box_rule(rule) for rule in tail[idx + 1 :]):
return True
return False
def _is_box_rule(line: str) -> bool:
"""
Return whether a line is a TUI box-drawing horizontal rule.
Claude Code frames its input box with rows of ``─`` (plus corner
glyphs). Such a rule below ```` marks the live input box, letting
the readiness scan reach a prompt buried under a tall running-turn
footer without matching a bare ```` echoed into scrollback.
:param line: A single pane line, e.g. ``"──────────"``.
:returns: ``True`` when the line is predominantly box-rule glyphs.
"""
stripped = line.strip()
return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped)
def _submit_needle(content: str) -> str:
+178 -30
View File
@@ -233,11 +233,14 @@ _DAEMON_RECONNECT_GRACE_S = 5.0
_DAEMON_REUSE_MIN_AGE_S = 6.0
# How long uvicorn waits for active connections (WebSocket, SSE) after
# SIGTERM before force-closing them. 30 s gives in-flight responses time
# to drain while still guaranteeing the port is released promptly.
# SIGTERM before force-closing them. SSE streams signal themselves via
# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(),
# so the main remaining consumers of this window are WebSocket tunnels
# that need a moment to drain. 5 s is enough for a clean tunnel teardown
# while keeping Ctrl-C feeling instant.
# Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that
# need a longer drain window (e.g. large file uploads).
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 30
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int(
os.environ.get(
"OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S",
@@ -1196,6 +1199,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
"qwen",
"resume",
"run",
"session",
"sandbox",
"server",
"setup",
@@ -2972,6 +2976,7 @@ def server(
port = _picked
import uvicorn
import uvicorn.server
from omnigent.runner.transports.ws_tunnel.limits import (
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
@@ -3220,34 +3225,71 @@ def server(
# this foreground server instead of tearing it down on a spurious
# sig mismatch.
register_local_server(port)
class _ShutdownSignalingServer(uvicorn.server.Server):
"""uvicorn.Server that signals active SSE subscribers before the
graceful-shutdown wait starts.
uvicorn calls ``Server.shutdown()`` in this order:
1. close listening sockets / call connection.shutdown()
2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=)``
3. force-cancel remaining tasks on timeout
4. run the ASGI lifespan shutdown handler
The ASGI lifespan ``finally`` block runs at step 4 too late. SSE
generators waiting on a heartbeat tick are already force-cancelled by
step 3, which produces spurious ``CancelledError`` tracebacks.
Overriding here lets us drain SSE streams before step 2 so they exit
cleanly within the graceful window.
"""
async def shutdown(self, sockets=None) -> None: # type: ignore[override]
import asyncio as _asyncio
from omnigent.runtime import session_stream as _session_stream
_session_stream.shutdown_all()
# Yield to the event loop so generators can consume _DONE,
# flush their final "data: [DONE]\n\n" chunk, and exit before
# super().shutdown() calls connection.shutdown() / transport.close().
# Without this pause the generators write to an already-closing
# transport, leaving connections open past the graceful window.
await _asyncio.sleep(0)
await super().shutdown(sockets)
_config = uvicorn.Config(
app,
host=host,
port=port,
log_config=_server_uvicorn_log_config(),
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
# Server side of the runner/host tunnels' protocol keepalive, aligned
# to the 90 s app-level budget instead of uvicorn's 20 s default that
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
#
# uvicorn's ws_ping_* is server-global (no per-route override), so this
# 30 s/90 s budget also applies to the app's other WebSocket routes —
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
# Deliberate and acceptable: for an IDLE such socket the protocol
# PING/PONG is the only half-open detector (the sessions-updates
# heartbeat is a server->client send, and an idle terminal has no
# traffic), so widening it means a dead idle browser/terminal socket is
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
# terminal-attach proxy holds its runner socket + tmux child ~80 s
# longer), bounded and eventually reaped, not a leak or correctness
# change. The tunnels are the sockets that actually need the looser
# budget (issue #1116).
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
)
try:
uvicorn.run(
app,
host=host,
port=port,
log_config=_server_uvicorn_log_config(),
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
# Server side of the runner/host tunnels' protocol keepalive, aligned
# to the 90 s app-level budget instead of uvicorn's 20 s default that
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
#
# uvicorn's ws_ping_* is server-global (no per-route override), so this
# 30 s/90 s budget also applies to the app's other WebSocket routes —
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
# Deliberate and acceptable: for an IDLE such socket the protocol
# PING/PONG is the only half-open detector (the sessions-updates
# heartbeat is a server->client send, and an idle terminal has no
# traffic), so widening it means a dead idle browser/terminal socket is
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
# terminal-attach proxy holds its runner socket + tmux child ~80 s
# longer), bounded and eventually reaped, not a leak or correctness
# change. The tunnels are the sockets that actually need the looser
# budget (issue #1116).
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
)
_ShutdownSignalingServer(_config).run()
except KeyboardInterrupt:
# uvicorn.run() swallows KeyboardInterrupt; match that behaviour so
# a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero.
pass
finally:
if _is_canonical_local_server:
clear_local_server_record()
@@ -5501,6 +5543,112 @@ def resume(
)
@cli.group("session", invoke_without_command=True)
@click.pass_context
def session(ctx: click.Context) -> None:
"""Manage Omnigent sessions.
\b
Examples:
omnigent session export --id conv_abc123
omnigent session export --id conv_abc123 --output transcript.jsonl
omnigent session export --id conv_abc123 --server https://myserver.com
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@session.command("export")
@click.option(
"--id",
"session_id",
required=True,
metavar="SESSION_ID",
help="Session ID to export, e.g. conv_abc123.",
)
@click.option(
"--output",
"-o",
"output",
default=None,
metavar="FILE",
help="Output file path. Defaults to <SESSION_ID>.jsonl in the current directory.",
)
@click.option(
"--server",
default=None,
help=(
"Omnigent server URL. "
"Defaults to the configured server, or a local server already running."
),
)
def session_export(session_id: str, output: str | None, server: str | None) -> None:
"""Export a session transcript to a portable JSONL file.
Each line of the output is a JSON object. The first line carries
the session metadata (``"record_type": "session_meta"``); every
subsequent line is one conversation item
(``"record_type": "item"``). The file preserves full turn order
and can be re-imported with a future ``omnigent session import``.
\b
Examples:
omnigent session export --id conv_abc123
omnigent session export --id conv_abc123 --output my_session.jsonl
omnigent session export --id conv_abc123 --server https://myserver.com
"""
import httpx
from omnigent.chat import _remote_headers
cfg = _load_effective_config()
base_url = _resolve_attach_server(server, cfg.get("server"))
if base_url is None:
startup = ensure_local_omnigent_server()
base_url = startup.url
base_url = base_url.rstrip("/")
out_path = Path(output) if output else Path(f"{session_id}.jsonl")
with httpx.Client(
base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0
) as client:
# Fetch session metadata (items fetched separately via pagination).
resp = client.get(
f"/v1/sessions/{session_id}",
params={"include_items": "false", "include_liveness": "false"},
)
if resp.status_code == 404:
raise click.ClickException(f"Session {session_id!r} not found.")
resp.raise_for_status()
session_data = resp.json()
n_items = 0
with out_path.open("w", encoding="utf-8") as fh:
# First line: session metadata.
meta_record = {"record_type": "session_meta", **session_data}
fh.write(json.dumps(meta_record) + "\n")
# Remaining lines: items in ascending order, paginated.
after: str | None = None
while True:
params: dict[str, str | int] = {"limit": 500, "order": "asc"}
if after:
params["after"] = after
items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params)
items_resp.raise_for_status()
page = items_resp.json()
for item in page["data"]:
item_record = {"record_type": "item", **item}
fh.write(json.dumps(item_record) + "\n")
n_items += 1
if not page.get("has_more"):
break
after = page.get("last_id")
click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}")
# Shared option help for ``run`` and the harness commands. These are the same
# flags the legacy argparse CLI exposed — keeping them on the unified
# click CLI so users don't regress when a YAML declares no executor
+5
View File
@@ -708,6 +708,11 @@ def pick_local_port(preferred: int = _DEFAULT_LOCAL_PORT) -> int:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# SO_REUSEADDR mirrors what uvicorn sets when it binds. Without
# it, a fast server restart sees EADDRINUSE on macOS/BSD because
# recently closed connections are still in TIME_WAIT even though
# the listening socket is gone and uvicorn could successfully bind.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", preferred))
except OSError:
+17
View File
@@ -125,6 +125,23 @@ def close(conversation_id: str) -> None:
loop.call_soon_threadsafe(queue.put_nowait, _DONE)
def shutdown_all() -> None:
"""Signal all active subscribers across every conversation to exit.
Broadcasts the end-of-stream sentinel to every queued subscriber so
SSE generators return at their next iteration without waiting for a
heartbeat timeout or forced task cancellation. Called from the asyncio
event loop (``_ShutdownSignalingServer.shutdown`` in ``cli.py``) before
uvicorn's graceful-shutdown wait starts, so streams drain within the
window rather than being force-cancelled. Sync callers should use
:func:`close` per-conversation instead.
"""
with _lock:
all_subs = [entry for subs in _subscribers.values() for entry in subs]
for queue, _ in all_subs:
queue.put_nowait(_DONE)
async def subscribe(
conversation_id: str,
*,
+28 -25
View File
@@ -1151,6 +1151,7 @@ def test_server_command_reads_tunnel_token_and_does_not_spawn_runner(
:returns: None.
"""
import uvicorn
import uvicorn.server
captured: dict[str, Any] = {}
@@ -1165,22 +1166,27 @@ def test_server_command_reads_tunnel_token_and_does_not_spawn_runner(
captured["create_app_kwargs"] = kwargs
return _original_create_app(**kwargs)
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
"""Skip the blocking server loop.
def _fake_server_run(self: Any) -> None:
"""Skip the blocking server loop; capture config as flat kwargs dict.
:param app: FastAPI app instance built by ``create_app``.
:param kwargs: Uvicorn options (host, port).
:param self: The uvicorn Server instance whose config holds all options.
:returns: None.
"""
del app
captured["uvicorn_kwargs"] = kwargs
captured["uvicorn_kwargs"] = {
"ws_max_size": self.config.ws_max_size,
"ws_ping_interval": self.config.ws_ping_interval,
"ws_ping_timeout": self.config.ws_ping_timeout,
"log_config": self.config.log_config,
"port": self.config.port,
"host": self.config.host,
}
captured["uvicorn_called"] = True
from omnigent.server import app as app_module
_original_create_app = app_module.create_app
monkeypatch.setattr(app_module, "create_app", _spy_create_app)
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
monkeypatch.setenv("OMNIGENT_RUNNER_TUNNEL_TOKEN", "test-tunnel-token-abc")
# On a loopback bind the `server` command reuses an already-running
@@ -1248,6 +1254,7 @@ def test_server_with_explicit_db_does_not_reuse_canonical_server(
shared pidfile.
"""
import uvicorn
import uvicorn.server
captured: dict[str, Any] = {}
_original_create_app = None
@@ -1261,22 +1268,20 @@ def test_server_with_explicit_db_does_not_reuse_canonical_server(
captured["create_app_kwargs"] = kwargs
return _original_create_app(**kwargs)
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
def _fake_server_run(self: Any) -> None:
"""Skip the blocking server loop, record that it was called.
:param app: FastAPI app built by ``create_app``.
:param kwargs: Uvicorn options (host, port, ...).
:param self: The uvicorn Server instance.
:returns: None.
"""
del app
captured["uvicorn_kwargs"] = kwargs
captured["uvicorn_kwargs"] = {"port": self.config.port}
captured["uvicorn_called"] = True
from omnigent.server import app as app_module
_original_create_app = app_module.create_app
monkeypatch.setattr(app_module, "create_app", _spy_create_app)
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
# A healthy canonical server EXISTS. A bare `omnigent server` would
# reuse it; an explicit-DB server must ignore it. register/clear must
@@ -1334,19 +1339,18 @@ def test_server_with_explicit_port_does_not_check_canonical_server(
:returns: None.
"""
import uvicorn
import uvicorn.server
captured: dict[str, Any] = {}
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
def _fake_server_run(self: Any) -> None:
"""
Skip the blocking server loop.
:param app: FastAPI app instance built by ``create_app``.
:param kwargs: Uvicorn options (host, port).
:param self: The uvicorn Server instance.
:returns: None.
"""
del app
captured["uvicorn_kwargs"] = kwargs
captured["uvicorn_kwargs"] = {"port": self.config.port}
def _must_not_check_existing() -> str | None:
"""
@@ -1367,7 +1371,7 @@ def test_server_with_explicit_port_does_not_check_canonical_server(
from omnigent.host import local_server as _local_server_mod
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
monkeypatch.setattr(_local_server_mod, "local_server_url_if_healthy", _must_not_check_existing)
monkeypatch.setattr(_local_server_mod, "register_local_server", _must_not_touch_pidfile)
monkeypatch.setattr(_local_server_mod, "clear_local_server_record", _must_not_touch_pidfile)
@@ -1447,19 +1451,18 @@ def test_server_command_explicit_port_uses_bind_probe_not_connect_probe(
import socket
import uvicorn
import uvicorn.server
captured: dict[str, Any] = {}
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
def _fake_server_run(self: Any) -> None:
"""
Skip the blocking server loop.
:param app: FastAPI app instance built by ``create_app``.
:param kwargs: Uvicorn options (host, port).
:param self: The uvicorn Server instance.
:returns: None.
"""
del app
captured["uvicorn_kwargs"] = kwargs
captured["uvicorn_kwargs"] = {"port": self.config.port}
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
@@ -1468,7 +1471,7 @@ def test_server_command_explicit_port_uses_bind_probe_not_connect_probe(
with pytest.raises(OSError):
socket.create_connection(("127.0.0.1", port), timeout=0.01)
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0")
db_path = tmp_path / "chat.db"
+22
View File
@@ -91,6 +91,15 @@ _INFRA_ERROR_MARKERS: tuple[str, ...] = (
# Sequencing, not capability: a prior turn on the shared session had not
# fully settled. Reported SKIPPED so it never reads as a capability gap.
"already processing",
# Token provisioning failed before the harness could reach the model — an
# environment/auth gap (a missing/empty gateway token, a provider auth
# command that produced nothing), not a capability the harness lacks.
# Seen on full-server for codex ("provider auth command ... empty token")
# and pi ("could not fetch a gateway token").
"could not fetch a gateway token",
"provider auth command",
"empty token",
"Failed to resolve external API key auth",
)
@@ -125,6 +134,19 @@ def infra_failure_reason(result: TurnResult) -> str | None:
)
if "already processing" in text:
return "session busy from a prior turn (sequencing, not a capability gap)"
if any(
marker in text
for marker in (
"could not fetch a gateway token",
"provider auth command",
"empty token",
"Failed to resolve external API key auth",
)
):
return (
"gateway/provider token could not be provisioned for this transport "
"(environment/auth gap, not a capability the harness lacks)"
)
if "unexpected status" in text:
return "gateway returned an unexpected status (environment/auth issue)"
return "environment/connectivity error reaching the gateway"
+16 -3
View File
@@ -173,16 +173,29 @@ class FullServerDriver:
@staticmethod
def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None:
"""Return a skip reason if this driver cannot run *profile*, else ``None``."""
# full-server registers the harness via an agent bundle (the SDK-wrap
# path); a native harness needs the host-daemon/tmux provisioning only
# the native-tui driver does, so it cannot run here even under an
# explicit --transport full-server override.
if profile.transport == "native-tui":
return (
f"{profile.harness!r} is a native-tui harness; the full-server transport "
"registers via an agent bundle and cannot drive it (use --transport native-tui)"
)
if not databricks_profile:
return "no --profile / databricks profile provided; full-server needs a gateway route"
if lookup_databricks_host(databricks_profile) is None:
return (
f"databricks profile {databricks_profile!r} missing/hostless in ~/.databrickscfg"
)
# Reuse the wrap driver's CLI gate (same binary requirement).
from tests.harness_bench.driver import SdkInprocDriver
# Same CLI gate as the wrap driver (same binary requirement), but skip
# its transport check — that is sdk-inproc-specific and would misreport
# the driver name; the native case is already handled above.
from tests.e2e._harness_probes import cli_unavailable_reason
return SdkInprocDriver.unavailable(profile, databricks_profile=databricks_profile)
if profile.cli_binary is not None:
return cli_unavailable_reason(profile.cli_binary)
return None
def __enter__(self) -> FullServerDriver:
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
+34
View File
@@ -125,6 +125,17 @@ def test_infra_failure_reason_classifies_auth_and_ignores_capability_gaps() -> N
# A successful turn is never an infra failure.
assert infra_failure_reason(TurnResult(completed=True, text="ok")) is None
# Token-provisioning failures on full-server (codex/pi) are env/auth gaps,
# not capability gaps -> must yield a skip reason, never a false UNSUPPORTED
# that drifts against a SUPPORTED declaration.
for msg in (
"inner executor error: provider auth command `sh` produced an empty token",
"PiExecutor(gateway=True) could not fetch a gateway token for the workspace host.",
"Failed to resolve external API key auth",
):
result = TurnResult(failed=True, error={"message": msg})
assert infra_failure_reason(result) is not None, msg
async def test_offline_render_produces_matrix() -> None:
matrix = await run_bench(_OFFICIAL, live=False)
@@ -298,3 +309,26 @@ def test_native_tui_registered_and_gates() -> None:
# No profile → the same capability-neutral skip contract as other drivers.
assert NativeTuiDriver.unavailable(claude_native, databricks_profile=None) is not None
def test_full_server_skips_native_with_accurate_message() -> None:
"""full-server rejects a native profile by naming the native transport.
A native harness forced onto full-server (via --transport) cannot run
there (bundle registration, not host-daemon provisioning). The skip must
name native-tui as the answer, not misreport the 'sdk-inproc' driver.
"""
from tests.harness_bench.full_server_driver import FullServerDriver
# Real native profiles carry transport="native-tui" (set in the manifest);
# that is what the full-server gate keys on.
claude_native = BenchProfile(
harness="claude-native",
model="m",
env_prefix="HARNESS_CLAUDE_NATIVE_",
marker="X",
transport="native-tui",
)
reason = FullServerDriver.unavailable(claude_native, databricks_profile="oss")
assert reason is not None
assert "native-tui" in reason and "sdk-inproc" not in reason
+225
View File
@@ -0,0 +1,225 @@
"""Unit tests for ``omnigent session export``."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import httpx
import respx
from click.testing import CliRunner
from omnigent.cli import cli
_BASE = "http://localhost:6767"
_SESSION_META = {
"id": "conv_abc123",
"title": "test session",
"status": "idle",
"created_at": 1700000000,
"updated_at": 1700000001,
"agent_id": None,
"agent_name": None,
"items": [],
}
_ITEMS_PAGE = {
"data": [
{
"id": "msg_1",
"type": "message",
"status": "completed",
"response_id": "resp_1",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
{
"id": "msg_2",
"type": "message",
"status": "completed",
"response_id": "resp_1",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi there"}],
"model": "my-agent",
},
],
"first_id": "msg_1",
"last_id": "msg_2",
"has_more": False,
}
def _patch_server(base_url: str = _BASE) -> Any:
"""Patch the CLI so it uses *base_url* without spawning a real server."""
return patch("omnigent.cli._resolve_attach_server", return_value=base_url)
@respx.mock
def test_session_export_writes_jsonl(tmp_path: Path) -> None:
"""Export writes one session_meta line then one item line per item."""
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
return_value=httpx.Response(200, json=_SESSION_META)
)
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
return_value=httpx.Response(200, json=_ITEMS_PAGE)
)
out_file = tmp_path / "out.jsonl"
runner = CliRunner()
with _patch_server():
result = runner.invoke(
cli,
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
)
assert result.exit_code == 0, result.output
assert out_file.exists()
lines = [json.loads(line) for line in out_file.read_text().splitlines() if line]
assert len(lines) == 3 # 1 meta + 2 items
meta = lines[0]
assert meta["record_type"] == "session_meta"
assert meta["id"] == "conv_abc123"
assert meta["title"] == "test session"
item_lines = lines[1:]
assert all(r["record_type"] == "item" for r in item_lines)
assert [r["role"] for r in item_lines] == ["user", "assistant"]
assert item_lines[1]["content"] == [{"type": "output_text", "text": "hi there"}]
@respx.mock
def test_session_export_default_filename(tmp_path: Path) -> None:
"""Without --output, the file is named <session_id>.jsonl in cwd."""
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
return_value=httpx.Response(200, json=_SESSION_META)
)
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
return_value=httpx.Response(200, json={**_ITEMS_PAGE, "data": [], "has_more": False})
)
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), _patch_server():
result = runner.invoke(cli, ["session", "export", "--id", "conv_abc123"])
assert result.exit_code == 0, result.output
default_path = Path("conv_abc123.jsonl")
assert default_path.exists()
lines = [json.loads(line) for line in default_path.read_text().splitlines() if line]
assert len(lines) == 1
assert lines[0]["record_type"] == "session_meta"
assert lines[0]["id"] == "conv_abc123"
@respx.mock
def test_session_export_missing_session_errors(tmp_path: Path) -> None:
"""Export of an unknown session id exits non-zero with a clear message."""
respx.get(f"{_BASE}/v1/sessions/conv_doesnotexist").mock(
return_value=httpx.Response(404, json={"error": "not found"})
)
runner = CliRunner()
with _patch_server():
result = runner.invoke(
cli,
[
"session",
"export",
"--id",
"conv_doesnotexist",
"--output",
str(tmp_path / "out.jsonl"),
],
)
assert result.exit_code != 0
assert "conv_doesnotexist" in result.output
@respx.mock
def test_session_export_items_ordered_ascending(tmp_path: Path) -> None:
"""Items in the JSONL appear in ascending position order (user then assistant)."""
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
return_value=httpx.Response(200, json=_SESSION_META)
)
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
return_value=httpx.Response(200, json=_ITEMS_PAGE)
)
out_file = tmp_path / "ordered.jsonl"
runner = CliRunner()
with _patch_server():
result = runner.invoke(
cli,
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
)
assert result.exit_code == 0, result.output
records = [json.loads(line) for line in out_file.read_text().splitlines() if line]
item_records = [r for r in records if r["record_type"] == "item"]
assert len(item_records) == 2
assert item_records[0]["role"] == "user"
assert item_records[1]["role"] == "assistant"
@respx.mock
def test_session_export_pagination(tmp_path: Path) -> None:
"""Export follows has_more cursors to fetch all pages."""
page1 = {
"data": [
{
"id": "msg_1",
"type": "message",
"status": "completed",
"response_id": "r1",
"role": "user",
"content": [],
}
],
"first_id": "msg_1",
"last_id": "msg_1",
"has_more": True,
}
page2 = {
"data": [
{
"id": "msg_2",
"type": "message",
"status": "completed",
"response_id": "r1",
"role": "assistant",
"content": [],
"model": "ag",
}
],
"first_id": "msg_2",
"last_id": "msg_2",
"has_more": False,
}
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
return_value=httpx.Response(200, json=_SESSION_META)
)
# First call (no after param) → page1; second call (after=msg_1) → page2.
items_route = respx.get(f"{_BASE}/v1/sessions/conv_abc123/items")
items_route.side_effect = [
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
]
out_file = tmp_path / "paged.jsonl"
runner = CliRunner()
with _patch_server():
result = runner.invoke(
cli,
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
)
assert result.exit_code == 0, result.output
records = [json.loads(line) for line in out_file.read_text().splitlines() if line]
item_records = [r for r in records if r["record_type"] == "item"]
assert len(item_records) == 2
assert [r["id"] for r in item_records] == ["msg_1", "msg_2"]
+49
View File
@@ -4852,6 +4852,55 @@ def test_claude_prompt_rendered_sees_prompt_above_default_footer() -> None:
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_sees_prompt_above_running_turn_footer() -> None:
"""
The readiness scan reaches the prompt above a tall running-turn footer.
When a web-UI message is injected while Claude is mid-turn, the footer
grows extra status rows below the input box — a running-subagent line
(``○ Explore …``) on top of the usual box rule, model, auto-mode, and
branch rows. That pushes the live ```` row to the 6th non-empty line
from the bottom, one past the old 5-line window, so the readiness gate
timed out and the web UI rendered a spurious "did not become ready"
runtime-error card even though the terminal was healthy.
"""
pane = "\n".join(
[
"────────────────────────────────────────", # input box top rule
" ", # the live prompt row (6th non-empty line from bottom)
"────────────────────────────────────────", # box closing rule
" Opus 4.8 (1M context) | thinking medium", # model + effort line
" ⏵⏵ auto mode on (shift+tab to cycle)", # permission-mode hint
" main", # branch label
" ○ Explore Find session sidebar state… 1m 4s", # subagent status
]
)
assert _claude_prompt_rendered(pane) is True
def test_claude_prompt_rendered_ignores_unframed_glyph_deep_in_tail() -> None:
"""
A glyph in the wider window without a box rule below is not trusted.
The framed window that lets the scan reach a prompt under a tall
running-turn footer must not resurrect the scrollback false positive:
a ```` echoed into prior output sits in the wider window too, but
without the input box's closing ``────`` rule beneath it. Only plain
output follows here, so the gate must still report "not ready".
"""
pane = "\n".join(
[
" old prompt echo", # 6th non-empty line from bottom, no rule below
"output line 1",
"output line 2",
"output line 3",
"output line 4",
"output line 5",
]
)
assert _claude_prompt_rendered(pane) is False
def _write_deltas_lines(bridge_dir: Path, lines: list[str]) -> None:
"""
Append raw JSONL lines to the bridge deltas file.
+35 -5
View File
@@ -15,10 +15,18 @@ import type { AccountListEntry } from "@/lib/accountsApi";
import * as accountsApi from "@/lib/accountsApi";
import * as identity from "@/lib/identity";
const mocks = vi.hoisted(() => ({ accountsEnabled: true }));
const mocks = vi.hoisted(() => ({
accountsEnabled: true,
loginUrl: null as string | null,
serverVersion: "0.3.0.dev0" as string | null,
}));
vi.mock("@/lib/CapabilitiesContext", () => ({
useServerInfo: () => ({ accounts_enabled: mocks.accountsEnabled }),
useServerInfo: () => ({
accounts_enabled: mocks.accountsEnabled,
login_url: mocks.loginUrl,
server_version: mocks.serverVersion,
}),
}));
vi.mock("@/lib/identity", () => ({
resolveIdentity: vi.fn(),
@@ -52,6 +60,8 @@ function renderPage() {
beforeEach(() => {
mocks.accountsEnabled = true;
mocks.loginUrl = null;
mocks.serverVersion = "0.3.0.dev0";
vi.mocked(identity.resolveIdentity).mockResolvedValue("admin");
vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(true);
vi.mocked(accountsApi.listUsers).mockResolvedValue([]);
@@ -183,12 +193,32 @@ describe("MembersPage actions", () => {
});
});
describe("MembersPage in plain header/single-user mode", () => {
beforeEach(() => {
// Single-user mode: no accounts, no IdP (login_url is null). The
// /auth/users endpoint does not exist, so the page must skip the fetch
// and show a "not available" message instead.
mocks.accountsEnabled = false;
mocks.loginUrl = null;
mocks.serverVersion = "0.3.0.dev0";
});
it("shows a not-available message and never calls listUsers", async () => {
renderPage();
expect(
await screen.findByText("Member management is not available in single-user mode."),
).toBeInTheDocument();
expect(accountsApi.listUsers).not.toHaveBeenCalled();
});
});
describe("MembersPage under OIDC (read-only)", () => {
beforeEach(() => {
// OIDC: accounts disabled → no password-based management. The list still
// renders (admins can see who's provisioned), but every management
// affordance is gone.
// OIDC: accounts disabled but login_url is non-null (IdP present).
// The list still renders (admins can see who's provisioned), but every
// management affordance is gone.
mocks.accountsEnabled = false;
mocks.loginUrl = "/auth/login";
});
it("lists users but offers no management actions", async () => {
+26 -6
View File
@@ -55,6 +55,14 @@ export function MembersPage() {
// accounts mode — OIDC identities are owned by the IdP, so under OIDC
// this page is a read-only user list (no action column, no modals).
const manageable = info !== "loading" && info.accounts_enabled;
// Plain header/single-user mode: no auth endpoints exist. server_version
// distinguishes a live single-user server from a failed /v1/info probe
// (which uses the same accounts_enabled:false / login_url:null sentinel).
const isSingleUser =
info !== "loading" &&
!info.accounts_enabled &&
info.login_url === null &&
info.server_version !== null;
const [meIsAdmin, setMeIsAdmin] = useState<boolean | null>(null);
const [meId, setMeId] = useState<string | null>(null);
const [users, setUsers] = useState<AccountListEntry[] | null>(null);
@@ -82,12 +90,11 @@ export function MembersPage() {
setUsers(list);
}, []);
// Initial load: identity probe + members list. The identity probe
// gates the UI (non-admins see "no access"); the list is what we
// render the table from. Uses the mode-agnostic `/v1/me` identity
// (via resolveIdentity) rather than the accounts-only `/auth/me`, so
// the page also works under OIDC where `/auth/me` doesn't exist.
// Initial load: identity probe + members list. Skipped in single-user
// mode since no auth endpoints exist. isSingleUser is a stable boolean
// so it is safe as a dep without risking infinite re-renders.
useEffect(() => {
if (isSingleUser) return;
void (async () => {
const userId = await resolveIdentity();
if (userId === null) {
@@ -101,10 +108,23 @@ export function MembersPage() {
setMeIsAdmin(isAdmin);
if (isAdmin) await refresh();
})();
}, [refresh]);
}, [refresh, isSingleUser]);
if (isSingleUser) {
return (
<div className="mx-auto w-full max-w-2xl px-6 py-12">
<h1 className="mb-2 text-2xl font-semibold">Members</h1>
<p className="text-sm text-muted-foreground">
Member management is not available in single-user mode.
</p>
</div>
);
}
// Pre-admin-check render: blank loading state. min-h-full so the
// AppShell's outlet container governs height — we're a child view,
// not a full-page replacement. min-h-full so the
// AppShell's outlet container governs height — we're a child view,
// not a full-page replacement.
if (meIsAdmin === null) {
return (