refactor(maestro): flatten web UI to a single host

Session tree nests by working directory only; the daemon chips in the
header become a session tally, and the spawn form loses its host picker.
API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs,
/api/gitroot, and /api/info.
This commit is contained in:
Miguel Palhas
2026-08-22 10:14:10 +01:00
parent 6790f41652
commit 1bb464be4b
14 changed files with 155 additions and 467 deletions
+11 -9
View File
@@ -207,12 +207,12 @@ pub async fn pump_ws(mut client: WebSocket, upstream_url: String) {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed");
// The daemon rejects broker attaches it can't route (no shared
// perimeter, peer down, unknown session — NAP-247) with a structured
// HTTP error *before* the upgrade. We've already upgraded the browser
// socket, so a bare drop reaches it as an opaque 1006 close — the
// attach pane just flashes and vanishes with no reason. Relay the
// daemon's reason as an application close frame the UI can show (#39).
// The daemon rejects an attach it can't serve (unknown session)
// with a structured HTTP error *before* the upgrade. We've already
// upgraded the browser socket, so a bare drop reaches it as an
// opaque 1006 close — the attach pane just flashes and vanishes with
// no reason. Relay the daemon's reason as an application close frame
// the UI can show (#39).
let (code, reason) = upstream_close_reason(&e);
let _ = client
.send(AxMessage::Close(Some(axum::extract::ws::CloseFrame {
@@ -397,14 +397,16 @@ mod tests {
#[test]
fn upstream_close_reason_extracts_daemon_message() {
use tokio_tungstenite::tungstenite::{http, Error as WsError};
let body = br#"{"error":{"code":"no_perimeter_candidate","message":"peer hasn't advertised a perimeter; T2 unavailable"}}"#.to_vec();
let body =
br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"#
.to_vec();
let resp = http::Response::builder()
.status(502)
.status(404)
.body(Some(body))
.unwrap();
let (code, reason) = upstream_close_reason(&WsError::Http(resp));
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert_eq!(reason, "peer hasn't advertised a perimeter; T2 unavailable");
assert_eq!(reason, "no such session on this daemon");
}
/// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable
+3 -3
View File
@@ -20,8 +20,8 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
async fn list_sessions() -> Json<serde_json::Value> {
Json(serde_json::json!([{"id": "session-1"}]))
}
async fn web_info() -> Json<serde_json::Value> {
Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"}))
async fn daemon_info() -> Json<serde_json::Value> {
Json(serde_json::json!({"hostname": "mock-host"}))
}
async fn echo_ws(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(echo_handler)
@@ -43,7 +43,7 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
let app = Router::new()
.route("/api/sessions", get(list_sessions))
.route("/api/web/info", get(web_info))
.route("/api/info", get(daemon_info))
.route("/api/attach/:id", any(echo_ws));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+15 -22
View File
@@ -7,7 +7,6 @@ import type {
RecentDirs,
Session,
Uuid,
WebInfo,
} from "./types.js";
async function jsonOrError<T>(res: Response): Promise<T> {
@@ -25,10 +24,6 @@ async function jsonOrError<T>(res: Response): Promise<T> {
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
export async function listDaemons(): Promise<DaemonInfo[]> {
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
}
export async function listSessions(): Promise<Session[]> {
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
}
@@ -53,31 +48,29 @@ export async function killSession(id: Uuid): Promise<void> {
);
}
/** List one directory's immediate subdirectories on `daemonId`'s host (NAP-329).
* `path` may be empty, `~`, relative, or absolute — the daemon resolves it
* against its own home dir. */
export async function listDir(daemonId: string, path: string): Promise<DirListing> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/dirs?path=${encodeURIComponent(path)}`;
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
* `~`, relative, or absolute — the daemon resolves it against its home dir. */
export async function listDir(path: string): Promise<DirListing> {
const url = `/api/dirs?path=${encodeURIComponent(path)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** Identity + spawn presets of the daemon this web app is connected to (#26). */
export async function getWebInfo(): Promise<WebInfo> {
return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" }));
export async function getDaemonInfo(): Promise<DaemonInfo> {
return jsonOrError(await fetch("/api/info", { credentials: "same-origin" }));
}
/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by
* dir (#26). Drives the spawn form's recents picker. */
export async function recentDirs(daemonId: string): Promise<RecentDirs> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
* spawn form's recents picker. */
export async function recentDirs(): Promise<RecentDirs> {
return jsonOrError(await fetch("/api/recent-dirs", { credentials: "same-origin" }));
}
/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is
* absent when the path isn't inside a repo. `path` may be `~`/relative/absolute;
* the daemon resolves it against its home dir. */
export async function gitRoot(daemonId: string, path: string): Promise<GitRoot> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`;
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
* it against its home dir. */
export async function gitRoot(path: string): Promise<GitRoot> {
const url = `/api/gitroot?path=${encodeURIComponent(path)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
+10 -16
View File
@@ -36,12 +36,11 @@ export interface AttachOptions {
onClosed?: (sessionId: Uuid) => void;
}
/** What the header shows about the session (name / command / host). Optional —
* falls back to a short session id when the session isn't known yet. */
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
daemon: string;
}
function esc(s: string): string {
@@ -53,10 +52,9 @@ function esc(s: string): string {
}
/**
* Same-origin broker URL. The browser loads from `maestro-web`, which
* Same-origin attach URL. The browser loads from `maestro-web`, which
* reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id`
* broker endpoint (the daemon short-circuits to the local PTY when it owns the
* session, or brokers to the owning peer — NAP-247/NAP-327).
* endpoint.
*/
function brokerStreamUrl(sessionId: Uuid): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -70,18 +68,15 @@ export function createAttachConnection(
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command @ host. The full id stays
// in the tooltip for when you really need it.
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${esc(info.name)}</span>` +
(info.command && info.command !== info.name
? `<span class="at-cmd">${esc(info.command)}</span>`
: "") +
(info.daemon ? `<span class="at-host">${esc(info.daemon)}</span>` : "")
: "")
: `session <span class="sid">${esc(sessionId.slice(0, 8))}</span>`;
const titleAttr = info
? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})`
: sessionId;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
@@ -317,9 +312,8 @@ export function createAttachConnection(
};
ws.onclose = (ev) => {
// The web proxy relays a broker/daemon rejection (no shared perimeter, peer
// down, unknown session) as an application close code (4000+) carrying a
// human reason. Show it in the pane — both the header and the terminal body
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the pane — both the header and the terminal body
// — and KEEP the pane up so the user can read why, instead of letting it
// flash and vanish (#39). Clean session-ends and ordinary drops keep the
// auto-close behavior below.
+39 -94
View File
@@ -1,7 +1,7 @@
import { killSession } from "./api.js";
import { getDaemonInfo, killSession } from "./api.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { DaemonInfo, Session } from "./types.js";
import type { Session } from "./types.js";
import { abbreviateCwd, abbreviateFolderName, sessionCwd, sessionName } from "./types.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
@@ -29,26 +29,18 @@ function compareNodes(a: TreeNode, b: TreeNode): number {
return an.localeCompare(bn);
}
function buildTree(
sessions: Session[],
daemons: Map<string, DaemonInfo>,
filter: string,
): TreeNode {
function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const home = daemons.get(s.daemon_id)?.home_dir;
const cwd = sessionCwd(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase();
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
// Host is the first level of nesting; the working directory nests beneath
// it, so the daemon no longer needs to ride along on each leaf.
const dirParts = dir ? dir.split("/").filter(Boolean) : [];
const parts = [s.daemon_id, ...dirParts];
const parts = dir ? dir.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
@@ -69,16 +61,9 @@ function buildTree(
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
// Collapse linear directory chains *within* each daemon (but never the daemon
// node itself) so `~/git.naps.pt/naps62/maestro` reads as one compact folder
// rather than four nested ones.
for (const daemon of root.children) {
if (daemon.kind === "folder") {
daemon.children = daemon.children.map((c) =>
c.kind === "folder" ? mergeLinear(c) : c,
);
}
}
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
// one compact folder rather than four nested ones.
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
return root;
}
@@ -145,11 +130,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`,
);
// The host is now the enclosing folder, so only surface the command — and
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
row.innerHTML = `
@@ -165,8 +147,6 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
// The sidebar (which has the daemon list) opens the spawn modal pre-seeded
// from this session.
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
@@ -191,42 +171,19 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}
function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement {
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
// Per-host session counts replace the old global "N sessions / N live" tally:
// each daemon chip carries its own green count, so the tally was redundant.
const counts = new Map<string, number>();
for (const s of sessions) {
counts.set(s.daemon_id, (counts.get(s.daemon_id) ?? 0) + 1);
}
const daemonsWrap = document.createElement("div");
daemonsWrap.className = "daemons";
if (daemons.length === 0) {
daemonsWrap.innerHTML = `<span class="daemon-empty">no daemons connected</span>`;
} else {
for (const d of daemons) {
const n = counts.get(d.daemon_id) ?? 0;
const chip = document.createElement("span");
chip.className = "daemon-chip";
chip.title = `${d.hostname} · ${d.daemon_url} · ${n} session${n === 1 ? "" : "s"}`;
chip.innerHTML =
`<span class="daemon-name">${escapeHtml(d.daemon_id)}</span>` +
`<b class="daemon-count${n === 0 ? " is-empty" : ""}">${n}</b>`;
daemonsWrap.appendChild(chip);
}
}
wrap.appendChild(daemonsWrap);
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("span");
tally.className = "session-tally";
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
wrap.appendChild(tally);
return wrap;
}
function renderEmpty(
noDaemons: boolean,
filtering: boolean,
onSpawn: () => void,
): HTMLElement {
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
@@ -236,14 +193,6 @@ function renderEmpty(
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
return el;
}
if (noDaemons) {
el.classList.add("connecting");
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">◴</div>
<div class="empty-title">Waiting for a daemon</div>
<p class="empty-hint">No daemons are connected yet. Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
return el;
}
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
@@ -299,13 +248,11 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
onToggleSidebar?.(),
);
// Session tally + daemon chips live centered in the header (not the sidebar).
// Session tally lives centered in the header (not the sidebar).
const statusHost = header.querySelector(".app-status") as HTMLElement;
const renderStatus = () => {
const state = store.snapshot();
statusHost.replaceChildren(
renderStatusbar([...state.daemons.values()], [...state.sessions.values()]),
);
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
};
store.subscribe(renderStatus);
renderStatus();
@@ -327,10 +274,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
spawnBtn.addEventListener("click", () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
spawnBtn.addEventListener("click", () => openSpawnModal());
actions.appendChild(spawnBtn);
return header;
}
@@ -359,7 +303,7 @@ export interface SidebarHandle {
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live daemon/session status line, and the folder-grouped session tree. Unlike
* live session status line, and the folder-grouped session tree. Unlike
* the old full-screen catalog this is mounted once by the shell and stays put
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
@@ -374,6 +318,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const el = document.createElement("aside");
el.className = "sidebar";
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
// renders with absolute paths until it lands.
let homeDir: string | undefined;
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
@@ -474,14 +422,11 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const refresh = () => {
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, state.daemons, filter);
const tree = buildTree(sessions, homeDir, filter);
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
}),
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
);
return;
}
@@ -491,24 +436,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyHighlights();
};
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
refresh();
})
.catch(() => {
/* absolute paths are a fine fallback */
});
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
// Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir,
// and command (matched back to a preset by the form) — but with a freshly
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
// command (matched back to a preset by the form) — but with a freshly
// generated name rather than the original's, since it's a new session.
const spawnSimilarHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const snap = store.snapshot();
openSpawnModal(
snap.daemons,
{
daemon_id: s.daemon_id,
cwd: s.cwd,
command: s.command,
},
snap.sessions,
);
openSpawnModal({ cwd: s.cwd, command: s.command });
};
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
Binary file not shown.
+7 -19
View File
@@ -1,15 +1,14 @@
import { eventsWsUrl, listDaemons, listSessions } from "./api.js";
import type { DaemonInfo, RegistryEvent, Session, Uuid } from "./types.js";
import { eventsWsUrl, listSessions } from "./api.js";
import type { CatalogEvent, Session, Uuid } from "./types.js";
export interface CatalogState {
daemons: Map<string, DaemonInfo>;
sessions: Map<Uuid, Session>;
}
export type CatalogListener = (state: CatalogState) => void;
export class CatalogStore {
private state: CatalogState = { daemons: new Map(), sessions: new Map() };
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
@@ -46,9 +45,9 @@ export class CatalogStore {
this.ws = ws;
ws.onmessage = (ev) => {
let event: RegistryEvent;
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as RegistryEvent;
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
@@ -83,23 +82,13 @@ export class CatalogStore {
}, delay);
}
private apply(event: RegistryEvent): void {
private apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
this.state = {
daemons: new Map(event.daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(event.sessions.map((s) => [s.id, s])),
};
break;
case "DaemonAdded":
this.state.daemons.set(event.daemon.daemon_id, event.daemon);
break;
case "DaemonRemoved":
this.state.daemons.delete(event.daemon_id);
for (const [id, sess] of this.state.sessions) {
if (sess.daemon_id === event.daemon_id) this.state.sessions.delete(id);
}
break;
case "SessionAdded":
this.state.sessions.set(event.session.id, event.session);
break;
@@ -119,9 +108,8 @@ export class CatalogStore {
private async resync(): Promise<void> {
try {
const [daemons, sessions] = await Promise.all([listDaemons(), listSessions()]);
const sessions = await listSessions();
this.state = {
daemons: new Map(daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(sessions.map((s) => [s.id, s])),
};
this.emit();
-1
View File
@@ -15,7 +15,6 @@ import { sessionName, type Session, type Uuid } from "./types.js";
* them. Everything else on the Session is shown, including future fields. */
const CLIENT_OWNED = new Set([
"id",
"daemon_id",
"cwd",
"command",
"env",
+13 -23
View File
@@ -55,7 +55,6 @@ function folderHeader(name: string, count: number, depth: number): HTMLElement {
function leafRow(opts: {
name: string;
daemon: string;
cmd: string;
status: "running" | "dead";
active?: boolean;
@@ -192,8 +191,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
body.append(
specimen("daemon-chip", h("span", "daemon-chip", "thinkpad")),
specimen("daemon-empty", h("span", "daemon-empty", "no daemons connected")),
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
specimen(
@@ -217,23 +215,20 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"Host is the first level of nesting; the working directory (home collapsed to ~) nests beneath it, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("thinkpad", 3, 0),
folderHeader("~/git/maestro", 3, 1),
folderHeader("web", 2, 2),
leafRow({ name: "redesign", daemon: "", cmd: "claude", status: "running", indent: 3 }),
leafRow({ name: "dev-server", daemon: "", cmd: "vite dev", status: "running", indent: 3, active: true }),
folderHeader("crates/registry", 1, 2),
leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }),
folderHeader("tower", 2, 0),
folderHeader("~/work/acme", 1, 1),
leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "scratch", daemon: "", cmd: "bash", status: "dead", indent: 1 }),
folderHeader("pi", 1, 0),
leafRow({ name: "monitor", daemon: "", cmd: "btop", status: "running", indent: 1 }),
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
folderHeader("crates/daemon", 1, 1),
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
folderHeader("~/work/acme", 2, 0),
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
body.appendChild(list);
@@ -258,12 +253,7 @@ export function renderKit(): HTMLElement {
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
const waiting = h("div", "empty connecting");
waiting.innerHTML = `
<div class="empty-glyph" aria-hidden="true">◴</div>
<div class="empty-title">Waiting for a daemon</div>
<p class="empty-hint">Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
body.append(welcome, noSessions, waiting);
body.append(welcome, noSessions);
main.appendChild(el);
}
+2 -5
View File
@@ -176,7 +176,7 @@ function scheduleSave(): void {
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command, daemon: s.daemon_id } : undefined;
return s ? { name: sessionName(s), command: s.command } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
@@ -200,10 +200,7 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
spawn.addEventListener("click", () => openSpawnModal());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
+19 -35
View File
@@ -2,7 +2,7 @@
// main.ts (`?mock`), so it never ships in a production bundle. It pushes a
// synthetic Snapshot through the store's normal event path.
import type { CatalogStore } from "./events.js";
import type { ActivityState, Session, DaemonInfo } from "./types.js";
import type { ActivityState, Session } from "./types.js";
// Rotate inferred activity across the fixture so the debug HUD (#20) shows all
// three states. Keyed off the id's last hex digit for stable variety.
@@ -10,7 +10,6 @@ const ACTIVITIES: ActivityState[] = ["Active", "Idle", "AwaitingInput"];
function sess(
id: string,
daemon: string,
command: string,
status: "Running" | "Dead",
cwd: string,
@@ -19,7 +18,6 @@ function sess(
const seed = parseInt(id.slice(-1), 16) || 0;
return {
id,
daemon_id: daemon,
cwd,
command,
env: {},
@@ -48,13 +46,12 @@ function stubSpawnApi(): void {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
if (path.startsWith("/api/web/info")) {
if (path.startsWith("/api/info")) {
return Promise.resolve(
json({
daemon_id: "thinkpad",
hostname: "thinkpad.lan",
version: "0.1.0",
daemon_url: "wss://thinkpad.lan/daemon",
home_dir: "/home/dev",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
@@ -87,38 +84,31 @@ function stubSpawnApi(): void {
}
export function seedMock(store: CatalogStore): void {
const daemons: DaemonInfo[] = [
{ daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" },
{ daemon_id: "tower", hostname: "tower.lan", version: "0.1.0", daemon_url: "wss://tower.lan/daemon", home_dir: "/home/dev" },
{ daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" },
];
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", "/home/dev/git/maestro/crates/registry", "registry-broker"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/registry", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
// pi's home (different home_dir) — sits directly under ~.
sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", "/home/pi", "monitor"),
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
sess("a1b2c3d4-0000-0000-0000-000000000011", "btop", "Running", "/home/dev/git/infra", "monitor"),
// outside any home dir → shown verbatim (no ~ collapse).
sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", "/etc"),
// home root on thinkpad — a leaf directly under ~ (name from command).
sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", "/home/dev"),
sess("a1b2c3d4-0000-0000-0000-000000000012", "ssh gateway", "Dead", "/etc"),
// home root — a leaf directly under ~ (name from command).
sess("a1b2c3d4-0000-0000-0000-000000000013", "htop", "Running", "/home/dev"),
];
// The store applies RegistryEvents internally; feed a Snapshot the same way
// The store applies CatalogEvents internally; feed a Snapshot the same way
// the WS would. `apply` is private, so reach it through an index cast.
(store as unknown as { apply: (e: unknown) => void }).apply({
type: "Snapshot",
daemons,
sessions,
});
@@ -126,12 +116,6 @@ export function seedMock(store: CatalogStore): void {
// backend endpoints stubbed so presets/recents/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) =>
m.openSpawnModal(
new Map(daemons.map((d) => [d.daemon_id, d])),
undefined,
new Map(sessions.map((s) => [s.id, s])),
),
);
void import("./spawn.js").then((m) => m.openSpawnModal());
}
}
+18 -135
View File
@@ -1,25 +1,18 @@
import { createSession, getWebInfo, recentDirs, gitRoot } from "./api.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
import type {
CreateSessionRequest,
DaemonInfo,
RecentDir,
Session,
SpawnPreset,
} from "./types.js";
import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
* the same daemon, cwd, command, and name as an existing session. */
* the same cwd, command, and name as an existing session. */
export interface SpawnPrefill {
daemon_id?: string;
cwd?: string;
command?: string;
name?: string;
}
/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
@@ -28,14 +21,7 @@ const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
];
/** How many host tiles to show before collapsing the rest behind "More…". */
const MAX_HOST_TILES = 4;
export function openSpawnModal(
daemons: Map<string, DaemonInfo>,
prefill?: SpawnPrefill,
sessions?: Map<string, Session>,
): void {
export function openSpawnModal(prefill?: SpawnPrefill): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
@@ -49,10 +35,6 @@ export function openSpawnModal(
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<form class="modal-body" id="spawn-form">
<div class="field">
<span class="field-label">Host</span>
<div class="tile-row" id="host-tiles"></div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row" id="cmd-tiles"></div>
@@ -106,7 +88,6 @@ export function openSpawnModal(
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
const hostTiles = host.querySelector("#host-tiles") as HTMLElement;
const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement;
const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement;
const recentsEl = host.querySelector("#recents") as HTMLElement;
@@ -120,14 +101,13 @@ export function openSpawnModal(
const nameInput = host.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
let selectedHostId = "";
let homeDir = "";
let presets: SpawnPreset[] = FALLBACK_PRESETS;
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
let showAllHosts = false;
// A clone's cwd, applied once the (async) recents load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let cloneCwd: string | null = prefill?.cwd ?? null;
@@ -187,11 +167,12 @@ export function openSpawnModal(
}
});
// Presets ride `/api/web/info`; render fallbacks immediately, then refine.
// Presets ride `/api/info`; render fallbacks immediately, then refine.
renderCommandTiles();
applyInitialCommand();
getWebInfo()
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir ?? "";
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
@@ -260,7 +241,7 @@ export function openSpawnModal(
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs(selectedHostId)).dirs ?? [];
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
@@ -272,13 +253,12 @@ export function openSpawnModal(
recentsEl.appendChild(otherDirRow());
if (cloneCwd) {
// A clone seeds the directory verbatim into the raw input, regardless of
// this host's history.
// the recorded history.
showRawCwd(cloneCwd);
cloneCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the host's home dir.
const home = daemons.get(selectedHostId)?.home_dir ?? "";
showRawCwd(home);
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
} else {
cwdRawWrap.hidden = true;
}
@@ -292,8 +272,7 @@ export function openSpawnModal(
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const home = daemons.get(selectedHostId)?.home_dir;
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, home));
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
@@ -306,10 +285,7 @@ export function openSpawnModal(
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.addEventListener("click", () => {
const home = daemons.get(selectedHostId)?.home_dir ?? "";
showRawCwd(cwd || home);
});
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
@@ -335,7 +311,7 @@ export function openSpawnModal(
wtBody.hidden = true;
let root: string | undefined;
try {
root = (await gitRoot(selectedHostId, cwd)).root;
root = (await gitRoot(cwd)).root;
} catch {
root = undefined;
}
@@ -364,86 +340,7 @@ export function openSpawnModal(
}
});
// ---- host tiles ------------------------------------------------------
// Order hosts by most-recently-spawned-on first, so the common case is the
// pre-selected tile.
function orderedHosts(): DaemonInfo[] {
const list = [...daemons.values()];
const lastUsed = new Map<string, number>();
for (const s of sessions?.values() ?? []) {
const t = Date.parse(s.created_at);
if (Number.isFinite(t)) {
lastUsed.set(s.daemon_id, Math.max(lastUsed.get(s.daemon_id) ?? 0, t));
}
}
return list.sort((a, b) => (lastUsed.get(b.daemon_id) ?? 0) - (lastUsed.get(a.daemon_id) ?? 0));
}
function selectHost(id: string): void {
selectedHostId = id;
for (const tile of hostTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
// Recents + any worktree inference are host-scoped — refresh on change.
cwd = "";
inferredBaseRepo = null;
if (wtEnabled.checked) {
wtEnabled.checked = false;
wtBody.hidden = true;
wtHint.hidden = true;
}
void loadRecents();
}
function renderHostTiles(): void {
hostTiles.replaceChildren();
const ordered = orderedHosts();
if (ordered.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-loading";
empty.textContent = "(no daemons connected)";
hostTiles.appendChild(empty);
return;
}
const visible = showAllHosts ? ordered : ordered.slice(0, MAX_HOST_TILES);
for (const d of visible) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile tile-host";
tile.dataset.id = d.daemon_id;
tile.innerHTML = `
<span class="tile-label">${escapeHtml(d.hostname)}</span>
<span class="tile-sub">${escapeHtml(d.daemon_id)}</span>`;
tile.addEventListener("click", () => selectHost(d.daemon_id));
hostTiles.appendChild(tile);
}
if (!showAllHosts && ordered.length > MAX_HOST_TILES) {
const more = document.createElement("button");
more.type = "button";
more.className = "tile tile-more";
more.innerHTML = `<span class="tile-label">More…</span>`;
more.addEventListener("click", () => {
showAllHosts = true;
renderHostTiles();
// Keep the current selection highlighted after re-render.
if (selectedHostId) {
hostTiles
.querySelector(`.tile[data-id="${cssEscape(selectedHostId)}"]`)
?.classList.add("is-active");
}
});
hostTiles.appendChild(more);
}
}
renderHostTiles();
// Pre-select: a clone's host if present & connected, else the most-recent.
const ordered = orderedHosts();
const initialHost =
prefill?.daemon_id && daemons.has(prefill.daemon_id)
? prefill.daemon_id
: ordered[0]?.daemon_id;
if (initialHost) selectHost(initialHost);
void loadRecents();
// ---- clone seed ------------------------------------------------------
if (prefill) {
@@ -453,9 +350,7 @@ export function openSpawnModal(
}
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocompletes: DirAutocompleteHandle[] = [
attachDirAutocomplete(cwdRaw, () => selectedHostId),
];
const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)];
// ---- lifecycle -------------------------------------------------------
const close = () => {
@@ -479,10 +374,6 @@ export function openSpawnModal(
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
if (!selectedHostId) {
errEl.textContent = "Pick a host.";
return;
}
if (!cwd) {
errEl.textContent = "Pick a working directory.";
return;
@@ -497,7 +388,6 @@ export function openSpawnModal(
if (name) metadata.name = name;
const req: CreateSessionRequest = {
daemon_id: selectedHostId,
cwd,
command,
metadata,
@@ -543,10 +433,3 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}
/** Escape a string for use inside a CSS attribute selector. Uses the native
* `CSS.escape` when available, with a conservative fallback. */
function cssEscape(s: string): string {
const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS;
if (cssApi?.escape) return cssApi.escape(s);
return s.replace(/["\\]/g, "\\$&");
}
+5 -75
View File
@@ -625,7 +625,7 @@ textarea {
box-shadow: 0 0 0 3px var(--accent-tint);
}
/* ---------- daemon / status bar ---------- */
/* ---------- status bar ---------- */
.statusbar {
display: flex;
@@ -645,28 +645,14 @@ textarea {
min-width: var(--sp-2);
}
.daemons {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--sp-2);
align-items: center;
min-width: 0;
}
.daemon-chip {
.session-tally {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-pill);
padding: 3px var(--sp-2) 3px var(--sp-2);
font-size: var(--fs-12);
color: var(--ink);
gap: 7px;
font-variant-numeric: tabular-nums;
}
.daemon-chip::before {
.session-tally::before {
content: "";
width: 6px;
height: 6px;
@@ -675,42 +661,6 @@ textarea {
box-shadow: 0 0 5px var(--live-glow);
}
/* Green session count baked into each host chip (replaces the global tally). */
.daemon-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 5px;
border-radius: var(--r-pill);
font-size: var(--fs-11);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--live);
background: color-mix(in srgb, var(--live) 16%, transparent);
}
.daemon-count.is-empty {
color: var(--muted);
background: var(--surface-2);
}
.daemon-empty {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
}
.daemon-empty::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warn);
}
/* ---------- session tree ---------- */
.tree {
@@ -863,16 +813,6 @@ textarea {
gap: var(--sp-2);
}
.leaf-daemon {
color: var(--faint);
flex: 0 0 auto;
}
.leaf-daemon::before {
content: "@";
opacity: 0.6;
}
.leaf-cmd {
color: var(--muted);
overflow: hidden;
@@ -995,11 +935,6 @@ textarea {
margin-top: var(--sp-2);
}
.empty.connecting .empty-glyph {
color: var(--warn);
animation: pulse 1.6s var(--ease-out) infinite;
}
/* ---------- attach view ---------- */
.attach {
@@ -1803,14 +1738,9 @@ textarea {
max-width: none;
order: -1;
}
/* daemons drop to their own full-width wrapped line; no side overflow */
.statusbar .spacer {
display: none;
}
.daemons {
flex: 1 1 100%;
justify-content: flex-start;
}
.tree-leaf .leaf-actions {
opacity: 1;
}
+13 -30
View File
@@ -12,7 +12,6 @@ export type ActivityState = "Active" | "Idle" | "AwaitingInput";
export interface Session {
id: Uuid;
daemon_id: string;
cwd: string;
command: string;
env: Record<string, string>;
@@ -32,17 +31,7 @@ export interface Session {
current_cwd?: string | null;
}
export interface DaemonInfo {
daemon_id: string;
hostname: string;
version: string;
daemon_url: string;
/** Home dir of the user the daemon runs as; used to prefill the spawn form's
* working directory. Empty/absent for older daemons. */
home_dir?: string;
}
/** One directory's immediate subdirectories on a daemon's host (NAP-329). */
/** One directory's immediate subdirectories (NAP-329). */
export interface DirListing {
/** Resolved absolute directory that was listed (after ~/relative expansion). */
path: string;
@@ -60,13 +49,14 @@ export interface SpawnPreset {
command: string;
}
/** `GET /api/web/info` — the connected daemon's identity plus its spawn presets
* (#26). Mirrors the daemon-side `WebInfo`. */
export interface WebInfo {
daemon_id: string;
/** `GET /api/info` — the daemon's identity plus its spawn presets (#26).
* Mirrors the daemon-side `DaemonInfo`. */
export interface DaemonInfo {
hostname: string;
version: string;
daemon_url: string;
/** Home dir of the user the daemon runs as; used to prefill the spawn form's
* working directory. Empty/absent for older daemons. */
home_dir?: string;
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
presets: SpawnPreset[];
}
@@ -79,14 +69,14 @@ export interface RecentDir {
last_used: IsoTimestamp;
}
/** `GET /api/daemons/:id/recent-dirs` — a host's recent working dirs (#26). */
/** `GET /api/recent-dirs` — the daemon's recent working dirs (#26). */
export interface RecentDirs {
dirs: RecentDir[];
truncated?: boolean;
}
/** `GET /api/daemons/:id/gitroot?path=` — the enclosing repo toplevel of a path
* on a host, or absent when the path isn't inside a repo (#26). */
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or absent
* when the path isn't inside a repo (#26). */
export interface GitRoot {
root?: string;
}
@@ -98,7 +88,6 @@ export interface WorktreeSpec {
}
export interface CreateSessionRequest {
daemon_id: string;
cwd: string;
command: string;
env?: Record<string, string>;
@@ -106,16 +95,10 @@ export interface CreateSessionRequest {
worktree?: WorktreeSpec;
}
export type RegistryEvent =
| {
type: "Snapshot";
daemons: DaemonInfo[];
sessions: Session[];
}
| { type: "DaemonAdded"; daemon: DaemonInfo }
| { type: "DaemonRemoved"; daemon_id: string }
export type CatalogEvent =
| { type: "Snapshot"; sessions: Session[] }
| { type: "SessionAdded"; session: Session }
| { type: "SessionRemoved"; daemon_id: string; id: Uuid }
| { type: "SessionRemoved"; id: Uuid }
| { type: "SessionUpdated"; session: Session }
| { type: "Lagged"; skipped: number };