Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af3cc05eb7 | |||
| a32e8f2e18 | |||
| f9db9f62a5 | |||
| abac666a56 | |||
| 95467f9875 | |||
| cacee20248 | |||
| 40f7f2c6b9 | |||
| 50efaef93c | |||
| bf90a4549f | |||
| 49fb9f8d15 | |||
| 20f8072eaa | |||
| 1027a44b0d | |||
| 9a377c6063 | |||
| 466ab0c902 | |||
| a740573096 | |||
| b3f6ae4a52 | |||
| 06ee549bea | |||
| cf557ac385 | |||
| 417c7895d6 | |||
| b9b23d93b6 | |||
| 07285f1a02 | |||
| d464948a33 | |||
| 3f91d74245 | |||
| d9ba4a0504 | |||
| fd8d0422ce | |||
| 171743a6e1 | |||
| af9f4d6e20 | |||
| dadf7dc44b | |||
| 99d9365771 | |||
| 7575f6dd05 | |||
| 70432dc3fe | |||
| 4d73131d71 | |||
| 01dd150b0a | |||
| 5fc3fa6632 | |||
| 24cbb6ac0d | |||
| 9f90cc2925 | |||
| dfcde6c57a | |||
| 013fdbec07 | |||
| a8639eb9c5 | |||
| 07b15efd5c | |||
| 9009010da7 | |||
| 3e6f3dde64 | |||
| 066f87ec37 | |||
| 360db7c317 | |||
| df12913b14 |
@@ -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
|
||||
|
||||
Generated
+3
-2
@@ -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",
|
||||
|
||||
@@ -96,5 +96,8 @@
|
||||
"nsis"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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"),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user