Compare commits

...

45 Commits

Author SHA1 Message Date
Zeyi (Rice) Fan af3cc05eb7 refactor(electron): explicit-only runner connect, no ambient host UI
## Related issue

N/A

## Summary

Radical simplification of desktop runner management. Connecting this
machine as a runner is now a single explicit action from the in-app host
selection menu — the shell never starts a runner on its own.

- Remove the sidebar `HostStatusIndicator` entirely (the "Host Status"
  and "Local Server Status" rows + their Start/Stop/Restart menus) and
  its mount in `Sidebar.tsx`.
- Stop auto-connecting the runner: drop `restoreRunner` (launch restore
  in `did-finish-load`) and `connectRunner` (connect-time). The shell no
  longer reconnects on launch or on connect.
- Remove the now-dead setup-page "Connect this machine as a runner"
  toggle (markup, styles, JS) and its wiring: the `host` opt on
  `setServerUrl`, the `get-host-on-connect` IPC, and the
  `host_on_connect` / `host_servers` settings + their helpers
  (`loadHostServers`, `setHostServerEnabled`, `isHostServerEnabled`).
- Drop `showRunnerToast` (only used by the removed auto-connect paths)
  and the host-server carry-over in `followLocalServerMove`.
- Keep the runner-start path intact: `host-control` IPC + `controlHost`
  bridge, driven by the host selection menu's "connect this machine"
  action (`NewChatDialog`). Lifecycle unchanged — the desktop still owns
  and tears down what it starts on quit; adopted daemons are left
  running.
- Update README to match: no connect-time toggle, no sidebar rows, no
  restore-on-launch.

## Test Plan

- `cd ap-web/electron && npm test` — 50 pass; `node --check` on main.js /
  preload.js; `prettier --check` + `oxlint` clean.
- `cd ap-web && npx tsc -b` exit 0; `vitest run NewChatDialog` — 136 pass
  (the host-selection-menu connect path is unaffected).

## Type of change

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

## Test coverage

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

## Coverage notes

Pure removal of UI + auto-connect wiring; no new logic to unit-test. The
retained connect path (host selection menu → controlHost) is covered by
the existing NewChatDialog suite (136 pass). Existing electron unit tests
(50) still pass. Live GUI verification remains blocked by the test
machine's out-of-date local DB schema, as in prior commits.
2026-06-25 22:42:46 -07:00
Zeyi (Rice) Fan a32e8f2e18 perf(electron): resolve host status from disk + a single tunnel probe
## Related issue

N/A

## Summary

- `omnigent host status --json` is slow because, per daemon, it makes a
  network round trip for the host tunnel status AND enumerates sessions
  with a per-session runner-online probe (`_add_daemon_sessions` in
  omnigent/cli.py). The desktop's `connectionFromStatus` only reads
  `process`/`host_status`/`pid` — the entire session enumeration is
  wasted work on the status hot path.
- Add a fast substitute in `omnigent_cli.js`: `readDaemonRecords()`
  reads the on-disk daemon registry (`~/.omnigent/daemons/*.json`) and a
  pid-liveness check yields `process` instantly with no subprocess and no
  network. Tunnel health comes from `probeHostTunnel()` — a single
  `GET /v1/hosts/{host_id}` (the same basic request the CLI makes), with
  loopback needing no auth and a remote server using a stored bearer from
  `auth_tokens.json`.
- `getHostConnectionFast()` combines them and returns the same shape as
  `connectionFromStatus`, plus a `verified` flag: when the tunnel can't
  be probed in-process (a Databricks-pointer login mints tokens via the
  SDK, not reproducible in Node), a live process is reported as connected
  optimistically rather than falsely shown offline.
- Wire `server_manager.statusFor` (sidebar status) and `connectHost`'s
  adopt pre-check (`probe: false` — it only needs `process`) onto the
  fast path. The slow `getHostStatus` is no longer called on hot paths.

## Test Plan

- `cd ap-web/electron && npm test` — 50 pass (added `parseDaemonRecord`
  and `daemonServerUrl` cases).
- `node --check` on both source modules; `prettier --check` clean.

## Type of change

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

## Test coverage

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

## Coverage notes

Unit tests cover the new pure helpers (`parseDaemonRecord` validation +
string-pid coercion; `daemonServerUrl` local-vs-server resolution). The
fs/fetch-backed functions (`readDaemonRecords`, `probeHostTunnel`,
`getHostConnectionFast`) are exercised in the manual verification flow,
matching how the other spawning/IO helpers in this module are covered.
2026-06-25 22:29:24 -07:00
Zeyi (Rice) Fan f9db9f62a5 fix(electron): adopt an existing daemon instead of failing on a conflict
## Related issue

N/A

## Summary

Connecting the runner could fail with "A host daemon is already running for this
server (target=local)" — a false failure: a local-mode daemon was already
serving the loopback server (so the runner WAS connected), but our pre-check
didn't recognize it and tried to spawn a duplicate, which the CLI rejects.

- `matchesServer` now also matches a daemon's `resolved_server_url`, so a
  local-mode daemon (target "local", server_url null) is recognized when
  connecting by its loopback URL.
- `connectHost` queries all daemons (no `--server` filter) in the adopt
  pre-check so local-mode daemons are visible, and — as a backstop — treats the
  CLI's "already running for this server" conflict as success (adopt) rather
  than an error.
- `statusFor` likewise queries all daemons so the sidebar recognizes an adopted
  local daemon.

## Test Plan

- `cd ap-web/electron && npm test` — 44 tests pass (incl. new
  resolved_server_url match case); `node --check` clean; prettier clean.

## Type of change

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

## Test coverage

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

## Coverage notes

matchesServer resolved_server_url case unit-tested; the conflict-adopt backstop
is a text match on the CLI's own conflict message. Live confirmation needs a
running local daemon, but the false-failure path is now handled.
2026-06-25 22:18:19 -07:00
Zeyi (Rice) Fan abac666a56 chore(electron): rename the local-server button to "Start locally"
## Related issue

N/A

## Summary

Rename the setup-page button (and its references) from "Run locally" to "Start
locally".

## Test Plan

- `npx prettier --check electron/setup/index.html` — clean. Copy-only change.

## Type of change

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

## Test coverage

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

## Coverage notes

Label-only rename on the bundled setup page; verified via prettier.
2026-06-25 22:14:34 -07:00
Zeyi (Rice) Fan 95467f9875 polish(electron): render the runner toast with real HTML + a code chip
## Related issue

N/A

## Summary

- The runner toast no longer puts markdown backticks in textContent. The
  command renders in a proper `<code>` chip built as a DOM node (textContent —
  safe, no markup parsing of untrusted strings); `showRunnerToast` takes
  `{ kind, command }`.
- Restyled: a blurred dark card with a status dot (red/green), the message, the
  code chip below, a border, drop shadow, and a slide-up + fade animation.
- The login hint commands include the server arg (`omnigent login <url>`) in the
  code chip.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean;
  prettier clean.

## Type of change

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

## Test coverage

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

## Coverage notes

Presentation-only change to the injected toast; verified via node --check, the
electron unit suite, and prettier. Visual confirmation in the running app is the
remaining check.
2026-06-25 22:13:42 -07:00
Zeyi (Rice) Fan cacee20248 feat(electron): polished runner-connect feedback + auth-aware messages + restore
## Related issue

N/A

## Summary

Replaces the modal message box with a polished in-page toast, classifies auth
failures for friendlier messaging, and routes the launch-time restore through
the same path so failures are visible.

- `showRunnerToast`: a small, auto-dismissing toast injected into the focused
  window's page (executeJavaScript). Works regardless of the server's SPA
  version, isn't suppressed like a notification for the frontmost app, and is
  lighter than a modal dialog.
- Auth classification: `server_manager.isAuthError` flags `omnigent host`'s
  auth failures (401 / login-redirect / "omnigent login" hint); the result
  carries `authError`. On auth failures the toast says "Sign in … run
  `omnigent login <server>`" (with the server arg) instead of a raw error.
- `connectRunner` (connect-time toggle): ensures auth, connects, and toasts the
  outcome — success too, so there's positive feedback even on an old SPA.
- `restoreRunner` (launch restore): attempts the reconnect and, on failure,
  shows a gentle auth/again toast. It does NOT auto-launch an interactive login
  (no surprise browser on startup). The did-finish-load restore now calls this
  instead of a silent ensureHostConnected.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- prettier clean.

## Type of change

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

## Coverage notes

Auth classification is a text match on `omnigent host`'s own messages; the toast
+ restore paths reuse the verified auth/connect helpers. Live remote
connect/restore needs a server to exercise end to end, but failures are now
visible (toast) and auth ones read friendly.
2026-06-25 22:11:33 -07:00
Zeyi (Rice) Fan 40f7f2c6b9 fix(electron): show runner-connect failures in a dialog, not a notification
## Related issue

N/A

## Summary

The connect-time runner-connect failure used an OS notification, which the OS
suppresses for the frontmost app (the user just clicked Connect) — so it landed
silently in Notification Center and was never seen, and an old server SPA shows
no in-app host indicator either. Switch to a native modal dialog
(`dialog.showMessageBox`), which appears regardless of window focus or SPA
version and shows the failure reason.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- prettier clean.

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

Feedback-channel change (notification → modal dialog) so connect-as-runner
failures are actually visible. Verified via node --check, electron unit suite,
and prettier.
2026-06-25 22:05:43 -07:00
Zeyi (Rice) Fan 50efaef93c fix(electron): auth + surface failures for connect-time runner connect
## Related issue

N/A

## Summary

The setup page's "Connect this machine as a runner" toggle connected the host
without ensuring CLI auth and swallowed any error (`.catch(() => {})`), so on a
remote server it could silently do nothing — with no signal at all on a server
whose SPA predates the in-app host indicator.

- The connect-time path now ensures CLI auth first (via `ensureServerAuth`,
  matching the sidebar/picker path) before connecting — local servers still need
  none.
- Failures (no CLI, auth, or host connect) are now logged and surfaced as an OS
  notification ("Couldn't connect this machine as a runner: <reason>"), the one
  feedback channel that works regardless of the server's SPA version.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- prettier clean.

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

ensureServerAuth/serverAuthed verified against the real token store earlier; the
connect-time path now reuses them and reports failures. Live remote connect
needs a server to exercise end to end, but the failure is no longer silent.
2026-06-25 22:03:19 -07:00
Zeyi (Rice) Fan bf90a4549f feat(electron): ensure CLI auth before connecting a host to a remote server
## Related issue

N/A

## Summary

Connecting "this machine" (or the sidebar Connect/Restart) to a remote server
that requires auth used to just fail on a 401. Now the host-control start/restart
path ensures the CLI is authenticated first:

- `serverAuthed(serverUrl)` reads `~/.omnigent/auth_tokens.json` directly (no
  subprocess), mirroring cli_auth.py: valid = a Databricks pointer record or a
  non-expired session token for the trailing-slash-stripped URL.
- `ensureServerAuth`: loopback servers need no auth; an already-authed remote is
  fine; otherwise it runs `omnigent login <url>` (browser/OIDC/Databricks; a
  no-op when the server needs no auth) and, if that fails (e.g. a password/TTY
  mode), returns an error pointing at `omnigent login`.
- host-control "start"/"restart" run ensureServerAuth before connecting. The
  silent restore-on-load path is unchanged (no surprise browser on launch).

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- Probed `serverAuthed` against the real auth_tokens.json: Databricks-authed
  servers → true, unknown → false, loopback handled separately.
- prettier clean.

## Type of change

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

## Coverage notes

serverAuthed verified against the real token store. The interactive
`omnigent login` browser flow needs a live remote server to exercise end to end;
the no-auth/loopback and already-authed paths are covered by the logic + probe.
2026-06-25 21:51:25 -07:00
Zeyi (Rice) Fan 49fb9f8d15 fix(ap-web): identify "this machine" instantly via a fast identity bridge
## Related issue

N/A

## Summary

The new-session picker's "this machine" tag and connect affordance were gated on
getHostStatus, which blocks on the slow `omnigent host status` runner check
(~1s) — so the label only appeared after a delay. The picker only needs the CLI
presence and host id, both of which come from local config instantly.

- Add a fast bridge call `getHostIdentity()` → `{ cliInstalled, hostId }` read
  from local config with no subprocess (host-get-identity IPC). ~13ms vs ~1s.
- NewChatDialog now uses getHostIdentity (on mount, on host-status pings, and
  after auto-connect) instead of getHostStatus, so the machine is recognized and
  the connect row/label show immediately. The sidebar indicator still uses the
  full getHostStatus (it needs the live connected state).

## Test Plan

- `cd ap-web && npx vitest run src/shell/NewChatDialog.test.tsx` — 103 pass;
  `npx tsc -b` (exit 0); prettier clean.
- `cd ap-web/electron && npm test` — 43 pass; `node --check` clean.
- Probed: localHostId + resolveCliPath together resolve in ~13ms.

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

Fast-path timing measured directly (~13ms). Existing suites pass; the live
picker label is now driven by the instant identity call.
2026-06-25 21:39:33 -07:00
Zeyi (Rice) Fan 20f8072eaa fix(ap-web): show "Connecting…" in the host chip while this machine connects
## Related issue

N/A

## Summary

After selecting "Run on this machine", the connect runs once the dropdown
closes, but the host chip still showed the old/placeholder label until it
finished. The chip now reads "Connecting…" (in foreground color) while
`connectingThisMachine` is in flight, then switches to the machine once
selected.

## Test Plan

- `cd ap-web && npx vitest run src/shell/NewChatDialog.test.tsx` — 103 tests
  pass. `npx tsc -b` (exit 0); prettier clean.

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

Existing NewChatDialog suite (103 tests) passes; typecheck + prettier clean.
Live connect transition pending a running server on the test machine.
2026-06-25 21:33:16 -07:00
Zeyi (Rice) Fan 1027a44b0d fix(ap-web): say "Disconnecting…" for the host stop action
## Related issue

N/A

## Summary

The Host Status row's action is "Disconnect", but the in-flight state still read
"Stopping…". Host now shows "Disconnecting…" (status line) / "disconnecting…"
(row hint) while the stop is in flight. The Local Server row keeps "Stopping…".

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean. Copy-only.

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

## Coverage notes

Label-only change to the host disconnect transition; verified via tsc, oxlint,
prettier.
2026-06-25 21:30:45 -07:00
Zeyi (Rice) Fan 9a377c6063 fix(ap-web): reword the this-machine connect hint to "select to connect"
## Related issue

N/A

## Summary

The offline this-machine host row's subtitle now reads "this machine · select to
connect" (was "· connect"), making it clear that selecting the row is what
connects it.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); prettier clean. Copy-only change.

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

## Coverage notes

Label-only change; verified via tsc + prettier.
2026-06-25 21:29:54 -07:00
Zeyi (Rice) Fan 466ab0c902 fix(ap-web): connect this machine only after the host dropdown closes
## Related issue

N/A

## Summary

Selecting "Run on this machine" (or the offline this-machine row) no longer
connects while the dropdown is still open (which looked janky). The select now
just flags intent (`pendingConnectRef`) and lets the menu close normally;
`connectThisMachine()` runs from the menu's `onOpenChange` once it's actually
closed.

## Test Plan

- `cd ap-web && npx vitest run src/shell/NewChatDialog.test.tsx` — 103 tests
  pass. `npx tsc -b` (exit 0); prettier clean.

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

Existing NewChatDialog suite (103 tests) passes; typecheck + prettier clean. The
connecting feedback shows in the sidebar Host Status indicator while the menu is
closed. Live connect flow pending a running server on the test machine.
2026-06-25 21:28:23 -07:00
Zeyi (Rice) Fan a740573096 fix(ap-web): de-duplicate the this-machine host option, label as a subtitle
## Related issue

N/A

## Summary

Polish the new-session host picker per feedback:

- No duplicate row. When this machine is already in the host list (offline),
  that row IS the connect affordance — clickable, keeps the menu open while
  connecting — instead of a disabled entry plus a separate "Run on this
  machine" item. The standalone item now shows only when the machine isn't in
  the list at all.
- "this machine" is now a second-line subtitle under the host name (was an
  inline tag that wrapped and pushed the status badge off-screen). HostOption is
  a two-line layout: name + status on line 1, optional subtitle on line 2, with
  truncation so it no longer overflows.

## Test Plan

- `cd ap-web && npx vitest run src/shell/NewChatDialog.test.tsx` — 103 tests
  pass. `npx tsc -b` (exit 0); prettier clean.

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

Existing NewChatDialog suite (103 tests) passes; typecheck + prettier clean.
Visual layout confirmed against the reported overflow (two-line subtitle +
truncation). Live connect flow pending a running server on the test machine.
2026-06-25 21:25:30 -07:00
Zeyi (Rice) Fan b3f6ae4a52 feat(ap-web): offer "Run on this machine" in the new-session host picker
## Related issue

N/A

## Summary

In the desktop shell, the new-session host picker now recognizes the current
machine and can connect it in one click — no terminal `omni host` needed.

- Tracks this machine's host status via the desktop bridge (getHostStatus +
  onHostStatusChanged); matches it in the /v1/hosts list by host_id and tags it
  "· this machine".
- When this machine can host (Electron shell + CLI present) but isn't an online
  host for this server, shows a "Run on this machine" item. Selecting it calls
  `controlHost("start")`, then reads the host id, refreshes the host list, and
  selects this machine — so it's immediately usable as the session's host.
- No-op in a plain browser (isElectronShell gate).

## Test Plan

- `cd ap-web && npx vitest run src/shell/NewChatDialog.test.tsx` — 103 tests
  pass.
- `npx tsc -b` (exit 0); prettier clean. (Two oxlint findings in the file are
  pre-existing, outside the changed lines.)

## Type of change

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

## Coverage notes

Existing NewChatDialog suite (103 tests) passes with the change; typecheck +
prettier clean. The live auto-connect flow needs a running server + the desktop
shell, pending the test machine's local DB (out-of-date schema blocks `omnigent
server start`).
2026-06-25 21:19:51 -07:00
Zeyi (Rice) Fan 06ee549bea feat(electron): expose this machine's host id via the bridge
## Related issue

N/A

## Summary

Groundwork for letting the new-session host picker identify (and auto-connect)
the current desktop machine.

- omnigent_cli: add `localHostId()` — reads the machine identity from
  `config.yaml` (`host.host_id`, written by omnigent/host/identity.py) using a
  real YAML parse (js-yaml), memoized; instant, no subprocess. Present once
  generated, even before connecting anywhere. Add `localConfigDir()`
  (honors `$OMNIGENT_CONFIG_HOME`).
- Add `js-yaml` as the electron app's first runtime dependency (bundled by
  electron-builder) so the parse is proper rather than a regex.
- `statusFor` now includes `hostId` in every shape; `HostStatus.hostId` added to
  nativeBridge so the SPA can match "this machine" against /v1/hosts and select
  it after an auto-connect.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- Probed `localHostId()` against the real config.yaml — returns the host id via
  js-yaml.
- `cd ap-web && npx tsc -b` (exit 0); prettier clean.

## Type of change

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

## Coverage notes

localHostId exercised against the real config.yaml. js-yaml is a declared
production dependency so electron-builder bundles it; full packaged-build
verification is out of scope here.
2026-06-25 21:15:40 -07:00
Zeyi (Rice) Fan cf557ac385 fix(electron): follow the local server to a new port on restart
## Related issue

N/A

## Summary

Per the chosen approach (keep the canonical, pidfile-tracked `omnigent server
start` rather than pinning a port): `server start` has no `--port` and falls
back to a fresh free port when 6767 is busy, so a restart can move the server.
The window now follows it instead of being left on a dead port.

- After a local-server start/restart, if the server came up on a different
  origin than the window is on, `followLocalServerMove` re-points the window
  (re-pin + loadURL), persists the new default URL, and migrates hosting intent
  + the live host connection to the new URL (drops the old, restores on the new
  via the post-load hook) so a connected runner follows too.
- `waitForPortFree` (prior commit) still minimizes moves when 6767 is free, so
  the re-point only triggers when the port genuinely changes.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- prettier clean.

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

Window re-point logic lives in main.js (Electron-bound). Live confirmation of a
port move + follow needs a working local server, pending the test machine's DB
(out-of-date schema blocks `omnigent server start`). The CLI's no-pin port
behavior was confirmed in omnigent/host/local_server.py.
2026-06-25 21:03:10 -07:00
Zeyi (Rice) Fan 417c7895d6 fix(electron): health-verify local server before reusing it on "Run locally"
## Related issue

N/A

## Summary

Handle a stale local-server pidfile in the reuse path. "Run locally" reused a
server based on pid-liveness alone, so a stale pidfile (dead pid was already
handled, but a *reused* pid with nothing listening, or a hung server) could hand
back a URL for a phantom server and navigate the window to a dead page.

- Add `cli.localServerHealthy()` — pidfile + pid liveness + a short `/health`
  probe, mirroring `local_server_url_if_healthy()` in
  omnigent/host/local_server.py. Returns null for a stale pidfile (refused fast,
  or times out for a hung server).
- `startLocalServer` reuses only a health-verified server; otherwise it falls
  through to `omnigent server start` (which starts fresh / cleans up). Still far
  faster than the old `omnigent server status` Python pre-check.
- The sidebar row keeps the instant pidfile-only read (`localServerStatus`): it's
  only shown for the port the window is already connected to, so a server is
  provably up there — documented the distinction. Factored out
  `readLocalServerPidfile`.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- Probed `localServerHealthy`: dead pid → null; live pid with nothing on the
  port → connection refused → null (both fast). prettier clean.

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

localServerHealthy exercised directly (dead pid, refused port → null). Healthy
reuse + the cold-start fallback need a working local server, pending the test
machine's local DB (out-of-date schema blocks `omnigent server start`).
2026-06-25 20:53:28 -07:00
Zeyi (Rice) Fan b9b23d93b6 perf(electron): make "Run locally" reuse a running server instantly
## Related issue

N/A

## Summary

Clicking "Run locally" paid an `omnigent server status` Python cold start on
every click — even when a local server was already running this session — before
navigating. Reuse is now detected via the instant pidfile read.

- `startLocalServer` checks `cli.localServerStatus()` (pidfile + pid liveness,
  no subprocess) first: if a local server is already up, return its URL
  immediately (we didn't start it, so no ownership claim). Only a genuine cold
  start spawns `omnigent server start`.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- prettier clean.

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

Reuse now uses the pidfile read (already exercised against real/fake pidfiles).
The cold-start path still uses `omnigent server start`. Full live confirmation is
pending a working local DB on the test machine (out-of-date schema blocks
`omnigent server start`).
2026-06-25 20:50:29 -07:00
Zeyi (Rice) Fan 07285f1a02 fix(electron): keep the local server on the same port across restart
## Related issue

N/A

## Summary

Restarting the local server moved it to a new port: `omnigent server start`
prefers the stable port (6767) but falls back to a free one when the
just-stopped port isn't rebindable yet, leaving the connected window pointed at
a dead URL. (`server start` takes no `--port`, so the port can't be pinned
directly.)

- `restartLocalServer` now captures the current port (from the pidfile),
  stops, then waits for that port to actually free before starting — so
  `server start`'s preferred-port probe rebinds the same port.
- Add `waitForPortFree(port, timeout)` (polls a loopback bind, 5s cap).

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass; `node --check` clean.
- Functionally exercised waitForPortFree: returns once a busy port frees
  (~0.6s in a bind/release test) and immediately (0ms) for an already-free port.

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

waitForPortFree exercised directly (busy→free and already-free); the restart
flow depends on the CLI's 6767 preference, confirmed in omnigent/host/local_server.py.
Full restart against a live server is pending a working local DB on the test
machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 20:49:01 -07:00
Zeyi (Rice) Fan d464948a33 feat(electron): lead the setup card with "Run locally"
## Related issue

N/A

## Summary

Move "Run locally" to the top of the connect screen so it's the first,
prominent action, with the connect-to-a-server form below a divider:

- Order is now: Run locally (filled primary) + CLI panel → "or connect to a
  server" divider → Server URL + runner toggle + Connect (secondary outline) →
  recents.
- Update the subtitle to "Run an Omnigents server on this machine, or connect to
  an existing one." and the divider to "or connect to a server"; tidy spacing.

## Test Plan

- `npx prettier --check electron/setup/index.html` — clean. Setup-page reorder;
  all element IDs unchanged so the existing JS wiring is unaffected.

## Type of change

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

## Coverage notes

Pure markup reorder + copy/spacing on the bundled setup page; IDs preserved so
getElementById wiring is unchanged. Verified via prettier.
2026-06-25 20:44:38 -07:00
Zeyi (Rice) Fan 3f91d74245 feat(electron): rename "Start a server on this machine" to "Run locally", make it prominent
## Related issue

N/A

## Summary

- Rename the setup-page local-server button to "Run locally".
- Make it the prominent primary action: "Run locally" is now the filled button
  and Connect (to a remote server) becomes the secondary outline button, so the
  local option clearly stands out.

## Test Plan

- `npx prettier --check electron/setup/index.html` — clean. Setup-page
  markup/style change; behavior unchanged.

## Type of change

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

## Coverage notes

Label + button-prominence change on the bundled setup page; verified via
prettier. No behavior change (same start-local flow).
2026-06-25 20:40:56 -07:00
Zeyi (Rice) Fan d9ba4a0504 perf(electron): read local-server pidfile directly instead of the CLI
## Related issue

N/A

## Summary

Replaces the `omnigent server status` subprocess (and the URL cache from the
previous commit) for the Local Server Status row with a direct read of the
local-server pidfile — instant and always fresh.

- omnigent_cli: add `localServerStatus()` — reads
  `$OMNIGENT_DATA_DIR|~/.omnigent/local_server.pid` (two lines: pid, port),
  checks pid liveness via `process.kill(pid, 0)`, returns
  `{ running, url, pid, port }` or null. Plus pure helpers
  `parseLocalServerPidfile` (unit-tested) and `isPidAlive`. Mirrors
  `_read_local_server_pid_file()` / `_local_data_dir()` in
  omnigent/host/local_server.py.
- server_manager.serverStatusFor now uses it (no subprocess), and only surfaces
  the row when the pidfile's port matches the window's connected loopback server
  — which also guards against a stale pidfile (you can only be connected to a
  running server). Drops the knownLocalServerUrl cache.

## Test Plan

- `cd ap-web/electron && npm test` — 43 tests pass (incl. new
  parseLocalServerPidfile cases); `node --check` clean.
- Probed `localServerStatus()` directly: returns a status for a live pid and
  null for a dead one (via a fake OMNIGENT_DATA_DIR pidfile).
- prettier clean.

## Type of change

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

## Test coverage

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

## Coverage notes

parseLocalServerPidfile is unit-tested; localServerStatus was exercised directly
against real and fake pidfiles (live pid → running, dead pid → null).
2026-06-25 20:35:55 -07:00
Zeyi (Rice) Fan fd8d0422ce perf(electron): make Local Server Status appear instantly too
## Related issue

N/A

## Summary

The Local Server Status row lagged on refresh because getLocalServerStatus
always shelled out to `omnigent server status` (Python cold start). Mirror the
host fix:

- Cache the URL of a local server we know is running (`knownLocalServerUrl`),
  set when we start/reuse one and after a CLI status read confirms one, cleared
  on stop.
- `serverStatusFor` returns it instantly (no subprocess) when it matches the
  connected loopback server. The main process and cache survive renderer
  reloads, so after the first read the row appears immediately on refresh; a
  desktop-started local server is instant from the start.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean.
- prettier clean.

## 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 via the electron unit suite, node --check, and prettier. The fast path
returns from in-process cache; live confirmation against a running local server
is pending a working local DB on the test machine (out-of-date schema blocks
`omnigent server start`).
2026-06-25 20:31:52 -07:00
Zeyi (Rice) Fan 171743a6e1 fix(ap-web): nudge the status dot onto the text optical center
## Related issue

N/A

## Summary

The status dot read slightly high next to "connecting…" / "checking…": with
`items-center` the dot aligns to the text line-box center, but lowercase text
sits a hair below that. Revert the earlier `leading-none` (which worsened it) and
give the dot `relative top-px` so it lands on the text's optical center. Applies
in both the hint and dot-only states.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean.

## 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 optical alignment; verified via tsc, oxlint, and prettier.
2026-06-25 20:25:24 -07:00
Zeyi (Rice) Fan af9f4d6e20 fix(ap-web): vertically center the status hint with the dot
## Related issue

N/A

## Summary

The "connecting…" / "checking…" hint (text-xs) sat slightly off the status dot
because its line box is taller than its glyphs inside the text-sm row. Add
`leading-none` to the hint so it centers cleanly against the dot.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean.

## 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 alignment tweak; verified via tsc, oxlint, and prettier.
2026-06-25 20:22:32 -07:00
Zeyi (Rice) Fan dadf7dc44b feat(ap-web): runner-centric labels in the Host Status menu
## Related issue

N/A

## Summary

Reword the Host Status row to runner/connection terms (Local Server Status menu
is unchanged):

- Start → "Connect", Stop → "Disconnect" (via a per-menu `labels` override on
  StatusMenu; Restart stays "Restart").
- The off-state status line is now "Connect this machine as a runner" (was "Not
  hosting").

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean on the component.

## Type of change

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

## Coverage notes

Label-only change; verified via tsc, oxlint, and prettier.
2026-06-25 20:19:59 -07:00
Zeyi (Rice) Fan 99d9365771 refactor(electron): drop the session count from host/server status
## Related issue

N/A

## Summary

The session/active count added nothing useful to the sidebar status, so remove
it end to end:

- UI: "Connected" (was "Connected · N sessions") and "Running" (was "Running · N
  active").
- Drop the non-blocking background session refresh from server_manager (it
  existed only to populate that count) — the owned-host fast path is now pure
  in-process state with no background subprocess at all.
- Remove `sessions` from `connectionFromStatus` / the HostStatus bridge type and
  `liveSessions` from serverStatusFor / the LocalServerStatus bridge type, plus
  the corresponding test assertions.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean.
- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean.

## Type of change

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

## Test coverage

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

## Coverage notes

connectionFromStatus tests updated for the dropped field; verified via the
electron unit suite, tsc, oxlint, and prettier.
2026-06-25 20:12:56 -07:00
Zeyi (Rice) Fan 7575f6dd05 perf(electron): report an owned host's status instantly (no subprocess)
## Related issue

N/A

## Summary

After a page refresh the sidebar still waited on an `omnigent host status` Python
spawn before showing "Connected". But for a host the desktop started, the main
process already knows it's connected (it owns the child and saw its "✓ Connected"
marker), so no subprocess is needed.

- `statusFor` now short-circuits for an owned, live host child: returns
  connected immediately (no CLI call), so on refresh the status appears in an
  IPC round-trip instead of after Python cold start. The main process persists
  across renderer reloads, so ownership survives a refresh.
- The session count for an owned host is filled in by a non-blocking background
  query (one per key at a time, pings only if it changed) so the instant path
  isn't blocked and there's still no polling.
- Clear the cached session count when the host exits or is disconnected.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean.
- prettier clean.

## 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 via the electron unit suite, node --check, and prettier. The owned-host
instant path returns from in-process state (hostChildren); live confirmation of
the refresh latency against a running local server is pending a working local DB
on the test machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 20:08:00 -07:00
Zeyi (Rice) Fan 70432dc3fe perf(electron): cache CLI path + render Host Status row immediately
## Related issue

N/A

## Summary

Fixes the lag before the "Host Status" row appears in the sidebar.

- Cache CLI resolution in main.js: `resolvedCliPath` memoizes the found binary
  and only re-probes when the configured override changes or the cached path is
  no longer executable (a cheap stat) — instead of shelling out to `command -v`
  on every status/control call.
- Render the sidebar row immediately in a neutral "Checking…" state instead of
  returning null until the first `getHostStatus` (which shells out to the Python
  CLI) resolves, so it no longer pops in seconds late.
- Gate the whole indicator on `isElectronShell()` at render time — it's never
  shown in a plain browser tab, only in the desktop shell. Once loaded, a page
  with no host status (not a connected server) hides it.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean.
- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean.

## 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 via tsc, oxlint, prettier, and the electron unit suite. The CLI-path
cache invalidates on a cheap executable check; live confirmation of the
immediate "Checking…" → status render against a running local server is pending
a working local DB on the test machine (out-of-date schema blocks `omnigent
server start`).
2026-06-25 20:02:05 -07:00
Zeyi (Rice) Fan 4d73131d71 fix(electron): show "connecting…" while the daemon is starting
## Related issue

N/A

## Summary

The auto-restore and connect-time host paths don't set the renderer's
client-side pending state, and with event-driven updates there was no push
*during* the connect — so the Host Status row jumped from "Not hosting" straight
to "Connected", never showing it was starting.

- `server_manager` now reports an in-flight connect: `statusFor` returns a
  connecting shape (`process: "online"`, `connected: false`) — with no
  subprocess — while a server's key is in the `connectingHosts` map, which the
  renderer renders as "connecting…".
- `ensureHostConnected` emits a change ping when a connect begins (and again when
  it settles), so the sidebar reflects the starting state promptly for every
  path (restore-on-load, connect-time, and sidebar Start).

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean.
- prettier clean.

## 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 via the electron unit suite, node --check, and prettier. The connecting
shape is surfaced from the in-flight connect map in server_manager; live visual
confirmation against a running local server is pending a working local DB on the
test machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 19:49:46 -07:00
Zeyi (Rice) Fan 01dd150b0a feat(electron): remember hosting per server and restore it on next launch
## Related issue

N/A

## Summary

The host daemon is torn down on quit, but the *intent* is now remembered and
restored:

- Persist host-enabled servers in `settings.json` → `host_servers`. A successful
  Start / Restart / connect-time host enables it; an explicit Stop or a
  connect-time opt-out clears it. Quit-time teardown does NOT clear it.
- On launch, once a window actually reaches its pinned server (not an auth
  redirect or the setup page), if that server was host-enabled the daemon is
  reconnected automatically. Ephemeral (multi-server) windows are never
  persisted or restored.
- server_manager.ensureHostConnected now dedups concurrent connects per server
  (in-flight promise map), so the restore-on-load path can't race the
  connect-time path into spawning two `omnigent host` processes.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean on
  main.js / server_manager.js.
- `cd ap-web && npx tsc -b` (exit 0); prettier clean.

## Type of change

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

## Coverage notes

Verified via the electron unit suite, tsc, and prettier. The persistence/restore
logic lives in main.js (Electron-bound, consistent with the rest of that file's
manual-verification coverage); the per-server dedup is in server_manager. Live
restore against a running local server is pending a working local DB on the test
machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 19:46:17 -07:00
Zeyi (Rice) Fan 5fc3fa6632 perf(electron): make host/server status event-driven instead of polling
## Related issue

N/A

## Summary

Removes the 5s host-status poller that spawned an `omnigent host status`
subprocess (Python startup) on every tick — plus a redundant renderer re-fetch —
for every connected window. Status is now event-driven:

- `server_manager` exposes `onChange(cb)` and fires it when a managed host child
  exits on its own (crash / external kill) — derived from the child process we
  already own, no subprocess or server call.
- main.js registers that listener and pings the renderer; the existing pings
  after control actions and connect-time hosting remain. `broadcastHostStatus`
  is now a bare ping (no `statusFor` call) — the renderer reads status on demand
  via the get-status handlers.
- The host daemon is a websocket client with no HTTP health endpoint of its own,
  so we observe its lifecycle through the child process + its stdout "✓ Connected"
  marker rather than polling the server. The CLI status query now runs only on
  mount and on real change events, never on a timer.
- preload / nativeBridge: `onHostStatusChanged` is now a no-arg change ping
  (re-read on fire) rather than a payload pushed on a timer.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass; `node --check` clean on
  main.js / preload.js / server_manager.js.
- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean.
- Confirmed no remaining `setInterval` / poller in main.js.

## Type of change

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

## Test coverage

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

## Coverage notes

Verified via the electron unit suite, tsc, oxlint, prettier, and a grep
confirming the poller is gone. Live confirmation of the event-driven updates
against a running local server is pending a working local DB on the test machine
(out-of-date schema blocks `omnigent server start`).
2026-06-25 19:36:22 -07:00
Zeyi (Rice) Fan 24cbb6ac0d feat(ap-web): surface connecting/transition state on the Host Status row
## Related issue

N/A

## Summary

- The Host Status row now shows "connecting…" (amber dot + inline hint) when the
  host daemon is coming up — both from live status (process online but the tunnel
  isn't yet) and immediately while a Start/Restart action is in flight, instead
  of staying on "Not hosting" until the connect finishes.
- Symmetric transient hints for the actions: "stopping…", "starting…",
  "restarting…", on both the Host Status and Local Server Status rows, reflected
  in the dot color, the inline hint, and the menu's status line.
- Track the in-flight action per row so the feedback is immediate and the menu
  items disable while it runs.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean on the component.

## Type of change

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

## Coverage notes

Verified via tsc, oxlint, and prettier. Live visual confirmation of the
connecting transition against a running local server is pending a working local
DB on the test machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 19:29:41 -07:00
Zeyi (Rice) Fan 9f90cc2925 chore(electron): reword connect-screen toggle to "Connect this machine as a runner"
## Related issue

N/A

## Summary

- Rename the connect-screen toggle label from "Host this machine on connect" to
  "Connect this machine as a runner to this server" — clearer about what opting
  in does (this machine runs agent work the server dispatches).

## Test Plan

- `npx prettier --check electron/setup/index.html` — clean. Text-only label
  change; behavior unchanged.

## Type of change

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

## Test coverage

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

## Coverage notes

Copy-only change to a setup-page label; no behavior change, nothing to test
beyond prettier formatting.
2026-06-25 19:25:50 -07:00
Zeyi (Rice) Fan dfcde6c57a fix(electron): show Local Server Status only for the connected local server
## Related issue

N/A

## Summary

- The "Local Server Status" sidebar row now appears only when the window is
  connected to a local server that the omnigent CLI actually manages at that
  same loopback port — not for any loopback URL. `serverStatusFor` returns null
  unless the CLI's running local-server URL is the same loopback host:port as
  the window's server (so it never shows a misleading status for an unrelated /
  background local server, and stays hidden for remote servers as before).
- Add `sameLoopbackServer(a, b)` to omnigent_cli (loopback host + same port;
  `localhost` and `127.0.0.1` treated as equal) with unit tests.

## Test Plan

- `cd ap-web/electron && npm test` — 41 tests pass (incl. new sameLoopbackServer
  cases).
- Probed against the real binary: remote, and loopback URLs with no matching
  running local server, all resolve to null (row hidden); a running local server
  at the connected port resolves to its status (row shown).

## Type of change

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

## Test coverage

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

## Coverage notes

Unit tests cover sameLoopbackServer; the gating was probed against the real
omnigent binary (remote and non-matching loopback → null). Full visual
confirmation against a running local server is pending a working local DB on the
test machine (out-of-date schema blocks `omnigent server start`).
2026-06-25 18:06:57 -07:00
Zeyi (Rice) Fan 013fdbec07 feat(ap-web): show only state-relevant actions in the host/server menus
## Related issue

N/A

## Summary

- The Host Status and Local Server Status menus now hide irrelevant actions
  instead of disabling them: **Start** shows only when off/stopped; **Stop** and
  **Restart** show only when running/connected. When the omnigent CLI is
  missing, no actions (and no separator) render — just the status line.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean on the changed
  component.

## Type of change

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

## Coverage notes

Verified via tsc, oxlint, and prettier. Visual confirmation of the live menus
against a running local server is pending a working local DB on the test machine
(out-of-date schema blocks `omnigent server start`; unrelated to this change).
2026-06-25 17:50:51 -07:00
Zeyi (Rice) Fan a8639eb9c5 feat: sidebar Host/Local Server status menus with start/stop/restart
## Related issue

N/A

## Summary

Reworks the sidebar host indicator into action menus per design feedback ("Host:
off" felt off):

- The sidebar footer (next to Settings) now shows a **Host Status** row — label
  + status dot — that opens a **Start / Stop / Restart** menu for this machine's
  host daemon. Items enable by current state (no Start when already running,
  etc.) and disable while an action runs or the CLI is missing.
- For a **local** server, a parallel **Local Server Status** row with the same
  Start / Stop / Restart menu controls the local server itself.
- Bridge: re-add control via `window.omnigentDesktop.controlHost(action)` /
  `controlServer(action)` (`start|stop|restart`), gated to the pinned origin;
  keep the live read path (`getHostStatus` / `getServerStatus` /
  `onHostStatusChanged`). Typed in nativeBridge.ts.
- server_manager: add `restartHost` (stop-then-connect; stop now awaits the
  child's exit so restart spawns fresh rather than adopting the dying daemon),
  `stopLocalServer` (unconditional, for the explicit Stop) and
  `restartLocalServer`. The connect-time host toggle on the setup page stays.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); oxlint + prettier clean on all changed
  TS / JS / md.
- `cd ap-web/electron && npm test` — 38 tests pass; `node --check` clean on
  main.js / preload.js / server_manager.js.

## Type of change

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

## Coverage notes

Verified via tsc, oxlint, prettier, and the electron unit suite. The host/server
bridge was exercised against the real omnigent binary earlier; the live
start/stop/restart flow against a running local server is pending a working
local DB on the test machine (its schema is out of date and blocks `omnigent
server start`, an environment issue unrelated to this change).
2026-06-25 17:32:49 -07:00
Zeyi (Rice) Fan 07b15efd5c feat: host toggle on the connect screen + read-only status in the sidebar
## Related issue

N/A

## Summary

Reworks the host UX per design feedback: the host control is no longer in the
title-bar menu. Hosting is now a connect-time choice, and the connected app
shows host status in the sidebar.

- Setup page: add a "Host this machine on connect" toggle next to Connect
  (disabled until the omnigent CLI resolves; last state remembered as
  `host_on_connect`). It applies to both Connect and Start-locally.
- main.js: `set-server-url` accepts `{ host }` and, once the server actually
  responds, registers this machine as a host for it (adopt-or-spawn).
  Best-effort — failures surface in the sidebar indicator, not as a connect
  error. Add `get-host-on-connect`. The SPA now only READS status; the in-app
  control endpoints (`host-set-enabled`, `server-set-running`) are removed.
- ap-web: revert TitleBarServerPicker to the plain server switcher; add a
  read-only `HostStatusIndicator` in the sidebar footer next to Settings (dot +
  label + tooltip), live via `getHostStatus`/`onHostStatusChanged`. Trim the
  now-unused control wrappers from nativeBridge.ts.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0); `npx oxlint` + `npx prettier --check` on
  all changed TS / HTML / JS / md — clean.
- `cd ap-web/electron && npm test` — 38 tests pass; `node --check` on main.js /
  preload.js passes.

## Type of change

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

## Coverage notes

Verified via tsc, oxlint, prettier, and the electron unit suite. The underlying
host/server bridge was exercised against the real omnigent binary earlier; the
end-to-end visual flow (connect-time toggle → sidebar indicator) is pending a
working local server on the test machine (its DB schema is out of date and
blocks `omnigent server start`, an environment issue unrelated to this change).
2026-06-25 17:17:46 -07:00
Zeyi (Rice) Fan 9009010da7 feat(ap-web): host status + connect toggle in the desktop server picker
## Related issue

N/A

## Summary

- Surface desktop host management in the macOS title-bar server picker
  (TitleBarServerPicker), the existing in-window server surface:
  - A live connection dot on the trigger — green when this machine is connected
    as a host, amber while connecting/degraded, muted when off or the CLI is
    missing.
  - A "Host on this machine" toggle in the dropdown (Switch) that calls
    `setHostEnabled`; disabled when the omnigent CLI isn't found. Hosting runs
    agent work the server dispatches, so it stays an explicit opt-in.
  - A status sub-line (connected · N sessions / connecting / not hosting / CLI
    not found) and, for loopback servers, a local-server status line.
- Reads via `getHostStatus`/`getLocalServerStatus` on mount and stays live by
  subscribing to `onHostStatusChanged` (the shell pushes on a timer and right
  after a toggle), matching the existing nativeBridge subscription pattern.

## Test Plan

- `cd ap-web && npx tsc -b` (exit 0), `npx oxlint`, `npx prettier --check` on
  the component — all clean.
- Placement: the picker is the macOS-only title-bar surface; Windows/Linux host
  control via this in-window UI is a follow-up (those platforms keep the native
  Server menu + setup page today).

## Type of change

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

## Coverage notes

Verified via TypeScript typecheck, oxlint, and prettier; the component follows
the file's established (untested) helper convention and the codebase's
nativeBridge subscription pattern. The end-to-end visual flow (dot + toggle
against a live local host) is pending a working local server on the test machine
— the local DB schema is out of date and blocks `omnigent server start`, an
environment issue unrelated to this change. The underlying bridge was verified
against the real omnigent binary.
2026-06-25 16:11:07 -07:00
Zeyi (Rice) Fan 3e6f3dde64 feat(electron): typed host/server bridge wrappers + README
## Related issue

N/A

## Summary

- nativeBridge.ts: add type-safe wrappers (`getHostStatus`, `setHostEnabled`,
  `getLocalServerStatus`, `setLocalServerRunning`, `onHostStatusChanged`) over
  the desktop shell's new `window.omnigentDesktop` server-management methods,
  plus the `HostStatus` / `LocalServerStatus` / `HostActionResult` types. They
  follow the file's existing optional-method pattern: feature-detected, never
  throwing, and a no-op / null off-shell so the browser build is unaffected.
- electron README: document CLI detection, "Start locally", in-app host status
  via the bridge, the server-vs-host distinction, and the quit lifecycle.

## Test Plan

- `cd ap-web && npx tsc -b` — typecheck passes (exit 0).
- `npx oxlint src/lib/nativeBridge.ts` and `npx prettier --check` — clean.

## Type of change

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

## Coverage notes

Web-side change is the typed bridge layer plus docs; verified via TypeScript
typecheck, oxlint, and prettier. The wrappers are exercised against the live
preload once an SPA component consumes them (the visible in-app indicator is a
follow-up).
2026-06-25 16:04:37 -07:00
Zeyi (Rice) Fan 066f87ec37 feat(electron): setup page — CLI detection, path config, start locally
## Related issue

N/A

## Summary

- Add a "Start a server on this machine" button to the setup page that calls
  the new `startLocalServer` bridge and, on success, hands the resulting URL to
  the existing setServerUrl navigation flow (reusing normalize/expand/recents).
- Detect the `omnigent` CLI on load via `getCliStatus`. When it's missing, show
  the install one-liner (with a permission-free copy button) and a path-config
  row (text input + native Browse picker) that validates and persists the path
  through `setCliPath`; the Start-locally button stays disabled until the CLI
  resolves. Remote Connect never depends on the CLI.
- All dynamic text uses textContent (no innerHTML), matching the page's
  existing security convention; the whole local section hides gracefully on an
  older shell without the CLI bridge.

## Test Plan

- Verified the underlying bridge against the real `omnigent` binary with a Node
  harness: `getCliStatus` reports installed/path/version; `server status` and
  `host status` parse to the normalized status shapes; a failed `server start`
  surfaces its error cleanly through the structured result. No stray processes
  left behind.
- `cd ap-web/electron && npm test` — 38 tests pass.

## Type of change

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

## Coverage notes

Verified the server-management bridge end-to-end against the real `omnigent`
0.1.0 binary (CLI detection, server/host status parsing, error propagation on a
failed local-server start) via a Node harness. A full Electron GUI smoke test of
the setup page (clicking Start-locally to a healthy local server) is pending a
working local DB on the test machine — the local `~/.omnigent/chat.db` currently
has an out-of-date schema that blocks `omnigent server start`, which is an
environment issue unrelated to this change.
2026-06-25 16:01:59 -07:00
Zeyi (Rice) Fan 360db7c317 feat(electron): wire server-management IPC, bridge, and quit teardown
## Related issue

N/A

## Summary

- main.js: add CLI-detection and server-management IPC handlers. Setup-page
  gated: `get-cli-status`, `set-cli-path` (persists only a path that validates
  as configured), `browse-cli-path` (native picker), `start-local-server`.
  Pinned-origin gated (act only on the sender window's own server):
  `host-get-status`, `host-set-enabled`, `server-get-status`,
  `server-set-running`.
- Track the full connected `serverUrl` per window (the pinned origin drops any
  path, but host/server CLI commands need the exact URL, e.g. a Databricks
  `…/ml/omnigents` mount); set it on connect/switch and clear it on return to
  setup.
- Add a deduplicated host-status poller that pushes `host-status-changed` to
  pinned windows, and emit immediately after a toggle so the in-app indicator
  stays live.
- Add a `before-quit` handler that tears down host children the app spawned and
  stops an owned local server (the confirmed quit lifecycle).
- preload.js: expose `getHostStatus/setHostEnabled/getServerStatus/
  setServerRunning/onHostStatusChanged` on `omnigentDesktop` and
  `getCliStatus/setCliPath/browseCliPath/startLocalServer` on `omnigentSetup`.

## Test Plan

- `node --check` on all four changed/added src files passes.
- `cd ap-web/electron && npm test` — 38 tests pass.
- Manual end-to-end (launching the app, toggling host, start-locally) is the
  next checkpoint once the setup-page UI consumes these handlers.

## Type of change

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

## Test coverage

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

## Coverage notes

N/A
2026-06-25 15:58:46 -07:00
Zeyi (Rice) Fan df12913b14 feat(electron): CLI discovery + server/host process lifecycle modules
## Related issue

N/A

## Summary

- Add `ap-web/electron/src/omnigent_cli.js`: locates the `omnigent` binary
  (configured path → PATH → well-known install locations, since a GUI-launched
  app inherits a minimal PATH), runs the short `server`/`host` status commands,
  and parses their `--json` output. Includes pure helpers (`matchesServer`,
  `connectionFromStatus`, `normalizeServerUrl`) that answer "is this machine
  connected to server X?" robustly across trailing-slash/path differences.
- Add `ap-web/electron/src/server_manager.js`: owns the lifecycle of
  desktop-started `omnigent host` children and an optionally-owned local
  `omnigent server`. Adopts a pre-existing daemon rather than spawning a
  duplicate, tears down only what the app started (the confirmed quit
  behavior), and never caches status (the CLI is the source of truth).
- These are pure logic + lifecycle only; no main.js/preload wiring yet.

## Test Plan

- `cd ap-web/electron && npm test` — 38 tests pass, including the new
  `test/omnigent_cli.test.js` suites covering path-resolution order,
  server-URL matching, status parsing, and loopback detection.

## Type of change

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

## Test coverage

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

## Coverage notes

N/A
2026-06-25 15:55:10 -07:00
11 changed files with 2765 additions and 39 deletions
+64
View File
@@ -280,6 +280,70 @@ server from this repo:
Then enter `http://localhost:8000` in the setup page.
## Managing servers and hosting
Beyond pointing at an already-running server, the shell can drive the local
`omnigent` CLI to start a server and register this machine as a **host** (a
machine that runs the agent work a server dispatches). Two concepts stay
deliberately separate:
- **Server** — the backend the webview talks to (local or remote).
- **Host** — _this machine_ executing agent work for a server. Because hosting
runs agent code, it is **opt-in** and **explicit**: the shell never connects
this machine as a runner on its own — not on connect, not on launch. You
connect it from the **host selection menu** inside the app (when starting a
chat, pick this machine), which drives `controlHost` over the bridge.
### Detecting the CLI (setup page)
On the setup page the shell probes for the `omnigent` binary —
`settings.omnigent_path` first, then `PATH`, then the well-known install
locations (`~/.local/bin`, `~/.cargo/bin`, Homebrew, `/usr/local/bin`). A
GUI-launched app inherits a minimal `PATH`, which is why the install locations
are probed directly. When the CLI isn't found, the page shows the install
one-liner
```bash
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
```
and a field to point the app at the binary (typed or via a native file picker).
A configured path is saved to `settings.json` (`omnigent_path`) only once it
validates as a runnable `omnigent`. Connecting to a **remote** server never
needs the CLI — only "Start locally" and hosting do.
### Start locally
**"Start a server on this machine"** runs `omnigent server start` (idempotent —
reuses a healthy one) and then connects this window to its
`http://127.0.0.1:<port>` URL through the normal connect flow. It does not
connect this machine as a runner — that stays an explicit step in the app.
### Connecting this machine as a runner
There is **no** connect-time toggle and no sidebar status row: the shell never
connects a runner automatically. Inside the connected app, the host selection
menu (when starting a chat) tags this machine and offers to connect it. Choosing
it calls `controlHost("start")` over the bridge, which — once the CLI is
authenticated for the server (remote only; local needs none) — either adopts a
daemon already serving that server (one you started by hand) or spawns
`omnigent host --server <url>`. The same bridge exposes `stop` / `restart`.
Status is read live (host connected = a live daemon process **and** an online
host tunnel; the shell never caches it). The host surface goes through the JS
bridge — `window.omnigentDesktop` → `getHostStatus` / `getHostIdentity` /
`onHostStatusChanged` (read + live) and `controlHost` (start/stop/restart),
typed in [`../src/lib/nativeBridge.ts`](../src/lib/nativeBridge.ts) and gated to
the window's **pinned origin** like the badge/notification bridge.
### Lifecycle
The desktop **owns the host processes it starts**: quitting the app SIGTERMs
them (and stops a local server it started), so closing the app disconnects this
machine. A daemon the shell merely _adopted_ (you started it in a terminal) is
left running on quit. Hosting is **not** restored on the next launch — you
reconnect this machine explicitly from the host menu when you want it.
## Passkeys (WebAuthn)
External security keys (e.g. a YubiKey) work out of the box: Chromium's
+3 -2
View File
@@ -7,6 +7,9 @@
"": {
"name": "omnigent-desktop-electron",
"version": "0.1.1",
"dependencies": {
"js-yaml": "^4.2.0"
},
"devDependencies": {
"electron": "^42.3.2",
"electron-builder": "^26.0.0"
@@ -793,7 +796,6 @@
"version": "2.0.1",
"resolved": "https://npm-proxy.cloud.databricks.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/asn1js": {
@@ -2295,7 +2297,6 @@
"version": "4.2.0",
"resolved": "https://npm-proxy.cloud.databricks.com/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
+3
View File
@@ -96,5 +96,8 @@
"nsis"
]
}
},
"dependencies": {
"js-yaml": "^4.2.0"
}
}
+254 -8
View File
@@ -98,16 +98,18 @@
opacity: 0.5;
cursor: default;
}
/* Connect (to a remote server) is the secondary path, below the prominent
"Start locally" action that leads the card. */
#connect {
margin-top: 16px;
padding: 9px 12px;
font-weight: 500;
border: none;
background: var(--primary);
color: var(--primary-foreground);
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
}
#connect:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 90%, transparent);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.recents {
margin-top: 24px;
@@ -139,6 +141,94 @@
line-height: 1.4;
min-height: 18px;
}
/* Separator between the prominent "Start locally" action and the
connect-to-a-server form below it. */
.divider {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0;
color: var(--muted-foreground);
font-size: 12px;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
/* "Start locally" — the prominent primary action (filled), leading the
card above the connect form. */
#start-local {
margin-top: 0;
padding: 10px 12px;
font-weight: 600;
border: none;
background: var(--primary);
color: var(--primary-foreground);
}
#start-local:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 90%, transparent);
}
.cli-panel {
margin-top: 16px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
font-size: 13px;
line-height: 1.45;
}
.cli-panel .title {
font-weight: 500;
margin: 0 0 6px;
}
.cli-panel p {
margin: 6px 0;
}
.cli-cmd {
display: flex;
gap: 8px;
align-items: stretch;
margin: 8px 0;
}
.cli-cmd code {
flex: 1;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border-radius: 6px;
overflow-x: auto;
white-space: nowrap;
}
.mini-btn,
.path-row button {
width: auto;
flex: none;
padding: 6px 12px;
font-size: 12px;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
}
.path-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.path-row input {
flex: 1;
}
.cli-note {
margin-top: 6px;
font-size: 12px;
color: var(--muted-foreground);
min-height: 16px;
}
.cli-note.bad {
color: var(--destructive);
}
/* With the native title bar hidden (titleBarStyle "hiddenInset" on
macOS), this strip is the window's only drag surface on the setup
page. Harmless elsewhere. */
@@ -162,9 +252,36 @@
/>
<img class="logo" src="../../platform-assets/logos/omnigents-logo.svg" alt="Omnigents" />
</picture>
<p class="sub">
Enter the URL of the Omnigents server. The desktop app loads its web UI directly.
</p>
<p class="sub">Run an Omnigents server on this machine, or connect to an existing one.</p>
<div id="local-section">
<button id="start-local" disabled>Start locally</button>
<div class="cli-panel" id="cli-panel" hidden>
<p class="title" id="cli-title"></p>
<div id="cli-install" hidden>
<p>Install the Omnigent CLI to run a server locally:</p>
<div class="cli-cmd">
<code id="cli-cmd-text"></code>
<button type="button" class="mini-btn" id="cli-copy">Copy</button>
</div>
<p>Already installed? Point the app at the binary:</p>
<div class="path-row">
<input
id="cli-path"
type="text"
placeholder="/path/to/omnigent"
autocomplete="off"
spellcheck="false"
/>
<button type="button" id="cli-browse">Browse…</button>
</div>
<p class="cli-note" id="cli-path-note"></p>
</div>
</div>
<div class="divider">or connect to a server</div>
</div>
<label for="url">Server URL</label>
<input
id="url"
@@ -175,6 +292,7 @@
/>
<button id="connect">Connect</button>
<div class="err" id="err"></div>
<div class="recents" id="recents" hidden>
<p class="recents-title">Recent servers</p>
<div id="recents-list"></div>
@@ -273,7 +391,8 @@
button.disabled = true;
try {
// setServerUrl persists the URL and navigates this window to it —
// after which the server's SPA takes over the window.
// after which the server's SPA takes over the window. Connecting this
// machine as a runner is done later from the host menu, not here.
await setup.setServerUrl(value);
} catch (e) {
err.textContent = String(e && e.message ? e.message : e);
@@ -285,6 +404,133 @@
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") connect();
});
// --- Start locally + omnigent CLI detection ---------------------------
// "Start locally" runs `omnigent server` via the main process, then
// connects this window to it through the normal flow. It
// needs the local omnigent CLI, so we detect the CLI on load and, when
// it's missing, show install instructions plus a way to point at the
// binary. Remote Connect never depends on the CLI.
const startLocalBtn = document.getElementById("start-local");
const localSection = document.getElementById("local-section");
const cliPanel = document.getElementById("cli-panel");
const cliTitle = document.getElementById("cli-title");
const cliInstall = document.getElementById("cli-install");
const cliCmdText = document.getElementById("cli-cmd-text");
const cliCopy = document.getElementById("cli-copy");
const cliPathInput = document.getElementById("cli-path");
const cliBrowse = document.getElementById("cli-browse");
const cliPathNote = document.getElementById("cli-path-note");
// Copy without a clipboard permission: a transient off-screen textarea +
// execCommand works on a user gesture in the file:// setup page.
function copyText(text) {
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
return true;
} catch {
return false;
}
}
async function refreshCliStatus() {
let status;
try {
status = await setup.getCliStatus();
} catch {
return;
}
// textContent, never innerHTML: the command and version come from the
// main process and are rendered as inert text.
cliCmdText.textContent = status.installCommand || "";
const installed = Boolean(status.installed);
// Starting a local server needs the CLI; gate the button on it.
startLocalBtn.disabled = !installed;
if (installed) {
cliPanel.hidden = true;
} else {
cliPanel.hidden = false;
cliInstall.hidden = false;
cliTitle.textContent = "Omnigent CLI not found";
}
}
async function applyCliPath(value) {
const p = (value || "").trim();
if (p === "") return;
cliPathNote.className = "cli-note";
cliPathNote.textContent = "Checking…";
let result;
try {
result = await setup.setCliPath(p);
} catch {
result = { accepted: false };
}
if (result && result.accepted) {
cliPathNote.textContent = result.version ? `Found: ${result.version}` : "Found omnigent.";
await refreshCliStatus();
} else {
cliPathNote.className = "cli-note bad";
cliPathNote.textContent = "That path is not a runnable omnigent binary.";
}
}
if (setup.getCliStatus) {
cliCopy.addEventListener("click", () => {
if (copyText(cliCmdText.textContent)) {
cliCopy.textContent = "Copied";
setTimeout(() => {
cliCopy.textContent = "Copy";
}, 1500);
}
});
cliBrowse.addEventListener("click", async () => {
const picked = await setup.browseCliPath();
if (picked) {
cliPathInput.value = picked;
await applyCliPath(picked);
}
});
cliPathInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") applyCliPath(cliPathInput.value);
});
startLocalBtn.addEventListener("click", async () => {
err.textContent = "";
const prev = startLocalBtn.textContent;
startLocalBtn.disabled = true;
startLocalBtn.textContent = "Starting…";
try {
const result = await setup.startLocalServer();
if (result && result.ok && result.url) {
// Hand off to the normal connect/navigate flow; on success the
// window loads the server and this page goes away. Connecting this
// machine as a runner is done later from the host menu.
input.value = result.url;
await setup.setServerUrl(result.url);
return;
}
err.textContent = (result && result.error) || "Could not start the local server.";
} catch (e) {
err.textContent = String(e && e.message ? e.message : e);
}
startLocalBtn.textContent = prev;
startLocalBtn.disabled = false;
});
// Resolve CLI status (enables/disables Start-locally).
refreshCliStatus();
} else {
// Older shell without the CLI bridge: hide the whole local section.
localSection.hidden = true;
}
input.focus();
</script>
</body>
+311 -5
View File
@@ -32,6 +32,8 @@ const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { registerLocalhostCors } = require("./localhost_cors");
const { normalizeUrl, expandDatabricksWorkspaceUrl, WORKSPACE_UI_PATH } = require("./url");
const omnigentCli = require("./omnigent_cli");
const serverManager = require("./server_manager");
/** Absolute path to the bundled setup page (the "connect to server" form). */
const SETUP_PAGE = path.join(__dirname, "..", "setup", "index.html");
@@ -536,6 +538,78 @@ function pinWindow(win, origin) {
state.origin = origin;
}
/**
* Record (or clear) the full server URL a window is connected to. The pinned
* `origin` drops any path, but the host/server CLI commands need the exact URL
* the user connected with (e.g. a Databricks ``…/ml/omnigents`` mount), so the
* window keeps both.
*
* @param {BrowserWindow} win
* @param {string | null} serverUrl
*/
function setWindowServerUrl(win, serverUrl) {
const state = windows.get(win);
if (state) state.serverUrl = serverUrl;
}
/**
* The full server URL of the window that sent an IPC event, or null. Used by
* the host/server-management handlers to scope CLI commands to the window's
* own server.
*
* @param {Electron.IpcMainInvokeEvent | Electron.IpcMainEvent} event
* @returns {string | null}
*/
function senderServerUrl(event) {
const win = BrowserWindow.fromWebContents(event.sender);
return (win && windows.get(win)?.serverUrl) || null;
}
/**
* Re-point the sender's window to a local server that moved to a new URL (the
* CLI's `server start` can't be pinned to a port, so a restart may land on a
* different one). No-op when the origin is unchanged. Persists the new default
* and migrates any hosting intent + the live host connection to the new URL so
* a connected runner follows the server too.
*
* @param {Electron.IpcMainInvokeEvent} event
* @param {string} oldServerUrl The URL the window was on.
* @param {string} newUrl The (re)started server's URL.
*/
function followLocalServerMove(event, oldServerUrl, newUrl) {
const newOrigin = originOf(newUrl);
if (!newOrigin || originOf(oldServerUrl) === newOrigin) return; // didn't move
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed()) return;
const ephemeral = Boolean(windows.get(win)?.ephemeral);
if (!ephemeral) {
const settings = loadSettings();
settings.server_url = newUrl;
saveSettings(settings);
}
pinWindow(win, newOrigin);
setWindowServerUrl(win, newUrl);
void win.loadURL(newUrl);
}
/**
* Notify every pinned window that host/server status may have changed, so the
* SPA re-reads it. This is a bare ping — NOT a poll: it fires only on real
* events (a host child connecting or exiting, and after a control action), so
* there is no periodic querying of the server. The renderer reads the actual
* status on demand via the get-status handlers.
*/
function broadcastHostStatus() {
for (const [win, state] of windows) {
if (win.isDestroyed() || !state.origin || !state.serverUrl) continue;
try {
win.webContents.send("omnigent:host-status-changed");
} catch {
// Window torn down between the check and the send; ignore.
}
}
}
/**
* The window an OS-menu / app-level action should target: the currently
* focused shell window, falling back to any open one (or null when none).
@@ -574,6 +648,37 @@ function saveSettings(settings) {
fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), "utf8");
}
/**
* Resolve the `omnigent` CLI binary path from the user's configured override
* (``settings.omnigent_path``) plus the standard locations, or null when none
* is usable. Re-resolved on each call so a freshly-configured path takes
* effect without a restart.
*
* @returns {string | null}
*/
/**
* Cached CLI resolution: { configuredPath, path }. Resolving runs `command -v`
* (a subprocess), so we memoize the found path and only re-probe when the
* configured override changes or the cached binary is no longer executable —
* avoiding a shell-out on every status/control call.
*/
let cachedCli = null;
function resolvedCliPath() {
const configured = loadSettings().omnigent_path ?? null;
if (
cachedCli &&
cachedCli.configuredPath === configured &&
cachedCli.path &&
omnigentCli.isExecutableFile(cachedCli.path)
) {
return cachedCli.path;
}
const resolved = omnigentCli.resolveCliPath(configured);
cachedCli = { configuredPath: configured, path: resolved ? resolved.path : null };
return cachedCli.path;
}
/** Maximum number of entries kept in the persisted recent-servers list. */
const MAX_RECENT_SERVERS = 5;
@@ -791,6 +896,8 @@ function createWindow(targetUrl, opts = {}) {
// Pin to the destination's origin up front; setup-page windows stay
// unpinned (null) until the user connects them.
origin: destinationOrigin,
// Full connected URL (incl. any path) for host/server CLI commands.
serverUrl: destination,
ephemeral,
badgeCount: 0,
});
@@ -862,15 +969,18 @@ function createWindow(targetUrl, opts = {}) {
// is a fresh document); the SPA's own client-side routing keeps the same
// document, so the injected stylesheet persists across in-app navigation.
win.webContents.on("did-finish-load", () => {
const urlStr = win.webContents.getURL();
let pathname = "";
try {
pathname = new URL(win.webContents.getURL()).pathname;
pathname = new URL(urlStr).pathname;
} catch {
return;
}
if (pathname.startsWith(WORKSPACE_UI_PATH)) {
void win.webContents.insertCSS(WORKSPACE_CHROME_HIDE_CSS);
}
// Note: the desktop never auto-connects this machine as a runner — on
// launch or on connect. Connecting is an explicit action from the host menu.
});
win.on("closed", () => {
@@ -1398,16 +1508,20 @@ function registerIpc() {
// The user explicitly chose this server — it becomes the window's
// trusted origin for privileged IPC and permission grants.
pinWindow(win, new URL(target).origin);
setWindowServerUrl(win, target);
win
.loadURL(target)
.then(() => {
// Only a server that actually responded earns a recents slot —
// a typo'd or unreachable URL must not show up in the
// quick-pick list on the setup page.
if (ephemeral) return;
const settings = loadSettings();
rememberRecentServer(settings, target);
saveSettings(settings);
if (!ephemeral) {
const settings = loadSettings();
rememberRecentServer(settings, target);
saveSettings(settings);
}
// The desktop does NOT auto-connect this machine as a runner on
// connect — that's an explicit action from the host menu.
})
.catch(() => {
// Load failure is handled by the did-fail-load fallback (setup
@@ -1476,6 +1590,7 @@ function registerIpc() {
}
if (win) {
pinWindow(win, new URL(url).origin);
setWindowServerUrl(win, url);
win
.loadURL(url)
.then(() => {
@@ -1503,6 +1618,7 @@ function registerIpc() {
if (!win) return;
const ephemeral = windows.get(win)?.ephemeral === true;
pinWindow(win, null); // back on the setup page → no trusted origin
setWindowServerUrl(win, null);
void win.loadFile(SETUP_PAGE, ephemeral ? { search: "ephemeral=1" } : undefined);
});
@@ -1611,6 +1727,179 @@ function registerIpc() {
signalForeground();
return true;
});
// -------------------------------------------------------------------------
// Server management — CLI detection, local server, and host connection.
//
// Setup-page handlers (CLI detection, path config, start-locally, the
// connect-time host choice) gate on isSetupPageSender. Hosting is decided at
// connect time; the SPA only READS status (gated on isPinnedOriginSender) for
// the sidebar host indicator — it has no in-app control endpoints.
// -------------------------------------------------------------------------
// Setup page → is the `omnigent` CLI installed and runnable? Includes the
// resolved path, version, and the install one-liner to show when missing.
ipcMain.handle("omnigent:get-cli-status", async (event) => {
if (!isSetupPageSender(event)) {
throw new Error("get-cli-status is only available to the setup page");
}
return omnigentCli.getCliStatus(loadSettings().omnigent_path);
});
// Setup page → set an explicit path to the `omnigent` binary. Persisted only
// when that exact path validates as a runnable omnigent (so a typo doesn't
// silently mask a working PATH lookup). Returns the resulting CLI status plus
// whether the configured path was accepted.
ipcMain.handle("omnigent:set-cli-path", async (event, configuredPath) => {
if (!isSetupPageSender(event)) {
throw new Error("set-cli-path is only available to the setup page");
}
const trimmed = String(configuredPath ?? "").trim();
const status = await omnigentCli.getCliStatus(trimmed || null);
const accepted = status.installed && status.source === "configured";
if (accepted) {
const settings = loadSettings();
settings.omnigent_path = trimmed;
saveSettings(settings);
} else if (trimmed === "") {
// Empty input clears any saved override (revert to PATH/candidates).
const settings = loadSettings();
delete settings.omnigent_path;
saveSettings(settings);
}
return { ...status, accepted };
});
// Setup page → native file picker for the omnigent binary. Returns the chosen
// path (the renderer feeds it back through set-cli-path) or null on cancel.
ipcMain.handle("omnigent:browse-cli-path", async (event) => {
if (!isSetupPageSender(event)) {
throw new Error("browse-cli-path is only available to the setup page");
}
const win = BrowserWindow.fromWebContents(event.sender) ?? activeWindow();
const result = await dialog.showOpenDialog(win ?? undefined, {
title: "Locate the omnigent binary",
properties: ["openFile"],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// Setup page → start (or reuse) the local server. Returns its URL so the
// setup page can hand off to the normal setServerUrl navigation flow.
ipcMain.handle("omnigent:start-local-server", async (event) => {
if (!isSetupPageSender(event)) {
throw new Error("start-local-server is only available to the setup page");
}
const cliPath = resolvedCliPath();
if (!cliPath) {
return { ok: false, error: "The omnigent CLI was not found. Install it or set its path." };
}
return serverManager.startLocalServer(cliPath);
});
// SPA → this machine's identity: is the CLI installed, and its host id. Both
// come from local config (no `omnigent host status` subprocess), so this is
// instant — it lets the new-session picker tag/connect "this machine" without
// waiting on the slow runner-status check.
ipcMain.handle("omnigent:host-get-identity", (event) => {
if (!isPinnedOriginSender(event)) {
console.warn("[omnigent] host-get-identity from untrusted sender dropped");
return null;
}
return { cliInstalled: Boolean(resolvedCliPath()), hostId: omnigentCli.localHostId() };
});
// SPA → this machine's host-connection status for the window's own server
// (read-only; drives the sidebar host indicator).
ipcMain.handle("omnigent:host-get-status", async (event) => {
if (!isPinnedOriginSender(event)) {
console.warn("[omnigent] host-get-status from untrusted sender dropped");
return null;
}
const serverUrl = senderServerUrl(event);
if (!serverUrl) return null;
return serverManager.statusFor(resolvedCliPath(), serverUrl);
});
// SPA → local-server status (loopback servers only; null otherwise).
ipcMain.handle("omnigent:server-get-status", async (event) => {
if (!isPinnedOriginSender(event)) {
console.warn("[omnigent] server-get-status from untrusted sender dropped");
return null;
}
const serverUrl = senderServerUrl(event);
if (!serverUrl) return null;
return serverManager.serverStatusFor(resolvedCliPath(), serverUrl);
});
// SPA → start / stop / restart this machine's host daemon for the window's
// own server (the host selection menu's "connect this machine" action).
ipcMain.handle("omnigent:host-control", async (event, action) => {
if (!isPinnedOriginSender(event)) {
throw new Error("host-control is only available to a connected server page");
}
const serverUrl = senderServerUrl(event);
if (!serverUrl) return { ok: false, error: "this window is not connected to a server" };
const cliPath = resolvedCliPath();
if (!cliPath) {
return { ok: false, error: "The omnigent CLI was not found. Install it or set its path." };
}
let result;
if (action === "start" || action === "restart") {
// Ensure the CLI is authenticated for a remote server first (local needs
// none) — otherwise the host connect would just fail on a 401.
const auth = await serverManager.ensureServerAuth(cliPath, serverUrl);
if (!auth.ok) result = { ok: false, error: auth.error };
else if (action === "start")
result = await serverManager.ensureHostConnected(cliPath, serverUrl);
else result = await serverManager.restartHost(cliPath, serverUrl);
} else if (action === "stop") {
result = await serverManager.disconnectHost(cliPath, serverUrl);
} else {
result = { ok: false, error: `unknown host action '${action}'` };
}
broadcastHostStatus();
return result;
});
// SPA → start / stop / restart the local server (loopback servers only).
ipcMain.handle("omnigent:server-control", async (event, action) => {
if (!isPinnedOriginSender(event)) {
throw new Error("server-control is only available to a connected server page");
}
const serverUrl = senderServerUrl(event);
if (!serverUrl || !omnigentCli.isLoopbackServer(serverUrl)) {
return { ok: false, error: "the local-server control applies to loopback servers only" };
}
const cliPath = resolvedCliPath();
if (!cliPath) {
return { ok: false, error: "The omnigent CLI was not found. Install it or set its path." };
}
let result;
if (action === "start") result = await serverManager.startLocalServer(cliPath);
else if (action === "stop") result = await serverManager.stopLocalServer(cliPath);
else if (action === "restart") result = await serverManager.restartLocalServer(cliPath);
else result = { ok: false, error: `unknown server action '${action}'` };
// The CLI's `server start` can land the (re)started server on a different
// port (it prefers 6767, else a free port — there's no way to pin it). If
// it moved, follow it: re-point this window to the new URL so it isn't left
// on a dead port.
if (
(action === "restart" || action === "start") &&
result.ok &&
typeof result.url === "string"
) {
followLocalServerMove(event, serverUrl, result.url);
}
broadcastHostStatus();
return result;
});
// Push a status ping when a host child connects or exits on its own (no
// polling) — the server-management module owns the subprocess and reports
// lifecycle changes here.
serverManager.onChange(broadcastHostStatus);
}
// ---------------------------------------------------------------------------
@@ -1654,4 +1943,21 @@ if (!gotLock) {
// macOS apps typically stay alive until Cmd-Q.
if (process.platform !== "darwin") app.quit();
});
// Tear down what this app started: SIGTERM any host children it spawned and
// stop a local server it owns. The desktop owns its host connections (the
// confirmed lifecycle), so quitting disconnects this machine. We defer the
// quit until cleanup finishes, then re-issue it.
let quitCleanupDone = false;
app.on("before-quit", (event) => {
if (quitCleanupDone) return;
event.preventDefault();
serverManager
.shutdown(resolvedCliPath())
.catch(() => {})
.finally(() => {
quitCleanupDone = true;
app.quit();
});
});
}
+896
View File
@@ -0,0 +1,896 @@
// Discovery and invocation of the local `omnigent` CLI for the desktop shell.
//
// The desktop manages servers by shelling out to the same `omnigent` binary a
// user would run by hand — `server start|stop|status` and `host status` (the
// long-lived `host` connection is spawned by server_manager.js, which owns its
// lifetime). This module locates the binary, runs the short exit-quick
// commands, and parses their `--json` output. The CLE is the single source of
// truth for live state; nothing here is persisted.
//
// Unlike src/url.js this is main-process only (it needs child_process / fs),
// so it's a plain CommonJS module — never loaded in the renderer.
//
// The pure helpers (matchesServer, connectionFromStatus, normalizeServerUrl,
// candidatePaths, resolveCliPath with injected probes) are unit-tested in
// test/omnigent_cli.test.js; the functions that actually spawn a binary are
// exercised in the manual verification flow.
"use strict";
const { execFile, execFileSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const yaml = require("js-yaml");
const url = require("./url");
/** Default timeout for the short status commands. */
const DEFAULT_TIMEOUT_MS = 10000;
/**
* One-liner shown on the setup page when the CLI is missing. Mirrors the
* install instructions in the repo root README.
*/
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh";
/**
* Strip a trailing slash so URL comparisons survive the difference between
* what the user typed and what the CLI records in a daemon target.
*
* @param {unknown} value
* @returns {string}
*/
function normalizeServerUrl(value) {
if (typeof value !== "string") return "";
return value.trim().replace(/\/+$/, "");
}
/**
* True when a server URL points at the local machine — loopback host. Only
* loopback servers expose the local-server start/stop controls. Reuses the
* shared LOCAL_HOSTS set from url.js so the desktop never disagrees on what
* "local" means.
*
* @param {string} serverUrl
* @returns {boolean}
*/
function isLoopbackServer(serverUrl) {
try {
return url.LOCAL_HOSTS.has(new URL(serverUrl).hostname);
} catch {
return false;
}
}
/**
* True when two URLs refer to the same local server — both loopback hosts on
* the same port (so ``localhost:6767`` and ``127.0.0.1:6767`` match, but
* ``localhost:8000`` does not). Used to confirm the CLI's local server is the
* one a window is actually connected to before showing its controls.
*
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function sameLoopbackServer(a, b) {
try {
const ua = new URL(a);
const ub = new URL(b);
if (!url.LOCAL_HOSTS.has(ua.hostname) || !url.LOCAL_HOSTS.has(ub.hostname)) return false;
return ua.port === ub.port;
} catch {
return false;
}
}
/**
* The Omnigent local runtime data dir — `$OMNIGENT_DATA_DIR` (with `~`
* expanded) or `~/.omnigent`. Mirrors `_local_data_dir()` in
* omnigent/host/local_server.py. The local-server pidfile lives here.
*
* @returns {string}
*/
function localDataDir() {
const raw = process.env.OMNIGENT_DATA_DIR;
if (raw && raw.trim() !== "") {
const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw;
return path.resolve(expanded);
}
return path.join(os.homedir(), ".omnigent");
}
/**
* The Omnigent config dir — `$OMNIGENT_CONFIG_HOME` (with `~` expanded) or
* `~/.omnigent`. config.yaml (machine identity) lives here; it can differ from
* the data dir under test env overrides, but is the same by default.
*
* @returns {string}
*/
function localConfigDir() {
const raw = process.env.OMNIGENT_CONFIG_HOME;
if (raw && raw.trim() !== "") {
const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw;
return path.resolve(expanded);
}
return path.join(os.homedir(), ".omnigent");
}
/** Memoized machine host id (stable once generated; never cache a null). */
let cachedHostId = null;
/**
* This machine's Omnigent host id (e.g. "host_ab12…"), read from the machine
* identity in `config.yaml` (`host: host_id:`, written by
* omnigent/host/identity.py) — instant, no subprocess. Present once generated,
* even before connecting to any server. Returns null when no id exists yet;
* after the first connect it resolves. Lets the renderer match "this machine"
* against the server's /v1/hosts list and select it after an auto-connect.
*
* @returns {string | null}
*/
function localHostId() {
if (cachedHostId) return cachedHostId;
try {
const parsed = yaml.load(fs.readFileSync(path.join(localConfigDir(), "config.yaml"), "utf8"));
const id = parsed && typeof parsed === "object" ? parsed.host?.host_id : null;
if (typeof id === "string" && id) cachedHostId = id;
} catch {
// No config yet, or unparseable.
}
return cachedHostId;
}
/**
* Parse the local-server pidfile contents: two lines, PID then port. Returns
* null when malformed. Mirrors `_read_local_server_pid_file()` in
* omnigent/host/local_server.py.
*
* @param {string} text
* @returns {{ pid: number, port: number } | null}
*/
function parseLocalServerPidfile(text) {
if (typeof text !== "string") return null;
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return null;
const pid = Number.parseInt(lines[0], 10);
const port = Number.parseInt(lines[1], 10);
if (!Number.isFinite(pid) || !Number.isFinite(port)) return null;
return { pid, port };
}
/**
* True when a process with this pid exists. `process.kill(pid, 0)` sends no
* signal — it only probes existence: it throws ESRCH when gone, EPERM when the
* process exists but isn't ours (still alive).
*
* @param {number} pid
* @returns {boolean}
*/
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return Boolean(err) && err.code === "EPERM";
}
}
/**
* Read + parse the local-server pidfile. Returns { pid, port } or null.
*
* @returns {{ pid: number, port: number } | null}
*/
function readLocalServerPidfile() {
let text;
try {
text = fs.readFileSync(path.join(localDataDir(), "local_server.pid"), "utf8");
} catch {
return null;
}
return parseLocalServerPidfile(text);
}
/**
* Local-server status from the pidfile + a pid-liveness check — no `omnigent
* server status` subprocess, so it's instant. Returns null when no live local
* server is recorded.
*
* Liveness only (no `/health`): a dead/cleared pidfile correctly reports null,
* but a stale pidfile whose pid happens to be alive (reused/hung) would report
* running. That's acceptable for the sidebar row, which is only shown when this
* port matches the server the window is already connected to — so a server is
* provably up there. Decisions made WITHOUT that guarantee (e.g. reusing a
* server before navigating) must use {@link localServerHealthy} instead.
*
* @returns {{ running: true, url: string, pid: number, port: number } | null}
*/
function localServerStatus() {
const rec = readLocalServerPidfile();
if (!rec || !isPidAlive(rec.pid)) return null;
return { running: true, url: `http://127.0.0.1:${rec.port}`, pid: rec.pid, port: rec.port };
}
/**
* Health-verified local-server lookup: pidfile + pid liveness + a `/health`
* probe (short timeout), mirroring `local_server_url_if_healthy()` in
* omnigent/host/local_server.py. Returns null for a stale pidfile (dead pid, a
* reused pid with nothing listening → connection refused fast, or a hung server
* → times out). Use this before reusing a server you're about to navigate to,
* so a stale pidfile doesn't send the window to a dead URL.
*
* @param {number} [timeoutMs]
* @returns {Promise<{ url: string, pid: number, port: number } | null>}
*/
async function localServerHealthy(timeoutMs = 1500) {
const rec = readLocalServerPidfile();
if (!rec || !isPidAlive(rec.pid)) return null;
const url = `http://127.0.0.1:${rec.port}`;
try {
const resp = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
if (resp.ok) return { url, pid: rec.pid, port: rec.port };
} catch {
// Refused / unreachable / timed out → not a healthy server we can reuse.
}
return null;
}
/**
* Well-known install locations for the `omnigent` binary, in priority order.
* `uv tool install` (the documented installer) drops it in ~/.local/bin;
* the rest cover Homebrew and source/cargo installs. Probing these matters
* because a GUI-launched Electron app inherits a minimal PATH that usually
* omits ~/.local/bin, so `command -v` alone is not enough.
*
* @returns {string[]}
*/
function candidatePaths() {
const home = os.homedir();
return [
path.join(home, ".local", "bin", "omnigent"),
path.join(home, ".cargo", "bin", "omnigent"),
"/opt/homebrew/bin/omnigent",
"/usr/local/bin/omnigent",
];
}
/**
* True when `p` exists, is a regular file, and is executable by this process.
*
* @param {string} p
* @returns {boolean}
*/
function isExecutableFile(p) {
try {
if (!fs.statSync(p).isFile()) return false;
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Resolve `omnigent` on PATH (or the user's login shell PATH). Returns null
* when not found. On POSIX we go through `command -v` so shell-managed PATHs
* (uv shims) resolve; on Windows we use `where`.
*
* @returns {string | null}
*/
function whichOmnigent() {
try {
if (process.platform === "win32") {
const out = execFileSync("where", ["omnigent"], { encoding: "utf8" });
return out.trim().split(/\r?\n/)[0] || null;
}
const out = execFileSync("/bin/sh", ["-c", "command -v omnigent"], {
encoding: "utf8",
});
return out.trim() || null;
} catch {
return null;
}
}
/**
* Locate the `omnigent` binary. Resolution order: a user-configured path, then
* PATH, then the well-known candidate locations. Returns the resolved path and
* which source matched, or null if nothing usable was found.
*
* `deps` lets the tests inject the executability/PATH probes so the resolution
* order can be verified without a real binary on disk.
*
* @param {string | null | undefined} configuredPath settings.omnigent_path
* @param {{
* isExecutableFile?: (p: string) => boolean,
* whichOmnigent?: () => string | null,
* candidatePaths?: () => string[],
* }} [deps]
* @returns {{ path: string, source: "configured" | "path" | "candidate" } | null}
*/
function resolveCliPath(configuredPath, deps = {}) {
const isExec = deps.isExecutableFile || isExecutableFile;
const which = deps.whichOmnigent || whichOmnigent;
const candidates = (deps.candidatePaths || candidatePaths)();
if (configuredPath && isExec(configuredPath)) {
return { path: configuredPath, source: "configured" };
}
const onPath = which();
if (onPath && isExec(onPath)) {
return { path: onPath, source: "path" };
}
for (const candidate of candidates) {
if (isExec(candidate)) {
return { path: candidate, source: "candidate" };
}
}
return null;
}
/**
* Run an `omnigent` subcommand and resolve with its captured output. Never
* rejects — a failure surfaces as a non-zero `code` plus stderr so callers can
* decide. `execFile` (no shell) avoids quoting pitfalls.
*
* @param {string} cliPath
* @param {string[]} args
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
function runCli(cliPath, args, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
return new Promise((resolve) => {
execFile(cliPath, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout, stderr) => {
// execFile sets err.code to the numeric exit code on a normal non-zero
// exit, or a string errno (e.g. "ENOENT") when the spawn itself failed.
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
resolve({ code, stdout: stdout || "", stderr: stderr || "" });
});
});
}
/**
* Whether the CLI holds valid stored credentials for a server — read straight
* from `~/.omnigent/auth_tokens.json` (no subprocess), mirroring
* omnigent/cli_auth.py: keyed by the trailing-slash-stripped URL, a record is
* valid if it's a Databricks pointer (has `workspace_host`) or a non-expired
* session token. The CLI's `state_dir()` is hardcoded to `~/.omnigent`.
*
* @param {string} serverUrl
* @returns {boolean}
*/
function serverAuthed(serverUrl) {
if (typeof serverUrl !== "string" || serverUrl === "") return false;
const key = serverUrl.replace(/\/+$/, "");
let data;
try {
data = JSON.parse(
fs.readFileSync(path.join(os.homedir(), ".omnigent", "auth_tokens.json"), "utf8"),
);
} catch {
return false;
}
const entry = data && typeof data === "object" ? data[key] : null;
if (!entry || typeof entry !== "object") return false;
if (entry.auth_type === "databricks") {
return typeof entry.workspace_host === "string" && entry.workspace_host !== "";
}
if (typeof entry.token === "string" && entry.token !== "") {
// expires_at is unix seconds (cli_auth uses time.time()); treat absent as
// non-expiring.
return typeof entry.expires_at === "number" ? entry.expires_at >= Date.now() / 1000 : true;
}
return false;
}
/**
* Run `omnigent login <serverUrl>` to authenticate the CLI to a server. It's a
* no-op when the server needs no auth (header mode), opens the system browser
* for OIDC / Databricks, and fails fast for password (TTY) modes when run
* without a terminal. Long timeout to allow the interactive browser flow.
*
* @param {string} cliPath
* @param {string} serverUrl
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<{ ok: boolean, output: string }>}
*/
async function loginServer(cliPath, serverUrl, { timeoutMs = 180000 } = {}) {
const res = await runCli(cliPath, ["login", serverUrl], { timeoutMs });
return { ok: res.code === 0, output: (res.stdout || res.stderr).trim() };
}
/**
* Parse the first JSON object out of CLI stdout. The status commands emit a
* single JSON blob, but tolerate a stray leading warning line by falling back
* to the outermost `{…}` slice. Returns null when nothing parses.
*
* @param {string} stdout
* @returns {Record<string, unknown> | null}
*/
function parseJsonLoose(stdout) {
const text = (stdout || "").trim();
if (text === "") return null;
try {
return JSON.parse(text);
} catch {
/* fall through to the slice attempt */
}
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start >= 0 && end > start) {
try {
return JSON.parse(text.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
/**
* Probe the CLI and report whether it's installed and usable. Validates the
* resolved path by actually running `--version`, so a stale or wrong configured
* path reports `installed:false` rather than failing later.
*
* @param {string | null | undefined} configuredPath
* @returns {Promise<{
* installed: boolean,
* path: string | null,
* version: string | null,
* source: string | null,
* installCommand: string,
* }>}
*/
async function getCliStatus(configuredPath) {
const resolved = resolveCliPath(configuredPath);
if (!resolved) {
return {
installed: false,
path: null,
version: null,
source: null,
installCommand: INSTALL_COMMAND,
};
}
const res = await runCli(resolved.path, ["--version"], { timeoutMs: 5000 });
const ok = res.code === 0;
return {
installed: ok,
path: ok ? resolved.path : null,
version: ok ? res.stdout.trim() || res.stderr.trim() || null : null,
source: ok ? resolved.source : null,
installCommand: INSTALL_COMMAND,
};
}
/**
* `omnigent server status --json`. Returns the parsed payload, or a synthetic
* not-running shape when the command produced no JSON.
*
* @param {string} cliPath
* @returns {Promise<Record<string, unknown>>}
*/
async function getServerStatus(cliPath) {
const res = await runCli(cliPath, ["server", "status", "--json"]);
const json = parseJsonLoose(res.stdout);
if (!json) {
return { running: false, error: res.stderr.trim() || "could not read server status" };
}
return json;
}
/**
* Start (or reuse) the local background server, then re-read status for a
* reliable URL. `server start` is idempotent on the CLI side.
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, url?: string, port?: number, pid?: number, error?: string }>}
*/
async function startLocalServer(cliPath) {
const res = await runCli(cliPath, ["server", "start"], { timeoutMs: 30000 });
const status = await getServerStatus(cliPath);
if (status && status.running && typeof status.url === "string") {
return { ok: true, url: status.url, port: status.port, pid: status.pid };
}
return {
ok: false,
error: res.stderr.trim() || res.stdout.trim() || "failed to start the local server",
};
}
/**
* Stop the local background server (and its attached host daemon).
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, output: string }>}
*/
async function stopLocalServer(cliPath) {
const res = await runCli(cliPath, ["server", "stop"], { timeoutMs: 15000 });
return { ok: res.code === 0, output: (res.stdout || res.stderr).trim() };
}
/**
* `omnigent host status --json`, optionally scoped to one server. Returns the
* parsed payload (with a `daemons` array) or null.
*
* @param {string} cliPath
* @param {string | null} [serverUrl]
* @returns {Promise<Record<string, unknown> | null>}
*/
async function getHostStatus(cliPath, serverUrl) {
const args = ["host", "status", "--json"];
if (serverUrl) args.push("--server", serverUrl);
const res = await runCli(cliPath, args);
return parseJsonLoose(res.stdout);
}
/**
* Tell the server to drop a host daemon it owns for this target. Used to
* disconnect a daemon the desktop adopted rather than spawned.
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, output: string }>}
*/
async function stopHost(cliPath, serverUrl) {
const res = await runCli(cliPath, ["host", "stop", "--server", serverUrl], {
timeoutMs: 15000,
});
return { ok: res.code === 0, output: (res.stdout || res.stderr).trim() };
}
/**
* True when a daemon record refers to the given server URL. Compares its
* `server_url`, `target`, AND `resolved_server_url` (after trailing-slash
* normalization) — the last matters for a local-mode daemon (target `"local"`,
* server_url null) whose loopback URL only appears as `resolved_server_url`, so
* connecting by that loopback URL still recognizes it.
*
* @param {Record<string, unknown>} daemon One entry from the daemons array.
* @param {string} serverUrl
* @returns {boolean}
*/
function matchesServer(daemon, serverUrl) {
if (!daemon || typeof daemon !== "object") return false;
const want = normalizeServerUrl(serverUrl);
if (want === "") return false;
return (
normalizeServerUrl(daemon.server_url) === want ||
normalizeServerUrl(daemon.target) === want ||
normalizeServerUrl(daemon.resolved_server_url) === want
);
}
/**
* Reduce a `host status --json` payload to this machine's connection to one
* server. `connected` requires both a live daemon process and an online host
* tunnel — the two-field check the CLI itself uses.
*
* @param {Record<string, unknown> | null} statusJson
* @param {string} serverUrl
* @returns {{
* connected: boolean,
* process: "online" | "offline",
* hostStatus: string | null,
* pid: number | null,
* error: string | null,
* }}
*/
function connectionFromStatus(statusJson, serverUrl) {
const daemons = statusJson && Array.isArray(statusJson.daemons) ? statusJson.daemons : [];
const daemon = daemons.find((d) => matchesServer(d, serverUrl)) || null;
if (!daemon) {
return {
connected: false,
process: "offline",
hostStatus: null,
pid: null,
error: null,
};
}
const proc = daemon.process === "online" ? "online" : "offline";
const hostStatus = typeof daemon.host_status === "string" ? daemon.host_status : null;
return {
connected: proc === "online" && hostStatus === "online",
process: proc,
hostStatus,
pid: typeof daemon.pid === "number" ? daemon.pid : null,
error: typeof daemon.error === "string" ? daemon.error : null,
};
}
/**
* Directory holding per-target daemon registry records, mirroring
* `_daemon_registry_dir()` in omnigent/cli.py (`<state_dir>/daemons`).
*
* @returns {string}
*/
function daemonRegistryDir() {
return path.join(localDataDir(), "daemons");
}
/**
* Parse one decoded daemon registry record into the subset the desktop needs.
* Mirrors the validation in `_record_from_json()` (omnigent/cli.py): a usable
* record needs a positive integer `pid`, a non-empty `target`, and a known
* `mode`. Returns null for malformed records.
*
* @param {unknown} raw
* @returns {{
* pid: number,
* target: string,
* mode: "local" | "server",
* server_url: string | null,
* resolved_server_url: string | null,
* host_id: string | null,
* log_path: string | null,
* } | null}
*/
function parseDaemonRecord(raw) {
if (!raw || typeof raw !== "object") return null;
const pid =
typeof raw.pid === "number"
? raw.pid
: typeof raw.pid === "string"
? Number.parseInt(raw.pid, 10)
: NaN;
if (!Number.isInteger(pid) || pid <= 0) return null;
const target = typeof raw.target === "string" ? raw.target : "";
const mode = raw.mode === "local" || raw.mode === "server" ? raw.mode : "";
if (!target || !mode) return null;
const str = (v) => (typeof v === "string" && v ? v : null);
return {
pid,
target,
mode,
server_url: str(raw.server_url),
resolved_server_url: str(raw.resolved_server_url),
host_id: str(raw.host_id),
log_path: str(raw.log_path),
};
}
/**
* Read every daemon registry record from disk (`~/.omnigent/daemons/*.json`).
* This is the fast substitute for `omnigent host status --json`: it gives the
* daemon metadata and (with a pid-liveness check) process state without the
* per-session runner probes that make the CLI command slow. Tunnel health
* ({@link probeHostTunnel}) is layered on separately. Returns [] when the
* registry is absent.
*
* @returns {ReturnType<typeof parseDaemonRecord>[]}
*/
function readDaemonRecords() {
const dir = daemonRegistryDir();
let names;
try {
names = fs.readdirSync(dir);
} catch {
return [];
}
const records = [];
for (const name of names) {
if (!name.endsWith(".json")) continue;
try {
const raw = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
const rec = parseDaemonRecord(raw);
if (rec) records.push(rec);
} catch {
// Skip an unreadable/garbage record — a half-written file mid-rotation.
}
}
return records;
}
/**
* The Omnigent server URL a daemon record talks to, mirroring
* `_daemon_base_url()` (omnigent/cli.py): a local-mode daemon's URL lives in
* `resolved_server_url` (falling back to a healthy local server's URL); a
* server-mode daemon's is its `server_url`/`target`.
*
* @param {ReturnType<typeof parseDaemonRecord>} record
* @returns {string | null}
*/
function daemonServerUrl(record) {
if (!record) return null;
if (record.mode === "local") {
if (record.resolved_server_url) return record.resolved_server_url.replace(/\/+$/, "");
return localServerStatus()?.url ?? null;
}
return (record.server_url || record.target).replace(/\/+$/, "");
}
/**
* The bearer token to authenticate an in-process request to `serverUrl`, the
* subset of `_remote_headers()` (omnigent/chat.py) reproducible without the
* Databricks SDK: the `OMNIGENT_REMOTE_AUTH_TOKEN` env var, then a non-expired
* session token stored by `omnigent login` in `auth_tokens.json`. Returns null
* for a Databricks-pointer login (no token is stored — the SDK mints one per
* request) or when nothing is stored.
*
* @param {string} serverUrl
* @returns {string | null}
*/
function bearerTokenFor(serverUrl) {
const env = (process.env.OMNIGENT_REMOTE_AUTH_TOKEN || "").trim();
if (env) return env;
if (typeof serverUrl !== "string" || serverUrl === "") return null;
const key = serverUrl.replace(/\/+$/, "");
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(localDataDir(), "auth_tokens.json"), "utf8"));
} catch {
return null;
}
const entry = data && typeof data === "object" ? data[key] : null;
if (!entry || typeof entry !== "object") return null;
if (typeof entry.token === "string" && entry.token !== "") {
if (typeof entry.expires_at === "number" && entry.expires_at < Date.now() / 1000) return null;
return entry.token;
}
return null;
}
/**
* The "basic request" that detects whether a host's tunnel is up: a single
* `GET {serverUrl}/v1/hosts/{host_id}`, reading `body.status` — the same probe
* `_add_daemon_host_status()` (omnigent/cli.py) makes, minus the per-session
* runner enumeration. Loopback servers are single-user (no auth); a remote
* server needs a bearer ({@link bearerTokenFor}). When no bearer is obtainable
* (a Databricks-pointer login), returns `authMissing` so the caller can avoid
* falsely reporting the tunnel down.
*
* @param {string} serverUrl
* @param {string | null} hostId
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<{ status: string | null, reachable: boolean, authMissing: boolean }>}
*/
async function probeHostTunnel(serverUrl, hostId, { timeoutMs = 2000 } = {}) {
if (typeof serverUrl !== "string" || !serverUrl || typeof hostId !== "string" || !hostId) {
return { status: null, reachable: false, authMissing: false };
}
const headers = {};
if (!isLoopbackServer(serverUrl)) {
const token = bearerTokenFor(serverUrl);
if (!token) return { status: null, reachable: false, authMissing: true };
headers.Authorization = `Bearer ${token}`;
}
const base = serverUrl.replace(/\/+$/, "");
const target = `${base}/v1/hosts/${encodeURIComponent(hostId)}`;
try {
const resp = await fetch(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
if (!resp.ok) return { status: null, reachable: true, authMissing: false };
const body = await resp.json().catch(() => null);
const status = body && typeof body.status === "string" ? body.status : null;
return { status, reachable: true, authMissing: false };
} catch {
// Connection refused / unreachable / timed out → can't confirm the tunnel.
return { status: null, reachable: false, authMissing: false };
}
}
/**
* This machine's connection to `serverUrl`, resolved WITHOUT the slow `omnigent
* host status` subprocess: daemon metadata + process state come from the
* on-disk registry ({@link readDaemonRecords}), and tunnel health from one
* basic request ({@link probeHostTunnel}). Drop-in for the
* `getHostStatus` + `connectionFromStatus` pair, returning the same shape plus
* `verified` (false when the tunnel couldn't be probed — e.g. Databricks-pointer
* auth — so process-alive is reported optimistically rather than as offline).
*
* @param {string} serverUrl
* @param {{ probe?: boolean, timeoutMs?: number }} [opts]
* @returns {Promise<{
* connected: boolean,
* process: "online" | "offline",
* hostStatus: string | null,
* pid: number | null,
* error: string | null,
* verified: boolean,
* }>}
*/
async function getHostConnectionFast(serverUrl, { probe = true, timeoutMs = 2000 } = {}) {
const match = readDaemonRecords().find((r) => matchesServer(r, serverUrl)) || null;
if (!match) {
return {
connected: false,
process: "offline",
hostStatus: null,
pid: null,
error: null,
verified: true,
};
}
if (!isPidAlive(match.pid)) {
return {
connected: false,
process: "offline",
hostStatus: null,
pid: match.pid,
error: null,
verified: true,
};
}
// Process is alive. Without a tunnel probe we can only attest the process.
if (!probe) {
return {
connected: true,
process: "online",
hostStatus: null,
pid: match.pid,
error: null,
verified: false,
};
}
const hostId = match.host_id || localHostId();
const res = await probeHostTunnel(daemonServerUrl(match) || serverUrl, hostId, { timeoutMs });
if (res.authMissing) {
// Can't reproduce Databricks-pointer auth in-process → report the live
// process optimistically as connected, flagged unverified.
return {
connected: true,
process: "online",
hostStatus: null,
pid: match.pid,
error: null,
verified: false,
};
}
if (!res.reachable) {
return {
connected: false,
process: "online",
hostStatus: null,
pid: match.pid,
error: "server unreachable",
verified: true,
};
}
return {
connected: res.status === "online",
process: "online",
hostStatus: res.status,
pid: match.pid,
error: null,
verified: true,
};
}
module.exports = {
INSTALL_COMMAND,
DEFAULT_TIMEOUT_MS,
normalizeServerUrl,
isLoopbackServer,
sameLoopbackServer,
localHostId,
parseLocalServerPidfile,
isPidAlive,
readLocalServerPidfile,
localServerStatus,
localServerHealthy,
candidatePaths,
isExecutableFile,
whichOmnigent,
resolveCliPath,
runCli,
parseJsonLoose,
getCliStatus,
getServerStatus,
startLocalServer,
stopLocalServer,
getHostStatus,
stopHost,
serverAuthed,
loginServer,
matchesServer,
connectionFromStatus,
daemonRegistryDir,
parseDaemonRecord,
readDaemonRecords,
daemonServerUrl,
bearerTokenFor,
probeHostTunnel,
getHostConnectionFast,
};
+67
View File
@@ -67,13 +67,80 @@ contextBridge.exposeInMainWorld("omnigentDesktop", {
openServerSetup: () => {
ipcRenderer.send("omnigent:open-server-setup");
},
/**
* This machine's host-connection status for the window's server, e.g.
* `{cliInstalled, connected, process, hostStatus, sessions, ownedByDesktop,
* error}`. Read-only — connecting this machine as a runner is done from the
* host menu via controlHost. Resolves null on pages that aren't a connected
* server.
*/
getHostStatus: () => ipcRenderer.invoke("omnigent:host-get-status"),
/**
* This machine's identity — `{ cliInstalled, hostId }` — read from local
* config with no subprocess, so it's instant (unlike getHostStatus, which
* also runs the slow runner-status check).
*/
getHostIdentity: () => ipcRenderer.invoke("omnigent:host-get-identity"),
/**
* Local-server status for the window's server (loopback only); resolves null
* for remote servers.
*/
getServerStatus: () => ipcRenderer.invoke("omnigent:server-get-status"),
/**
* Start / stop / restart this machine's host daemon for the window's server.
* Resolves a `{ ok, error? }` result.
* @param {"start" | "stop" | "restart"} action
*/
controlHost: (action) => ipcRenderer.invoke("omnigent:host-control", action),
/**
* Start / stop / restart the local server (loopback servers only). Resolves a
* `{ ok, error? }` result.
* @param {"start" | "stop" | "restart"} action
*/
controlServer: (action) => ipcRenderer.invoke("omnigent:server-control", action),
/**
* Subscribe to host/server status-change pings. Fired only on real events (a
* host child connecting/exiting, or a control action) — never on a timer — so
* the renderer re-reads status on demand. The callback takes no argument.
* Returns an unsubscribe function.
* @param {() => void} callback
* @returns {() => void}
*/
onHostStatusChanged: (callback) => {
const listener = () => callback();
ipcRenderer.on("omnigent:host-status-changed", listener);
return () => ipcRenderer.removeListener("omnigent:host-status-changed", listener);
},
});
// Setup-page bridge: persist + navigate to a server URL, and read the saved
// one to pre-fill the form. Separate object so the SPA never sees it.
contextBridge.exposeInMainWorld("omnigentSetup", {
getServerUrl: () => ipcRenderer.invoke("omnigent:get-server-url"),
/**
* Persist + navigate to a server URL. Connecting this machine as a runner is
* a separate, explicit action from the host menu — not a connect-time choice.
* @param {string} url
*/
setServerUrl: (url) => ipcRenderer.invoke("omnigent:set-server-url", url),
/** Recently-connected server URLs, most recent first. */
getRecentServers: () => ipcRenderer.invoke("omnigent:get-recent-servers"),
/**
* Whether the `omnigent` CLI is installed/runnable, e.g.
* `{installed, path, version, source, installCommand}`.
*/
getCliStatus: () => ipcRenderer.invoke("omnigent:get-cli-status"),
/**
* Set an explicit path to the omnigent binary. Resolves the CLI status plus
* `accepted` (whether that exact path validated and was saved).
* @param {string} path
*/
setCliPath: (path) => ipcRenderer.invoke("omnigent:set-cli-path", path),
/** Native file picker for the omnigent binary; resolves the path or null. */
browseCliPath: () => ipcRenderer.invoke("omnigent:browse-cli-path"),
/**
* Start (or reuse) the local server. Resolves `{ok, url?, error?}`; the
* caller then connects to `url` via setServerUrl.
*/
startLocalServer: () => ipcRenderer.invoke("omnigent:start-local-server"),
});
+580
View File
@@ -0,0 +1,580 @@
// Process lifecycle for desktop-managed Omnigent servers and host connections.
//
// This is the only place the desktop spawns long-lived processes. It owns:
// - hostChildren: the foreground `omnigent host --server <url>` processes this
// app started. They are torn down when the app quits (the confirmed
// lifecycle: the desktop owns what it starts).
// - ownedLocalServer: a local `omnigent server` we started ourselves (and so
// are responsible for stopping). If a server was already running when we
// looked, we do NOT claim ownership and leave it alone.
//
// Status is never cached here — every query re-reads it from the CLI
// (omnigent_cli.js), which is the single source of truth. This module only
// tracks *ownership* (did we start it?), which the CLI can't tell us.
"use strict";
const { spawn } = require("child_process");
const net = require("net");
const cli = require("./omnigent_cli");
/** Max seconds to wait for `host` to print its connected marker before giving up. */
const CONNECT_TIMEOUT_MS = 30000;
/** Grace period after SIGTERM before escalating to SIGKILL on shutdown. */
const KILL_GRACE_MS = 4000;
/** The line `omnigent host` prints once the websocket tunnel is up. */
const CONNECTED_MARKER = "✓ Connected";
/** Cap the in-memory per-host log so a chatty daemon can't grow unbounded. */
const MAX_LOG_CHARS = 8000;
/**
* Max time to wait for a just-stopped local server's port to free up before
* restarting. `omnigent server start` prefers the stable port (6767) but falls
* back to a free one if the old port isn't rebindable yet, which would move the
* server (and break the window pointed at it) — so we wait for the port first.
*/
const PORT_FREE_TIMEOUT_MS = 5000;
/** serverUrl(normalized) -> { child, serverUrl, log } for host processes we started. */
const hostChildren = new Map();
/** serverUrl(normalized) -> in-flight ensureHostConnected promise (dedup). */
const connectingHosts = new Map();
/** { url, port, pid } when this app started the local server; null otherwise. */
let ownedLocalServer = null;
/** Single listener notified when a host child's lifecycle changes (no polling). */
let changeListener = null;
/**
* Register a callback fired when a managed host child connects or exits on its
* own, so the main process can push a status ping to the renderer without
* polling. One listener; a second call replaces the first.
*
* @param {(() => void) | null} cb
*/
function onChange(cb) {
changeListener = typeof cb === "function" ? cb : null;
}
/**
* Heuristically classify a host-connect error as an authentication failure,
* from `omnigent host`'s own messages (HostConnectError: "Authentication
* failed", "HTTP 401", login-page redirect, or the `omnigent login` hint). Lets
* the UI show a friendly "sign in" prompt instead of a scary raw error.
*
* @param {string | undefined} text
* @returns {boolean}
*/
function isAuthError(text) {
return /authentication failed|http 401|unauthor|login page|omnigent login/i.test(
String(text || ""),
);
}
/**
* True when `omnigent host` refused to start because a daemon already serves
* this target — which means a host is in fact already connected, so we can
* adopt it instead of treating the conflict as a failure.
*
* @param {string | undefined} text
* @returns {boolean}
*/
function isDaemonConflict(text) {
return /already running for this server|host daemon is already running/i.test(String(text || ""));
}
/** Fire the change listener, swallowing listener errors. */
function emitChange() {
if (changeListener) {
try {
changeListener();
} catch {
// A broken listener must not take down lifecycle handling.
}
}
}
/**
* Append to a capped log buffer (newest kept).
*
* @param {{ text: string }} holder
* @param {string} chunk
*/
function appendLog(holder, chunk) {
holder.text = (holder.text + chunk).slice(-MAX_LOG_CHARS);
}
/**
* True when we hold a live (not yet exited) host child for this server.
*
* @param {string} key Normalized server URL.
* @returns {boolean}
*/
function ownsLiveHost(key) {
const entry = hostChildren.get(key);
return Boolean(entry && entry.child.exitCode === null && !entry.child.killed);
}
/**
* Spawn `omnigent host --server <url>` and resolve once it reports connected
* (or fails / times out). On success the child keeps running; the caller
* registers it. Never rejects.
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, child: import("child_process").ChildProcess, holder: {text: string}, error?: string }>}
*/
function spawnHostChild(cliPath, serverUrl) {
return new Promise((resolve) => {
const holder = { text: "" };
let child;
try {
child = spawn(cliPath, ["host", "--server", serverUrl], {
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
resolve({ ok: false, child: null, holder, error: err.message });
return;
}
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
finish({ ok: false, child, holder, error: "timed out waiting for host to connect" });
}, CONNECT_TIMEOUT_MS);
const onData = (buf) => {
const text = buf.toString();
appendLog(holder, text);
if (text.includes(CONNECTED_MARKER)) finish({ ok: true, child, holder });
};
child.stdout.on("data", onData);
child.stderr.on("data", onData);
child.on("error", (err) => finish({ ok: false, child, holder, error: err.message }));
// An exit *before* the connected marker is a failure (auth error, conflict,
// bad URL). The settled guard makes this a no-op once connected, so the
// persistent cleanup listener (registered by the caller) handles later exits.
child.on("exit", (code, signal) =>
finish({
ok: false,
child,
holder,
error: holder.text.trim() || `host exited (code=${code}, signal=${signal})`,
}),
);
});
}
/**
* Ensure this machine is connected as a host to `serverUrl`.
*
* If a live daemon already serves it (e.g. one the user started by hand), we
* *adopt* it without spawning a duplicate — `omnigent host` would otherwise
* error on the conflict, and we must not kill a daemon we didn't start. Adopted
* connections report ownedByDesktop:false.
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, ownedByDesktop: boolean, adopted?: boolean, error?: string }>}
*/
async function ensureHostConnected(cliPath, serverUrl) {
const key = cli.normalizeServerUrl(serverUrl);
if (key === "") return { ok: false, ownedByDesktop: false, error: "missing server URL" };
if (ownsLiveHost(key)) return { ok: true, ownedByDesktop: true };
// Dedupe concurrent connects for the same server (the restore-on-load path
// racing the connect-time path, or a double-clicked Start) so we never spawn
// two `omnigent host` processes for one target.
const inflight = connectingHosts.get(key);
if (inflight) return inflight;
const op = connectHost(cliPath, serverUrl, key);
connectingHosts.set(key, op);
// Surface the "connecting…" state right away (statusFor reports it while the
// key is in connectingHosts), then again once it settles.
emitChange();
try {
return await op;
} finally {
connectingHosts.delete(key);
emitChange();
}
}
/**
* The actual connect: adopt a daemon already serving this target, else spawn
* and track one. Serialized per target by ensureHostConnected.
*
* @param {string} cliPath
* @param {string} serverUrl
* @param {string} key Normalized server URL.
* @returns {Promise<{ ok: boolean, ownedByDesktop: boolean, adopted?: boolean, error?: string }>}
*/
async function connectHost(cliPath, serverUrl, key) {
// Read the on-disk daemon registry (a local-mode daemon's loopback URL is its
// resolved_server_url) and check pid liveness — no tunnel probe needed, the
// adopt decision only turns on whether a daemon process already serves this
// target. Avoids the slow `omnigent host status` subprocess.
const conn = await cli.getHostConnectionFast(serverUrl, { probe: false });
if (conn.process === "online") {
// A daemon is already up for this target — adopt rather than spawn.
return { ok: true, ownedByDesktop: false, adopted: true };
}
const spawned = await spawnHostChild(cliPath, serverUrl);
if (!spawned.ok) {
if (spawned.child && spawned.child.exitCode === null) spawned.child.kill("SIGTERM");
// The CLI refuses to start a second daemon for a target already served by
// one (e.g. a local-mode daemon our pre-check couldn't match). That means a
// host is in fact already connected — adopt it rather than report failure.
if (isDaemonConflict(spawned.error)) {
return { ok: true, ownedByDesktop: false, adopted: true };
}
return {
ok: false,
ownedByDesktop: false,
error: spawned.error,
authError: isAuthError(spawned.error),
};
}
hostChildren.set(key, { child: spawned.child, serverUrl, log: spawned.holder });
// Persistent cleanup: drop the entry when this child eventually exits. If the
// entry is still ours here, this is a SPONTANEOUS exit (crash / external
// kill), not a user-initiated disconnect (which removes the entry first), so
// ping the UI — this is how a dying daemon is reflected without polling.
spawned.child.on("exit", () => {
if (hostChildren.get(key)?.child === spawned.child) {
hostChildren.delete(key);
emitChange();
}
});
return { ok: true, ownedByDesktop: true };
}
/**
* Disconnect this machine from `serverUrl`. A desktop-owned child is killed; a
* daemon we merely adopted is asked to stop via the CLI (the user explicitly
* toggled off, so honoring that is correct even for an adopted daemon).
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, error?: string }>}
*/
async function disconnectHost(cliPath, serverUrl) {
const key = cli.normalizeServerUrl(serverUrl);
const entry = hostChildren.get(key);
if (entry) {
hostChildren.delete(key);
// Await the exit so a follow-up restart spawns fresh rather than adopting
// the daemon we're tearing down.
await stopChild(entry.child);
return { ok: true };
}
// No desktop-owned child: ask the CLI to stop a daemon we'd adopted.
const res = await cli.stopHost(cliPath, serverUrl);
return { ok: res.ok, error: res.ok ? undefined : res.output };
}
/**
* Ensure the CLI is authenticated for a server before connecting a host to it.
* Local (loopback) servers need no auth. For a remote server with no valid
* stored credentials, runs `omnigent login <url>` (browser/OIDC/Databricks; a
* no-op when the server needs no auth). Returns ok when already authed, after a
* successful login, or for a no-auth server; an error (pointing at `omnigent
* login`) when login fails — e.g. a password/TTY mode that can't run headless.
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, error?: string }>}
*/
async function ensureServerAuth(cliPath, serverUrl) {
if (cli.isLoopbackServer(serverUrl) || cli.serverAuthed(serverUrl)) return { ok: true };
const res = await cli.loginServer(cliPath, serverUrl);
if (res.ok) return { ok: true };
return {
ok: false,
error: `Sign-in required — run \`omnigent login ${serverUrl}\` in a terminal, then try again.`,
};
}
/**
* Restart this machine's host connection: stop (awaiting the daemon down), then
* reconnect.
*
* @param {string} cliPath
* @param {string} serverUrl
* @returns {Promise<{ ok: boolean, ownedByDesktop: boolean, error?: string }>}
*/
async function restartHost(cliPath, serverUrl) {
await disconnectHost(cliPath, serverUrl);
return ensureHostConnected(cliPath, serverUrl);
}
/**
* SIGTERM a child, escalating to SIGKILL after a grace period, and resolve once
* it has actually exited.
*
* @param {import("child_process").ChildProcess} child
* @returns {Promise<void>}
*/
function stopChild(child) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null) {
resolve();
return;
}
const t = setTimeout(() => {
if (child.exitCode === null) child.kill("SIGKILL");
}, KILL_GRACE_MS);
// Don't let the escalation timer keep the event loop alive at quit.
if (typeof t.unref === "function") t.unref();
child.once("exit", () => {
clearTimeout(t);
resolve();
});
child.kill("SIGTERM");
});
}
/**
* Start (or reuse) the local background server. Ownership is recorded only when
* *we* actually start it — a server that was already running is left to its
* own lifecycle.
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, url?: string, alreadyRunning?: boolean, error?: string }>}
*/
async function startLocalServer(cliPath) {
// Reuse a server that's already running — but health-verify it (pidfile +
// pid + /health), not just pid-liveness, since we're about to navigate the
// window to this URL: a stale pidfile (dead/reused pid, hung server) must NOT
// be reused or we'd send the window to a dead URL. Still far faster than
// `omnigent server status` (a Python cold start). We didn't start it, so no
// ownership claim.
const existing = await cli.localServerHealthy();
if (existing) {
return { ok: true, url: existing.url, alreadyRunning: true };
}
const res = await cli.startLocalServer(cliPath);
if (res.ok) {
ownedLocalServer = { url: res.url, port: res.port, pid: res.pid };
return { ok: true, url: res.url };
}
return { ok: false, error: res.error };
}
/**
* Stop the local server only if this app started it (used at quit). A server
* the desktop didn't start is left running.
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, skipped?: boolean }>}
*/
async function stopOwnedLocalServer(cliPath) {
if (!ownedLocalServer) return { ok: true, skipped: true };
const res = await cli.stopLocalServer(cliPath);
ownedLocalServer = null;
return { ok: res.ok };
}
/**
* Stop the local server unconditionally — the user explicitly asked for it from
* the sidebar control, so honor it even if the desktop didn't start it.
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, error?: string }>}
*/
async function stopLocalServer(cliPath) {
const res = await cli.stopLocalServer(cliPath);
ownedLocalServer = null;
return { ok: res.ok, error: res.ok ? undefined : res.output };
}
/**
* Resolve once 127.0.0.1:port is bindable (i.e. free), or after `timeoutMs`.
* Used between a local-server stop and start so the freed port is reusable
* before `omnigent server start` probes it.
*
* @param {number} port
* @param {number} timeoutMs
* @returns {Promise<boolean>} true once free, false on timeout.
*/
function waitForPortFree(port, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const attempt = () => {
const tester = net.createServer();
tester.once("error", () => {
tester.close();
if (Date.now() >= deadline) {
resolve(false);
return;
}
const t = setTimeout(attempt, 150);
if (typeof t.unref === "function") t.unref();
});
tester.once("listening", () => {
// Listening succeeded → the port is free. Closing a never-accepted
// listen socket leaves no TIME_WAIT, so `server start` can rebind it.
tester.close(() => resolve(true));
});
tester.listen(port, "127.0.0.1");
};
attempt();
});
}
/**
* Restart the local server, keeping it on the same port. We stop it, wait for
* its port to actually free (so `omnigent server start`'s preferred-port probe
* rebinds it instead of falling back to a new free port — which would move the
* URL out from under the connected window), then start.
*
* @param {string} cliPath
* @returns {Promise<{ ok: boolean, url?: string, error?: string }>}
*/
async function restartLocalServer(cliPath) {
const before = cli.localServerStatus();
await stopLocalServer(cliPath);
if (before && Number.isInteger(before.port)) {
await waitForPortFree(before.port, PORT_FREE_TIMEOUT_MS);
}
return startLocalServer(cliPath);
}
/**
* Host-connection status for one server, plus whether we own the connection.
*
* @param {string | null} cliPath
* @param {string} serverUrl
* @returns {Promise<Record<string, unknown>>}
*/
async function statusFor(cliPath, serverUrl) {
if (!cliPath) {
return {
cliInstalled: false,
connected: false,
process: "offline",
hostStatus: null,
hostId: null,
ownedByDesktop: false,
error: null,
};
}
// This machine's host id (from on-disk daemon records; null until it has
// connected at least once). Lets the renderer match "this machine" in the
// server's host list and select it after an auto-connect.
const hostId = cli.localHostId();
const key = cli.normalizeServerUrl(serverUrl);
// A connect is in flight for this server (auto-restore, connect-time, or a
// Start that hasn't tunneled yet) — report "connecting" without a subprocess
// so the sidebar shows it immediately. `process: "online"` + `connected:
// false` is how the renderer renders the connecting state.
if (connectingHosts.has(key) && !ownsLiveHost(key)) {
return {
cliInstalled: true,
connected: false,
process: "online",
hostStatus: null,
hostId,
ownedByDesktop: true,
error: null,
};
}
// We own a live host child (saw its "✓ Connected") — report connected
// instantly without shelling out to the CLI. This is the common case after a
// page refresh, where the daemon is already up.
if (ownsLiveHost(key)) {
return {
cliInstalled: true,
connected: true,
process: "online",
hostStatus: "online",
hostId,
ownedByDesktop: true,
error: null,
};
}
// Resolve from the on-disk daemon registry + one basic tunnel probe rather
// than `omnigent host status` (which also enumerates sessions over the
// network per daemon — slow). A local-mode daemon serving this loopback URL
// is matched via its resolved_server_url.
const conn = await cli.getHostConnectionFast(serverUrl);
return {
cliInstalled: true,
connected: conn.connected,
process: conn.process,
hostStatus: conn.hostStatus,
pid: conn.pid,
error: conn.error,
hostId,
ownedByDesktop: ownsLiveHost(key),
};
}
/**
* Local-server status for a loopback server URL, plus ownership. Returns null
* for non-loopback URLs (the local-server controls don't apply remotely).
*
* @param {string | null} cliPath
* @param {string} serverUrl
* @returns {Promise<Record<string, unknown> | null>}
*/
async function serverStatusFor(cliPath, serverUrl) {
if (!cliPath || !cli.isLoopbackServer(serverUrl)) return null;
// Read the local-server pidfile (~/.omnigent/local_server.pid) directly — pid
// + port, then a pid-liveness check. Instant and always fresh: no `omnigent
// server status` subprocess, so the row appears immediately (incl. on a page
// refresh). Only surface controls when that server is the one THIS window is
// connected to (matching loopback port), not an unrelated background server.
const local = cli.localServerStatus();
if (!local || !cli.sameLoopbackServer(local.url, serverUrl)) return null;
return {
running: true,
url: local.url,
pid: local.pid,
ownedByDesktop: Boolean(ownedLocalServer),
};
}
/**
* Tear down everything this app started: SIGTERM all host children (await their
* exit within the grace period), then stop an owned local server. Called from
* the app's before-quit handler.
*
* @param {string | null} cliPath
* @returns {Promise<void>}
*/
async function shutdown(cliPath) {
const exits = [];
for (const [, entry] of hostChildren) {
exits.push(stopChild(entry.child));
}
await Promise.all(exits);
hostChildren.clear();
if (cliPath) await stopOwnedLocalServer(cliPath);
}
module.exports = {
ensureHostConnected,
ensureServerAuth,
disconnectHost,
restartHost,
startLocalServer,
stopOwnedLocalServer,
stopLocalServer,
restartLocalServer,
statusFor,
serverStatusFor,
shutdown,
onChange,
// Exposed for tests / introspection.
_hostChildren: hostChildren,
ownsLiveHost,
};
+264
View File
@@ -0,0 +1,264 @@
// Tests for the pure helpers in src/omnigent_cli.js, run with `node --test`
// (no extra deps). The spawning functions need a real binary and are covered by
// the manual verification flow; here we test path resolution order, server-URL
// matching, and status parsing — the logic that decides "is this machine
// connected to server X?" and "which omnigent binary do we run?".
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
normalizeServerUrl,
isLoopbackServer,
sameLoopbackServer,
parseLocalServerPidfile,
resolveCliPath,
parseJsonLoose,
matchesServer,
connectionFromStatus,
parseDaemonRecord,
daemonServerUrl,
} = require("../src/omnigent_cli");
describe("normalizeServerUrl", () => {
it("strips trailing slashes and trims", () => {
assert.equal(normalizeServerUrl("https://x.com/"), "https://x.com");
assert.equal(normalizeServerUrl(" http://localhost:6767// "), "http://localhost:6767");
assert.equal(normalizeServerUrl("https://x.com/ml/omnigents"), "https://x.com/ml/omnigents");
});
it("returns empty string for non-strings", () => {
assert.equal(normalizeServerUrl(undefined), "");
assert.equal(normalizeServerUrl(null), "");
assert.equal(normalizeServerUrl(42), "");
});
});
describe("isLoopbackServer", () => {
it("is true for loopback hosts", () => {
assert.equal(isLoopbackServer("http://localhost:6767"), true);
assert.equal(isLoopbackServer("http://127.0.0.1:6767"), true);
assert.equal(isLoopbackServer("http://[::1]:6767"), true);
});
it("is false for remote hosts and junk", () => {
assert.equal(isLoopbackServer("https://example.databricksapps.com"), false);
assert.equal(isLoopbackServer("not a url"), false);
});
});
describe("sameLoopbackServer", () => {
it("matches loopback hosts on the same port (localhost == 127.0.0.1)", () => {
assert.equal(sameLoopbackServer("http://127.0.0.1:6767", "http://localhost:6767/"), true);
assert.equal(sameLoopbackServer("http://localhost:6767", "http://[::1]:6767"), true);
});
it("does not match different ports", () => {
assert.equal(sameLoopbackServer("http://127.0.0.1:6767", "http://localhost:8000"), false);
});
it("does not match when either side is remote, or on junk", () => {
assert.equal(sameLoopbackServer("http://localhost:6767", "https://example.com:6767"), false);
assert.equal(sameLoopbackServer("not a url", "http://localhost:6767"), false);
});
});
describe("parseLocalServerPidfile", () => {
it("parses pid then port", () => {
assert.deepEqual(parseLocalServerPidfile("12345\n6767\n"), { pid: 12345, port: 6767 });
assert.deepEqual(parseLocalServerPidfile("42\n8000"), { pid: 42, port: 8000 });
});
it("returns null for malformed contents", () => {
assert.equal(parseLocalServerPidfile("12345"), null); // only one line
assert.equal(parseLocalServerPidfile("abc\ndef"), null); // non-numeric
assert.equal(parseLocalServerPidfile(""), null);
assert.equal(parseLocalServerPidfile(null), null);
});
});
describe("resolveCliPath", () => {
it("prefers a usable configured path", () => {
const got = resolveCliPath("/custom/omnigent", {
isExecutableFile: (p) => p === "/custom/omnigent",
whichOmnigent: () => "/usr/bin/omnigent",
candidatePaths: () => ["/home/me/.local/bin/omnigent"],
});
assert.deepEqual(got, { path: "/custom/omnigent", source: "configured" });
});
it("falls back to PATH when the configured path is unusable", () => {
const got = resolveCliPath("/bad/path", {
isExecutableFile: (p) => p === "/usr/bin/omnigent",
whichOmnigent: () => "/usr/bin/omnigent",
candidatePaths: () => ["/home/me/.local/bin/omnigent"],
});
assert.deepEqual(got, { path: "/usr/bin/omnigent", source: "path" });
});
it("falls back to a candidate when PATH misses (GUI minimal PATH)", () => {
const got = resolveCliPath(null, {
isExecutableFile: (p) => p === "/home/me/.local/bin/omnigent",
whichOmnigent: () => null,
candidatePaths: () => ["/home/me/.local/bin/omnigent", "/opt/homebrew/bin/omnigent"],
});
assert.deepEqual(got, { path: "/home/me/.local/bin/omnigent", source: "candidate" });
});
it("returns null when nothing is usable", () => {
const got = resolveCliPath(null, {
isExecutableFile: () => false,
whichOmnigent: () => null,
candidatePaths: () => ["/a", "/b"],
});
assert.equal(got, null);
});
});
describe("parseJsonLoose", () => {
it("parses clean JSON", () => {
assert.deepEqual(parseJsonLoose('{"running": true}'), { running: true });
});
it("recovers JSON after a stray warning line", () => {
assert.deepEqual(parseJsonLoose('WARN: something\n{"running": false}\n'), {
running: false,
});
});
it("returns null for empty or unparseable output", () => {
assert.equal(parseJsonLoose(""), null);
assert.equal(parseJsonLoose("not json"), null);
});
});
describe("matchesServer", () => {
it("matches on server_url or target, ignoring trailing slashes", () => {
assert.equal(matchesServer({ server_url: "https://x.com/" }, "https://x.com"), true);
assert.equal(matchesServer({ target: "https://x.com" }, "https://x.com/"), true);
});
it("matches a local-mode daemon by its resolved_server_url", () => {
// target "local", server_url null — only resolved_server_url has the URL.
assert.equal(
matchesServer(
{ target: "local", server_url: null, resolved_server_url: "http://127.0.0.1:6767" },
"http://127.0.0.1:6767/",
),
true,
);
});
it("does not match a different server", () => {
assert.equal(matchesServer({ server_url: "https://y.com" }, "https://x.com"), false);
});
it("is false for junk daemons or empty target", () => {
assert.equal(matchesServer(null, "https://x.com"), false);
assert.equal(matchesServer({ server_url: "https://x.com" }, ""), false);
});
});
describe("connectionFromStatus", () => {
const onlineDaemon = {
server_url: "https://x.com",
process: "online",
host_status: "online",
pid: 1234,
sessions: [{ id: "a" }, { id: "b" }],
};
it("reports connected when process and host_status are both online", () => {
const conn = connectionFromStatus({ daemons: [onlineDaemon] }, "https://x.com/");
assert.equal(conn.connected, true);
assert.equal(conn.process, "online");
assert.equal(conn.hostStatus, "online");
assert.equal(conn.pid, 1234);
});
it("is not connected when the host tunnel is offline though the process lives", () => {
const conn = connectionFromStatus(
{ daemons: [{ ...onlineDaemon, host_status: "offline" }] },
"https://x.com",
);
assert.equal(conn.connected, false);
assert.equal(conn.process, "online");
assert.equal(conn.hostStatus, "offline");
});
it("reports offline when no daemon matches the server", () => {
const conn = connectionFromStatus({ daemons: [onlineDaemon] }, "https://other.com");
assert.deepEqual(conn, {
connected: false,
process: "offline",
hostStatus: null,
pid: null,
error: null,
});
});
it("tolerates a missing/empty daemons array", () => {
assert.equal(connectionFromStatus(null, "https://x.com").connected, false);
assert.equal(connectionFromStatus({}, "https://x.com").connected, false);
});
});
describe("parseDaemonRecord", () => {
it("parses a server-mode record, keeping pid/target/urls", () => {
assert.deepEqual(
parseDaemonRecord({
pid: 4242,
target: "https://x.com",
mode: "server",
server_url: "https://x.com",
host_id: "host_abc",
log_path: "/tmp/x.log",
}),
{
pid: 4242,
target: "https://x.com",
mode: "server",
server_url: "https://x.com",
resolved_server_url: null,
host_id: "host_abc",
log_path: "/tmp/x.log",
},
);
});
it("coerces a string pid (registry writes it either way)", () => {
assert.equal(parseDaemonRecord({ pid: "99", target: "local", mode: "local" }).pid, 99);
});
it("rejects malformed records", () => {
assert.equal(parseDaemonRecord(null), null);
assert.equal(parseDaemonRecord({ target: "local", mode: "local" }), null); // no pid
assert.equal(parseDaemonRecord({ pid: 0, target: "local", mode: "local" }), null); // bad pid
assert.equal(parseDaemonRecord({ pid: 5, target: "", mode: "local" }), null); // empty target
assert.equal(parseDaemonRecord({ pid: 5, target: "x", mode: "weird" }), null); // bad mode
});
});
describe("daemonServerUrl", () => {
it("uses resolved_server_url for a local-mode daemon, stripping trailing slash", () => {
assert.equal(
daemonServerUrl({ mode: "local", resolved_server_url: "http://127.0.0.1:6767/" }),
"http://127.0.0.1:6767",
);
});
it("uses server_url (then target) for a server-mode daemon", () => {
assert.equal(
daemonServerUrl({ mode: "server", server_url: "https://x.com/" }),
"https://x.com",
);
assert.equal(
daemonServerUrl({ mode: "server", server_url: null, target: "https://y.com" }),
"https://y.com",
);
});
it("is null for a falsy record", () => {
assert.equal(daemonServerUrl(null), null);
});
});
+170
View File
@@ -120,6 +120,70 @@ interface ElectronDesktopApi extends NativeShellApi {
switchServer?: (url: string) => Promise<void>;
/** Return this window to the shell's "connect to server" setup page. */
openServerSetup?: () => void;
/**
* This machine's host-connection status for the window's server, or null.
* Read-only — hosting is enabled at connect time on the shell's setup page,
* not from inside the SPA.
*/
getHostStatus?: () => Promise<HostStatus | null>;
/** This machine's identity (CLI installed + host id) — fast, no subprocess. */
getHostIdentity?: () => Promise<HostIdentity | null>;
/** Local-server status for the window's server (loopback only), or null. */
getServerStatus?: () => Promise<LocalServerStatus | null>;
/** Start / stop / restart this machine's host daemon for the window's server. */
controlHost?: (action: HostControlAction) => Promise<HostActionResult>;
/** Start / stop / restart the local server (loopback only). */
controlServer?: (action: HostControlAction) => Promise<HostActionResult>;
/** Subscribe to host/server status-change pings (re-read on fire); returns an unsubscribe. */
onHostStatusChanged?: (callback: () => void) => () => void;
}
/** A lifecycle action for the host daemon or the local server. */
export type HostControlAction = "start" | "stop" | "restart";
/** This machine's host-connection status for a server, from the desktop shell. */
export interface HostStatus {
/** Whether the `omnigent` CLI was found and is runnable. */
cliInstalled: boolean;
/** Connected = a live daemon process AND an online host tunnel. */
connected: boolean;
/** Whether the host daemon process is alive. */
process: "online" | "offline";
/** The server-reported host tunnel state, e.g. "online", or null. */
hostStatus: string | null;
/**
* This machine's host id (from the local config), or null if it has none yet.
* Lets the SPA match "this machine" against the server's host list and select
* it after an auto-connect.
*/
hostId: string | null;
/** Whether this desktop app started (and owns) the host connection. */
ownedByDesktop: boolean;
/** A status error from the CLI, or null. */
error: string | null;
}
/** This machine's identity, read from local config (fast — no subprocess). */
export interface HostIdentity {
/** Whether the `omnigent` CLI was found and is runnable. */
cliInstalled: boolean;
/** This machine's host id, or null if it has none yet. */
hostId: string | null;
}
/** Local-server status for a loopback server, from the desktop shell. */
export interface LocalServerStatus {
running: boolean;
url: string | null;
pid: number | null;
/** Whether this desktop app started (and would stop) the local server. */
ownedByDesktop: boolean;
}
/** Result of a host/server control action from the desktop shell. */
export interface HostActionResult {
ok: boolean;
error?: string;
}
/** Data backing the title-bar server picker, from the Electron shell. */
@@ -414,3 +478,109 @@ export function openServerSetup(): void {
console.warn("[nativeBridge] electron openServerSetup failed:", err);
}
}
/**
* Fetch this machine's host-connection status for the window's server from the
* desktop shell — whether the `omnigent` CLI is installed, whether this machine
* is registered as a host with the server, how many sessions it's running, and
* whether this app owns the connection.
*
* Resolves `null` outside the Electron shell, under a shell too old to expose
* the host bridge, or on a page that isn't a connected server.
*/
export async function getHostStatus(): Promise<HostStatus | null> {
const electron = electronApi();
if (!electron?.getHostStatus) return null;
try {
return await electron.getHostStatus();
} catch (err) {
console.warn("[nativeBridge] electron getHostStatus failed:", err);
return null;
}
}
/**
* Fetch this machine's identity (CLI installed + host id) from the desktop
* shell. Fast — reads local config, no runner-status subprocess — so callers
* that only need to recognize "this machine" (e.g. the host picker) don't wait
* on the slow status check. Resolves `null` outside the Electron shell.
*/
export async function getHostIdentity(): Promise<HostIdentity | null> {
const electron = electronApi();
if (!electron?.getHostIdentity) return null;
try {
return await electron.getHostIdentity();
} catch (err) {
console.warn("[nativeBridge] electron getHostIdentity failed:", err);
return null;
}
}
/**
* Fetch local-server status for the window's server from the desktop shell.
* Resolves `null` outside the shell or for non-loopback (remote) servers, where
* the local-server controls don't apply.
*/
export async function getLocalServerStatus(): Promise<LocalServerStatus | null> {
const electron = electronApi();
if (!electron?.getServerStatus) return null;
try {
return await electron.getServerStatus();
} catch (err) {
console.warn("[nativeBridge] electron getServerStatus failed:", err);
return null;
}
}
/**
* Start / stop / restart this machine's host daemon for the window's server,
* via the desktop shell. Resolves `{ ok, error? }`; a no-op `{ ok: false }`
* outside the shell.
*/
export async function controlHost(action: HostControlAction): Promise<HostActionResult> {
const electron = electronApi();
if (!electron?.controlHost) return { ok: false, error: "not running under the desktop shell" };
try {
return await electron.controlHost(action);
} catch (err) {
console.warn("[nativeBridge] electron controlHost failed:", err);
return { ok: false, error: String(err) };
}
}
/**
* Start / stop / restart the local server (loopback servers only), via the
* desktop shell. Resolves `{ ok, error? }`; a no-op `{ ok: false }` outside the
* shell.
*/
export async function controlServer(action: HostControlAction): Promise<HostActionResult> {
const electron = electronApi();
if (!electron?.controlServer) return { ok: false, error: "not running under the desktop shell" };
try {
return await electron.controlServer(action);
} catch (err) {
console.warn("[nativeBridge] electron controlServer failed:", err);
return { ok: false, error: String(err) };
}
}
/**
* Subscribe to host/server status-change pings from the desktop shell. The
* shell fires these only on real events — a host child connecting or exiting,
* or a control action — never on a timer, so the callback should re-read status
* (getHostStatus / getLocalServerStatus) when it fires.
*
* Returns an unsubscribe function. A no-op (returning a no-op unsubscribe)
* outside the Electron shell or under a shell too old to push updates, so
* callers can register it unconditionally.
*/
export function onHostStatusChanged(callback: () => void): () => void {
const electron = electronApi();
if (!electron?.onHostStatusChanged) return () => {};
try {
return electron.onHostStatusChanged(callback);
} catch (err) {
console.warn("[nativeBridge] electron onHostStatusChanged failed:", err);
return () => {};
}
}
+153 -24
View File
@@ -52,6 +52,13 @@ import {
nativeWrapperLabelsForAgent,
} from "@/lib/nativeCodingAgents";
import { useHosts, type Host } from "@/hooks/useHosts";
import {
controlHost,
getHostIdentity,
isElectronShell,
onHostStatusChanged,
type HostIdentity,
} from "@/lib/nativeBridge";
import { useAvailableAgents, type AvailableAgent } from "@/hooks/useAvailableAgents";
import { useAutoGrowTextarea } from "@/hooks/useAutoGrowTextarea";
import { useRecentWorkspaces } from "@/hooks/useRecentWorkspaces";
@@ -188,23 +195,30 @@ const CODEX_NATIVE_APPROVAL_MODES: {
},
];
function HostOption({ host }: { host: Host }) {
function HostOption({ host, subtitle }: { host: Host; subtitle?: string }) {
const isOnline = host.status === "online";
return (
<span className="flex items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
{host.name.toLowerCase().includes("cloud") ? (
<MonitorCloudIcon className="size-4 text-muted-foreground" />
<MonitorCloudIcon className="size-4 shrink-0 text-muted-foreground" />
) : (
<MonitorIcon className="size-4 text-muted-foreground" />
<MonitorIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="text-xs">{host.name}</span>
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider ${isOnline ? "text-green-600" : "text-muted-foreground"}`}
>
<span
className={`inline-block size-1.5 rounded-full ${isOnline ? "bg-green-500" : "bg-muted-foreground"}`}
/>
{host.status}
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-2">
<span className="truncate text-xs">{host.name}</span>
<span
className={`inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-wider ${isOnline ? "text-green-600" : "text-muted-foreground"}`}
>
<span
className={`inline-block size-1.5 rounded-full ${isOnline ? "bg-green-500" : "bg-muted-foreground"}`}
/>
{host.status}
</span>
</span>
{subtitle && (
<span className="text-[10px] leading-tight text-muted-foreground">{subtitle}</span>
)}
</span>
</span>
);
@@ -942,6 +956,14 @@ export function NewChatLandingScreen() {
// host — the server provisions a sandbox host at create time
// (host_type: "managed"), so no host_id or workspace is sent.
const [sandboxSelected, setSandboxSelected] = useState(false);
// Desktop-shell host status for THIS machine (null outside Electron), so the
// picker can tag the current machine and offer to auto-connect it.
const [desktopHost, setDesktopHost] = useState<HostIdentity | null>(null);
const [connectingThisMachine, setConnectingThisMachine] = useState(false);
// Defer the connect until the dropdown has actually closed (set on select,
// consumed in the menu's onOpenChange) — connecting while the menu is open
// looks janky. A ref so the close handler sees it synchronously.
const pendingConnectRef = useRef(false);
// Sandbox repository inputs — composed into the managed create's
// `workspace` string (`<url>[#<branch>]`); both blank = empty
// server-created workspace.
@@ -984,6 +1006,33 @@ export function NewChatLandingScreen() {
const onlineHosts = allHosts.filter((h) => h.status === "online");
const offlineHosts = allHosts.filter((h) => h.status === "offline");
// Identify the current desktop machine and whether we can connect it. When
// it's already in the host list (online or offline) we connect via that row;
// only when it's absent do we show a standalone "Run on this machine" item —
// so the machine never appears twice.
const thisMachineHostId = desktopHost?.hostId ?? null;
const thisMachineInList =
thisMachineHostId != null && allHosts.some((h) => h.host_id === thisMachineHostId);
const canConnectThisMachine = Boolean(desktopHost?.cliInstalled);
const showConnectThisMachine = canConnectThisMachine && !thisMachineInList;
// Track this machine's host status from the desktop shell (no-op in a browser).
useEffect(() => {
if (!isElectronShell()) return;
let cancelled = false;
const refresh = () => {
void getHostIdentity().then((s) => {
if (!cancelled) setDesktopHost(s);
});
};
refresh();
const unsubscribe = onHostStatusChanged(refresh);
return () => {
cancelled = true;
unsubscribe();
};
}, []);
// Auto-select the FIRST AVAILABLE option, mirroring the menu order, so
// a session can be started without an explicit pick: the sandbox when
// the server supports it (it's pinned first in the picker), else the
@@ -1190,9 +1239,11 @@ export function NewChatLandingScreen() {
const workspaceLabel = workspaceTrimmed
? (workspaceTrimmed.split("/").filter(Boolean).pop() ?? workspaceTrimmed)
: "Working directory";
const hostLabel = sandboxSelected
? sandboxLabel
: (selectedHost?.name ?? (onlineHosts.length === 0 ? "No hosts" : "Select host"));
const hostLabel = connectingThisMachine
? "Connecting…"
: sandboxSelected
? sandboxLabel
: (selectedHost?.name ?? (onlineHosts.length === 0 ? "No hosts" : "Select host"));
const worktreeLabel = branchName.trim() || "No worktree";
// Sandbox repository chip label: repo name (server's clone-dir rule)
// plus the pinned branch, e.g. "repo#main"; placeholder when unset.
@@ -1320,6 +1371,25 @@ export function NewChatLandingScreen() {
seededHostRef.current = null;
}
// Connect THIS desktop machine as a host for the current server, then select
// it — so the user doesn't have to run `omni host` in a terminal first. The
// bridge's controlHost resolves once the host is connected; we then read its
// id, refresh the host list, and pick it.
async function connectThisMachine() {
if (connectingThisMachine) return;
setConnectingThisMachine(true);
try {
const res = await controlHost("start");
if (!res.ok) return;
const identity = await getHostIdentity();
setDesktopHost(identity);
await queryClient.invalidateQueries({ queryKey: ["hosts"] });
if (identity?.hostId) selectHost(identity.hostId);
} finally {
setConnectingThisMachine(false);
}
}
async function handleCreate() {
// Mirror the Send button's disabled condition (canSubmit) so the Enter-key
// and form-submit paths that call this directly can't create a session with
@@ -1776,7 +1846,16 @@ export function NewChatLandingScreen() {
<div className="relative z-0 -mt-9 flex w-full items-center rounded-b-2xl bg-tray/40 pt-8 pr-3 pb-2 pl-2">
<div className="flex flex-wrap items-center gap-1.5">
{/* Host chip */}
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
// Run a requested "connect this machine" only once the menu
// has closed.
if (!open && pendingConnectRef.current) {
pendingConnectRef.current = false;
void connectThisMachine();
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -1789,7 +1868,7 @@ export function NewChatLandingScreen() {
<MonitorIcon className="size-4 shrink-0" />
)}
<span
className={`max-w-32 truncate ${sandboxSelected || selectedHost != null ? "text-foreground" : ""}`}
className={`max-w-32 truncate ${sandboxSelected || selectedHost != null || connectingThisMachine ? "text-foreground" : ""}`}
>
{hostLabel}
</span>
@@ -1848,7 +1927,7 @@ export function NewChatLandingScreen() {
<DropdownMenuSeparator />
</>
)}
{allHosts.length === 0 && (
{allHosts.length === 0 && !showConnectThisMachine && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No hosts connected yet.
</div>
@@ -1860,15 +1939,65 @@ export function NewChatLandingScreen() {
data-active={host.host_id === selectedHostId ? "true" : undefined}
className="text-xs data-[active=true]:bg-accent/60"
>
<HostOption host={host} />
<HostOption
host={host}
subtitle={host.host_id === thisMachineHostId ? "this machine" : undefined}
/>
</DropdownMenuItem>
))}
{offlineHosts.map((host) => (
<DropdownMenuItem key={host.host_id} disabled className="text-xs">
<HostOption host={host} />
{offlineHosts.map((host) => {
// This machine, offline: make the row itself the connect
// affordance instead of a disabled entry + a duplicate "Run
// on this machine" item. Connect after the menu closes.
if (host.host_id === thisMachineHostId && canConnectThisMachine) {
return (
<DropdownMenuItem
key={host.host_id}
onSelect={() => {
pendingConnectRef.current = true;
}}
disabled={connectingThisMachine}
data-testid="new-chat-landing-run-on-this-machine"
className="text-xs"
>
<HostOption
host={host}
subtitle={
connectingThisMachine
? "connecting…"
: "this machine · select to connect"
}
/>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem key={host.host_id} disabled className="text-xs">
<HostOption
host={host}
subtitle={host.host_id === thisMachineHostId ? "this machine" : undefined}
/>
</DropdownMenuItem>
);
})}
{/* Desktop shell, machine not in the list yet: offer to connect
it in one click. */}
{showConnectThisMachine && (
<DropdownMenuItem
onSelect={() => {
pendingConnectRef.current = true;
}}
disabled={connectingThisMachine}
data-testid="new-chat-landing-run-on-this-machine"
className="gap-2 text-xs"
>
<MonitorIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="text-xs">
{connectingThisMachine ? "Connecting this machine…" : "Run on this machine"}
</span>
</DropdownMenuItem>
))}
{allHosts.length > 0 && <DropdownMenuSeparator />}
)}
{(allHosts.length > 0 || showConnectThisMachine) && <DropdownMenuSeparator />}
{/* Persistent escape hatch: open the connect-a-host
instructions. Present even with zero hosts so a fresh user
is never stuck. */}