feat(desktop): auto-discover Honcho instances on localhost (#1)

* feat(desktop): auto-discover Honcho instances on localhost

First-launch and on-demand discovery of running Honcho instances on
127.0.0.1:8000-8100. Desktop-only — the browser can't port-scan due to
CORS, so the scan runs in the Tauri Rust shell.

- New Rust command `discover_honcho_instances(start_port?, end_port?)`
  in src-tauri/src/discover.rs: parallel TCP probe of each port with
  150ms connect timeout + 250ms total request budget, looking for a
  /health endpoint returning `{"status":"ok"}`.
- New TS helper `discoverHonchoInstances()` in src/lib/discovery.ts:
  detects Tauri runtime; web build degrades to an empty result.
- `suggestNameForInstance()` fetches the first workspace and derives a
  friendly name from its id ("neo-personal" -> "Neo"). Used to seed the
  Name field for each discovered instance.
- `DiscoveredInstances` component: list of found instances with
  editable suggested names + per-row checkbox + "Add N instances"
  button. Rows are pre-checked by default and filter out already-
  configured baseUrls.
- Wired into `InstancesManager`:
  - First launch (no instances): renders above the connection-type
    chooser with autoRun=true. User sees results immediately.
  - With instances: adds a "Discover instances" button to the list
    view that opens the discovery flow on demand.
  - Both paths gate on `isTauri()`; the web build keeps its existing
    behaviour unchanged.

Adds tokio (with net + io-util + time + rt + macros) and futures to
the Tauri shell to get async TCP + join_all.

Tests:
- deriveNameFromWorkspaceId handles hyphenated, multi-segment, and
  no-hyphen ids

* test(desktop): add probe tests including live Hermes stack integration

- rejects_inverted_port_range
- ignores_ports_with_no_listener
- finds_live_hermes_stacks (#[ignore]d; opt-in with --ignored)

The ignored test exercises discover_honcho_instances against ports
8000-8010, expecting exactly [8001, 8002, 8003, 8004, 8005]. Verified
locally — the probe finds all 5 stacks in <10ms.

---------

Co-authored-by: Agents <agents@Jarviss-Mac-mini.local>
This commit is contained in:
Ben Sheridan-Edwards
2026-05-24 17:01:14 +01:00
committed by Offending Commit
parent 6960bf4ffe
commit 7355884051
8 changed files with 463 additions and 6 deletions

View File

@@ -923,6 +923,21 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -930,6 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -984,6 +1000,7 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2268,6 +2285,7 @@ dependencies = [
name = "openconcho"
version = "0.8.0"
dependencies = [
"futures",
"serde",
"serde_json",
"tauri",
@@ -2275,6 +2293,7 @@ dependencies = [
"tauri-plugin-deep-link",
"tauri-plugin-http",
"tauri-plugin-shell",
"tokio",
]
[[package]]

View File

@@ -17,3 +17,5 @@ tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "io-util", "time", "rt", "macros"] }
futures = "0.3"

View File

@@ -0,0 +1,98 @@
//! Localhost Honcho instance discovery.
//!
//! Probes a range of ports on 127.0.0.1 for a Honcho `/health` endpoint
//! that returns `{"status":"ok"}`. Desktop-only feature: the browser
//! can't port-scan due to CORS, so this lives in the Tauri Rust shell.
use serde::Serialize;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const DEFAULT_START_PORT: u16 = 8000;
const DEFAULT_END_PORT: u16 = 8100;
const CONNECT_TIMEOUT_MS: u64 = 150;
const REQUEST_TIMEOUT_MS: u64 = 250;
#[derive(Serialize, Debug)]
pub struct DiscoveredInstance {
pub port: u16,
pub base_url: String,
}
async fn probe_port(port: u16) -> Option<DiscoveredInstance> {
let addr = format!("127.0.0.1:{}", port);
let connect = TcpStream::connect(&addr);
let stream = tokio::time::timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), connect)
.await
.ok()?
.ok()?;
let mut stream = stream;
let req = b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
let io = async {
stream.write_all(req).await.ok()?;
let mut buf = Vec::with_capacity(512);
stream.read_to_end(&mut buf).await.ok()?;
Some(buf)
};
let buf = tokio::time::timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), io)
.await
.ok()??;
let body = String::from_utf8_lossy(&buf);
if body.contains("\"status\":\"ok\"") {
Some(DiscoveredInstance {
port,
base_url: format!("http://127.0.0.1:{}", port),
})
} else {
None
}
}
#[tauri::command]
pub async fn discover_honcho_instances(
start_port: Option<u16>,
end_port: Option<u16>,
) -> Vec<DiscoveredInstance> {
let start = start_port.unwrap_or(DEFAULT_START_PORT);
let end = end_port.unwrap_or(DEFAULT_END_PORT);
if end < start {
return Vec::new();
}
let probes: Vec<_> = (start..=end).map(probe_port).collect();
let results = futures::future::join_all(probes).await;
let mut found: Vec<DiscoveredInstance> = results.into_iter().flatten().collect();
found.sort_by_key(|d| d.port);
found
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn rejects_inverted_port_range() {
let found = discover_honcho_instances(Some(9000), Some(8000)).await;
assert!(found.is_empty());
}
#[tokio::test]
async fn ignores_ports_with_no_listener() {
// Port 1 should reliably have no listener — connect fails fast.
let result = probe_port(1).await;
assert!(result.is_none());
}
#[tokio::test]
#[ignore = "requires live Honcho stacks on 8001-8005"]
async fn finds_live_hermes_stacks() {
let found = discover_honcho_instances(Some(8000), Some(8010)).await;
let ports: Vec<u16> = found.iter().map(|d| d.port).collect();
assert_eq!(ports, vec![8001, 8002, 8003, 8004, 8005]);
}
}

View File

@@ -1,9 +1,12 @@
mod discover;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.invoke_handler(tauri::generate_handler![discover::discover_honcho_instances])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,209 @@
import { motion } from "framer-motion";
import { Loader, RefreshCw, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import { COLOR } from "@/lib/constants";
import {
type DiscoveredInstance,
discoverHonchoInstances,
suggestNameForInstance,
} from "@/lib/discovery";
interface Row {
discovered: DiscoveredInstance;
suggestedName: string;
checked: boolean;
}
interface Props {
/** If true, scan as soon as the component mounts. */
autoRun?: boolean;
/** Called after the user has added at least one instance. */
onAdded?: () => void;
}
export function DiscoveredInstances({ autoRun = false, onAdded }: Props) {
const { instances, add, activate } = useInstances();
const [scanning, setScanning] = useState(false);
const [hasScanned, setHasScanned] = useState(false);
const [rows, setRows] = useState<Row[]>([]);
const existingBaseUrls = useMemo(
() => new Set(instances.map((i) => i.baseUrl.replace(/\/+$/, "").toLowerCase())),
[instances],
);
const runScan = useCallback(async () => {
setScanning(true);
try {
const found = await discoverHonchoInstances();
const fresh = found.filter(
(d) => !existingBaseUrls.has(d.base_url.replace(/\/+$/, "").toLowerCase()),
);
const named = await Promise.all(
fresh.map(async (d) => {
const name = (await suggestNameForInstance(d.base_url)) ?? `Honcho :${d.port}`;
return { discovered: d, suggestedName: name, checked: true } satisfies Row;
}),
);
setRows(named);
} finally {
setScanning(false);
setHasScanned(true);
}
}, [existingBaseUrls]);
useEffect(() => {
if (autoRun) void runScan();
}, [autoRun, runScan]);
function setRowChecked(port: number, checked: boolean) {
setRows((r) => r.map((row) => (row.discovered.port === port ? { ...row, checked } : row)));
}
function setRowName(port: number, suggestedName: string) {
setRows((r) =>
r.map((row) => (row.discovered.port === port ? { ...row, suggestedName } : row)),
);
}
function addSelected() {
const selected = rows.filter((r) => r.checked);
if (selected.length === 0) return;
let firstId: string | null = null;
for (const row of selected) {
const created = add({
name: row.suggestedName.trim() || `Honcho :${row.discovered.port}`,
baseUrl: row.discovered.base_url,
token: "",
});
if (firstId === null) firstId = created.id;
}
if (firstId) activate(firstId);
setRows([]);
onAdded?.();
}
const selectedCount = rows.filter((r) => r.checked).length;
return (
<div
className="rounded-2xl p-5 space-y-3"
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="w-4 h-4 shrink-0"
style={{ color: COLOR.accentText }}
strokeWidth={1.5}
/>
<div>
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
Discover local Honcho instances
</p>
<Muted className="text-xs">Scans 127.0.0.1:80008100 for running instances</Muted>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => void runScan()}
disabled={scanning}
className="rounded-xl px-3 py-2"
title="Rescan"
>
{scanning ? (
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
>
<Loader className="w-4 h-4" strokeWidth={1.5} />
</motion.div>
) : (
<RefreshCw className="w-4 h-4" strokeWidth={1.5} />
)}
<span className="hidden sm:inline ml-1.5 text-xs">
{scanning ? "Scanning…" : "Rescan"}
</span>
</Button>
</div>
{hasScanned && !scanning && rows.length === 0 && (
<Muted className="text-xs">
No new instances found. Add one manually below if you know its URL.
</Muted>
)}
{rows.length > 0 && (
<>
<div className="space-y-1.5">
{rows.map((row) => (
<DiscoveredRow
key={row.discovered.port}
row={row}
onCheck={(c) => setRowChecked(row.discovered.port, c)}
onRename={(n) => setRowName(row.discovered.port, n)}
/>
))}
</div>
<Button
type="button"
variant="accent"
onClick={addSelected}
disabled={selectedCount === 0}
className="w-full rounded-xl py-2.5"
>
{selectedCount === 0
? "Select at least one"
: `Add ${selectedCount} instance${selectedCount === 1 ? "" : "s"}`}
</Button>
</>
)}
</div>
);
}
interface DiscoveredRowProps {
row: Row;
onCheck: (checked: boolean) => void;
onRename: (name: string) => void;
}
function DiscoveredRow({ row, onCheck, onRename }: DiscoveredRowProps) {
return (
<div
className="flex items-center gap-2.5 rounded-xl px-3 py-2"
style={{
background: row.checked ? "var(--surface)" : "transparent",
border: `1px solid ${row.checked ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<input
type="checkbox"
checked={row.checked}
onChange={(e) => onCheck(e.target.checked)}
className="w-4 h-4 shrink-0 cursor-pointer"
aria-label={`Select ${row.suggestedName}`}
/>
<input
type="text"
value={row.suggestedName}
onChange={(e) => onRename(e.target.value)}
className="flex-1 min-w-0 bg-transparent text-sm font-medium border-0 outline-none px-1 py-0.5 rounded"
style={{ color: "var(--text-1)" }}
aria-label={`Name for instance on port ${row.discovered.port}`}
disabled={!row.checked}
/>
<span className="text-xs font-mono shrink-0" style={{ color: "var(--text-4)" }}>
:{row.discovered.port}
</span>
</div>
);
}

View File

@@ -1,12 +1,24 @@
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
import {
Check,
ChevronRight,
Cloud,
Pencil,
Plus,
Radar,
Server,
Sparkles,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { DiscoveredInstances } from "@/components/settings/DiscoveredInstances";
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { isTauri } from "@/lib/discovery";
const LOCALHOST_PROBE_URL = "http://localhost:8000";
@@ -14,7 +26,8 @@ type Mode =
| { kind: "list" }
| { kind: "choose-type" }
| { kind: "create"; preset: ConnectionPreset }
| { kind: "edit"; id: string };
| { kind: "edit"; id: string }
| { kind: "discover" };
interface InstancesManagerProps {
onActivated?: () => void;
@@ -23,16 +36,44 @@ interface InstancesManagerProps {
export function InstancesManager({ onActivated }: InstancesManagerProps) {
const { instances, activeId, activate, remove } = useInstances();
const isFirstRun = instances.length === 0;
const inTauri = isTauri();
const [mode, setMode] = useState<Mode>(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
const backFromCreate = () => setMode(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
if (mode.kind === "discover") {
return (
<div className="space-y-3">
<DiscoveredInstances autoRun onAdded={() => setMode({ kind: "list" })} />
<Button
type="button"
variant="ghost"
onClick={() => setMode({ kind: "list" })}
className="w-full py-2.5 px-4 rounded-xl"
>
Back
</Button>
</div>
);
}
if (mode.kind === "choose-type") {
return (
<ConnectionTypeChooser
onPick={(preset) => setMode({ kind: "create", preset })}
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
/>
<div className="space-y-3">
{inTauri && (
<DiscoveredInstances
autoRun
onAdded={() => {
setMode({ kind: "list" });
onActivated?.();
}}
/>
)}
<ConnectionTypeChooser
onPick={(preset) => setMode({ kind: "create", preset })}
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
/>
</div>
);
}
@@ -82,6 +123,18 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
))}
</div>
{inTauri && (
<Button
type="button"
variant="ghost"
onClick={() => setMode({ kind: "discover" })}
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
>
<Radar className="w-4 h-4" strokeWidth={1.5} />
Discover instances
</Button>
)}
<Button
type="button"
variant="ghost"

View File

@@ -0,0 +1,57 @@
import { httpFetch } from "@/lib/http";
export interface DiscoveredInstance {
port: number;
base_url: string;
}
export function isTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/**
* Probe localhost ports for running Honcho instances. Desktop-only — the web
* build can't port-scan due to CORS, so this returns an empty list when not
* running inside Tauri.
*/
export async function discoverHonchoInstances(): Promise<DiscoveredInstance[]> {
if (!isTauri()) return [];
const { invoke } = await import("@tauri-apps/api/core");
return invoke<DiscoveredInstance[]>("discover_honcho_instances");
}
/**
* Derive a friendly agent name from a workspace ID. Honcho workspaces follow
* the convention `<agent>-<camp>` (e.g. "neo-personal", "jeeves-codewalnut"),
* so we take the prefix and capitalize it. Falls back to the full ID if no
* hyphen is present.
*/
export function deriveNameFromWorkspaceId(workspaceId: string): string {
const first = workspaceId.split("-")[0] || workspaceId;
return first.charAt(0).toUpperCase() + first.slice(1);
}
/**
* Probe a discovered instance for its first workspace and derive a suggested
* name from it. Returns `null` if the instance has no workspaces or the
* request fails.
*/
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
try {
const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return null;
const data = (await res.json()) as { items?: Array<{ id?: string }> };
const wsId = data.items?.[0]?.id;
if (typeof wsId === "string" && wsId.length > 0) {
return deriveNameFromWorkspaceId(wsId);
}
return null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { deriveNameFromWorkspaceId } from "@/lib/discovery";
describe("deriveNameFromWorkspaceId", () => {
it("capitalizes the prefix before the first hyphen", () => {
expect(deriveNameFromWorkspaceId("neo-personal")).toBe("Neo");
});
it("handles a multi-segment workspace id by taking only the first segment", () => {
expect(deriveNameFromWorkspaceId("jeeves-codewalnut-work")).toBe("Jeeves");
});
it("falls back to capitalizing the full id when there is no hyphen", () => {
expect(deriveNameFromWorkspaceId("standalone")).toBe("Standalone");
});
});