feat(maestro): nest sessions by live working directory, drop labels

Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.

Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.

The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:11:23 +00:00
co-authored by Claude Opus 4.8
parent 5602cf5fc8
commit 458c81094b
23 changed files with 280 additions and 167 deletions
+47 -13
View File
@@ -2,7 +2,7 @@ import { 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 { sessionLabel, sessionName } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
@@ -29,21 +29,26 @@ function compareNodes(a: TreeNode, b: TreeNode): number {
return an.localeCompare(bn);
}
function buildTree(sessions: Session[], filter: string): TreeNode {
function buildTree(
sessions: Session[],
daemons: Map<string, DaemonInfo>,
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)} ${sessionLabel(s) ?? ""}`.toLowerCase();
const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const label = sessionLabel(s);
// Host is the first level of nesting; the label path nests beneath it, so
// the daemon no longer needs to ride along on each leaf.
const labelParts = label ? label.split("/").filter(Boolean) : [];
const parts = [s.daemon_id, ...labelParts];
// 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];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
@@ -64,9 +69,39 @@ function buildTree(sessions: Session[], filter: string): TreeNode {
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,
);
}
}
return root;
}
/** Fold a folder with a single folder-child into one node (`a` + `b` → `a/b`),
* repeating down the chain, then recurse into the surviving branches. The merged
* node keeps the deepest path so collapse/expand state stays stable as it grows. */
function mergeLinear(folder: TreeFolder): TreeFolder {
let cur = folder;
while (cur.children.length === 1 && cur.children[0].kind === "folder") {
const child = cur.children[0];
cur = {
kind: "folder",
name: `${cur.name}/${child.name}`,
path: child.path,
children: child.children,
};
}
cur.children = cur.children.map((c) =>
c.kind === "folder" ? mergeLinear(c) : c,
);
return cur;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -406,7 +441,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
const refresh = () => {
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, filter);
const tree = buildTree(sessions, state.daemons, filter);
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
@@ -427,8 +462,8 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
document.addEventListener("catalog-rerender", rerenderHandler);
// Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir,
// command (matched back to a preset by the form), and label — but with a
// freshly generated name rather than the original's, since it's a new session.
// 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();
@@ -438,7 +473,6 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
daemon_id: s.daemon_id,
cwd: s.cwd,
command: s.command,
label: sessionLabel(s) ?? "",
},
snap.sessions,
);
+7 -8
View File
@@ -213,25 +213,24 @@ export function renderKit(): HTMLElement {
main.appendChild(el);
}
// --- session rows, nested by project label ---
// --- session rows, nested by working directory ---
{
const { el, body } = section(
"Session rows — nested by host, then project",
"Host is the first level of nesting; the project label nests beneath it, so the daemon no longer rides on each row. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"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.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("thinkpad", 3, 0),
folderHeader("maestro", 3, 1),
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("server", 1, 2),
folderHeader("crates/registry", 1, 2),
leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }),
folderHeader("tower", 2, 0),
folderHeader("work", 1, 1),
folderHeader("acme", 1, 2),
leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 3 }),
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 }),
+26 -21
View File
@@ -13,22 +13,25 @@ function sess(
daemon: string,
command: string,
status: "Running" | "Dead",
meta: Record<string, unknown>,
cwd: string,
name?: string,
): Session {
const seed = parseInt(id.slice(-1), 16) || 0;
return {
id,
daemon_id: daemon,
cwd: "/home/dev",
cwd,
command,
env: {},
metadata: meta,
metadata: name ? { name } : {},
status,
created_at: "2026-06-15T12:00:00Z",
// Inferred fields (#18) so the HUD has real data to render in review.
foreground: command.split(" ")[0],
activity: ACTIVITIES[seed % ACTIVITIES.length],
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
// The session tree nests by current_cwd; default it to the spawn dir.
current_cwd: cwd,
};
}
@@ -90,24 +93,26 @@ export function seedMock(store: CatalogStore): void {
{ daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" },
];
const sessions: Session[] = [
// maestro/web/* — a project with nested sub-labels (two levels deep)
sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web/frontend" }),
sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", { name: "dev-server", label: "maestro/web/frontend" }),
sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web/frontend" }),
sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", { name: "e2e-fixes", label: "maestro/web/testing" }),
// maestro/server/* — sibling project, also nested
sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server/registry" }),
sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server/registry" }),
sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", { name: "daemon", label: "maestro/server/daemon" }),
// work/* — a second top-level group with its own client nesting
sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", { name: "billing-api", label: "work/acme/api" }),
sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", { name: "dashboard", label: "work/acme/web" }),
// personal/* — flat-ish group
sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", { name: "dotfiles", label: "personal" }),
sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", { name: "monitor", label: "personal" }),
// ungrouped — no label, falls to the tree root (name from command)
sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", {}),
sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", {}),
// ~/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"),
// ~/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"),
// ~/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"),
// ~/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"),
// 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"),
];
// The store applies RegistryEvents internally; feed a Snapshot the same way
// the WS would. `apply` is private, so reach it through an index cast.
+1 -9
View File
@@ -10,13 +10,12 @@ import type {
} from "./types.js";
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
* the same daemon, cwd, command, name, and label as an existing session. */
* the same daemon, cwd, command, and name as an existing session. */
export interface SpawnPrefill {
daemon_id?: string;
cwd?: string;
command?: string;
name?: string;
label?: string;
}
/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched
@@ -74,10 +73,6 @@ export function openSpawnModal(
<button type="button" class="btn btn-icon reroll" id="reroll" aria-label="Generate a new name" title="New name">↻</button>
</div>
</div>
<label>
Label (optional)
<input name="label" type="text" placeholder="personal/maestro" autocomplete="off" />
</label>
<fieldset class="worktree">
<legend>
<label class="row-inline">
@@ -447,7 +442,6 @@ export function openSpawnModal(
// ---- clone seed ------------------------------------------------------
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
(host.querySelector("input[name=label]") as HTMLInputElement).value = prefill.label ?? "";
// The cwd itself is applied by loadRecents once the initial host's recents
// settle (see `cloneCwd`), so a slow fetch can't clobber it.
}
@@ -494,9 +488,7 @@ export function openSpawnModal(
const metadata: Record<string, unknown> = {};
const name = nameInput.value.trim();
const label = (host.querySelector("input[name=label]") as HTMLInputElement).value.trim();
if (name) metadata.name = name;
if (label) metadata.label = label;
const req: CreateSessionRequest = {
daemon_id: selectedHostId,
+18 -4
View File
@@ -26,6 +26,10 @@ export interface Session {
activity?: ActivityState;
/** Wall-clock of the most recent observed PTY output (NAP-340). */
last_activity_at?: IsoTimestamp;
/** The pane's *current* working directory, as the daemon's activity watcher
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
* Drives the directory-based session tree. Absent until first observed. */
current_cwd?: string | null;
}
export interface DaemonInfo {
@@ -132,8 +136,18 @@ export function sessionName(s: Session): string {
return s.command;
}
export function sessionLabel(s: Session): string | undefined {
const meta = s.metadata as Record<string, unknown>;
const label = meta?.label;
return typeof label === "string" ? label : undefined;
/** The directory a session lives in: its live `current_cwd` if the daemon has
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
export function sessionCwd(s: Session): string | undefined {
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
return s.cwd || undefined;
}
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
export function abbreviateCwd(cwd: string, home?: string): string {
if (home && (cwd === home || cwd.startsWith(`${home}/`))) {
return `~${cwd.slice(home.length)}`;
}
return cwd;
}