diff --git a/README.md b/README.md index 40e2694..55c5165 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Found bercail on [herdr.dev](https://herdr.dev/layouts/)? Its recipe walks your dev # this directory's space, with its desk agent ``` -`Ctrl+Shift+A` focuses the desk, `Ctrl+Shift+R` opens Review, and `bercail doctor` tells you if anything is missing. Something else? `bercail help`. +The layout opens on Shep, OpenClaw's terminal UI, with your spaces beside it: click one to work in it, `Ctrl+Shift+H` to come back. `Ctrl+Shift+A` focuses the desk, `Ctrl+Shift+R` opens Review, and `bercail doctor` tells you if anything is missing. Something else? `bercail help`. ## Docs @@ -59,7 +59,7 @@ dev # this directory's space, with its desk agent - [review](docs/review.md): tuicr, and how Review opens and closes. - [handoff](docs/handoff.md): stages, headless runs, result files. - [bstack](docs/bstack.md): opencode on your own plans, roles, autopilot. Then [the bstack guide](docs/bstack/README.md). -- [Shep](docs/shep.md): the Grok bot, and agent status webhooks. +- [Shep](docs/shep.md): the Grok bot, the Shep view, and agent status webhooks. - [development](docs/development.md): tests, dependencies, platforms. - [Herdr agents and subagents](docs/herdr-agents-subagents.md): a proposal for Herdr's agents list. diff --git a/bercail/RECIPE.md b/bercail/RECIPE.md index f3b4bf2..dad9348 100644 --- a/bercail/RECIPE.md +++ b/bercail/RECIPE.md @@ -1,6 +1,6 @@ # bercail -Built for the Shep Grok bot: hand off the jobs, let agents work side by side in their own worktrees, and review each one as it lands. +Built for Shep: the window opens on Shep (OpenClaw's terminal UI) beside your spaces and agents. Hand off the jobs, let agents work side by side in their own worktrees, and review each one as it lands. ![bercail](preview.png) @@ -16,7 +16,7 @@ these files. 1. Tell the person what the install does, and ask before you run it. It runs an installer that adds programs (the Herdr app if missing, git, worktrunk, beads, fzf, jq, tuicr, and opencode for bstack), a block in their shell rc, - and this layout with its two mods and its `layout:bercail` keys. + and this layout with its mods and its `layout:bercail` keys. 2. With their yes, install with one of these: ```bash @@ -31,8 +31,8 @@ these files. 3. bercail copies the layout and the mods, and merges the keys, itself. Skip the copy and merge steps of the Herdr app's guide: copying again would add a renamed second copy. Run `herdr-app check` on - `~/.config/herdr-app/mods/bercail-desk`, `.../bercail-agents` and - `.../bercail-review`, then + `~/.config/herdr-app/mods/bercail-desk`, `.../bercail-shep`, + `.../bercail-spaces`, `.../bercail-agents` and `.../bercail-review`, then `herdr-app do layout.switch --name bercail`. 4. Tell the person what [After the install](#after-the-install) says. @@ -46,28 +46,49 @@ Second choice, the layout on its own, when the person says no: - Not the `herdr` terminal (`~/.config/herdr`, `herdr plugin install`, `herdr-plugin.toml`): it has no layouts or mods, and this layout does not run in it. -- Copy `layouts/bercail.json`, `mods/bercail-desk/` and - `mods/bercail-agents/` into `~/.config/herdr-app/`, merge the - `layout:bercail` section of `keys.json` into - `~/.config/herdr-app/keys.json`, run `herdr-app check` on both mods - (`~/.config/herdr-app/mods/bercail-desk`, `.../bercail-agents`), then - switch with `ctrl-shift-l`. +- Copy `layouts/bercail.json`, `mods/bercail-desk/`, `mods/bercail-shep/`, + `mods/bercail-spaces/` and `mods/bercail-agents/` into + `~/.config/herdr-app/`, merge the `layout:bercail` section of `keys.json` + into `~/.config/herdr-app/keys.json`, run `herdr-app check` on each mod + (`~/.config/herdr-app/mods/bercail-desk`, `.../bercail-shep`, + `.../bercail-spaces`, `.../bercail-agents`), then switch with + `ctrl-shift-l`. - Tell the person what they won't have. The desk runs `cursor-agent`, or `[agent] command` from `~/.config/bercail/config.toml` if they write one, and that program must be on PATH; there is no bstack. Review needs - [tuicr](https://github.com/agavra/tuicr) (≥ 0.20) installed separately. + [tuicr](https://github.com/agavra/tuicr) (≥ 0.20) installed separately, + and the Shep view needs [OpenClaw](https://docs.openclaw.ai) with a + Gateway (`openclaw tui`) and an agent (`[shep] session`, else the default + agent's main session). There are no worktree spaces (`dev`, `wtc`, `wtd`), no handoff, skills or webhooks, and Shep can't run it. Installing bercail later adds all of that. ## What you see -Left to right, under no system title bar (`titlebar: "none"`, macOS traffic -lights at 12,16): +Two views of one window, under no system title bar (`titlebar: "none"`, +macOS traffic lights at 12,16). The **Shep view** (what the app opens on): +Shep, then the sidebar on the right. The **space view**: the sidebar on the +left, then the work column. The root row is `shep`, `sidebar`, `work`; a view +is which of `shep` and `work` is hidden. +- **Shep**, weight 1 (min 320), 8px padding: a card like the desk's + (`background` fill, 1px `border`, radius 10, `accent` border on focus). A + 32px `chrome` header (draggable: `window_drag`): a 7px dot (`ansi2` while + Shep runs, `blocked` without OpenClaw, else `dim`), "Shep" semibold, the + TUI's title or "OpenClaw TUI, the orchestrator for every desk" in `dim`, + the state word (running, starting, stopped, OpenClaw missing), and + "Ctrl+Shift+H spaces" at 11px. Below it, Shep's terminal with 6px padding. + Without one: "Shep is not running", a "Start Shep" button filled with + `solid`, and a hint; without OpenClaw: "OpenClaw is not installed", what to + do, a "Check again" button, and "Your spaces work without it". - **Sidebar**, 268px wide (min 44, max 480, not resizable), `chrome` - background. Herdr's own brand row (46px), then the spaces list and the - agents list sharing the rest equally (min 90px each), then the footer - (69px, Settings / Add machine). + background. Herdr's own brand row (46px); the **Shep row** (44px): in the + Shep view a selected (`selected`) row with an 18px `solid` tile "S", + "Shep" and the dot; in the space view a chevron, "Back to Shep", + "Ctrl+Shift+H" in `dim` and the dot. On the rail (narrower than 160px) + only the tile. Then the spaces list and the agents list sharing the rest + equally (min 90px each), then the footer (69px, Settings / Add machine). + While Shep shows, no space reads as selected. - **Work column**: the tab bar (42px), the work body, and the status bar (28px). The work body has 8px padding and 8px gaps between its three parts: - **The desk**, weight 1 (min 320px): a card with `background` fill, 1px @@ -87,6 +108,32 @@ lights at 12,16): ## How it behaves +- **Views.** The app opens on the Shep view once per app session, whatever + view was shown when it closed. Clicking a space (or making one with +) + selects it and shows the space view; the Shep row or `ctrl-shift-h` shows + the Shep view again. Hidden columns keep their terminals and mods running: + switching starts and stops nothing. +- **Shep** is one hc terminal, `bercail-shep` (label `bercail.role=shep`), + in no space, running `openclaw tui --session ` with no other flags + (OpenClaw's own config picks the Gateway and auth). The key is always + agent-scoped: `[shep] session` from `~/.config/bercail/config.toml`, else + `agent::main` of the agent `openclaw agents list --json` marks + `isDefault`; anything else keeps Shep stopped with a notification saying + what to set. The terminal carries it as label `bercail.shep_session` and + the header shows it. It starts on its + own once per app session, after the core lists its terminals; a running + one is shown again, never a second. One the person quits stays quit until + "Start Shep". When the TUI exits with an error, its message stays until + Enter. +- **The keyboard** goes into Shep when the Shep view shows, and back where it + was in the space (a pane, or the desk) when the space view returns. +- **Agents.** Clicking an agent, a desk or a sub-agent in the agents list + opens its own terminal in a dialog (86% of the window) over either view, + with a 34px bar: status dot, icon, name ("Desk" for a desk), space, title, + status, "Show in space" and ✕. Keys go to that terminal while it is open; + Esc is the program's. ✕ or a click beside it closes it and puts the + keyboard back where it was; "Show in space" selects the space, shows the + space view and, for a pane agent, focuses it. - **The desk agent** belongs to the shown space. Switching spaces points the desk at that space's agent, or shows the empty desk. - **Statuses** show in the header dot and word: working `working`, blocked @@ -100,6 +147,7 @@ lights at 12,16): is replaced. At most one automatic open per space every 15s; a space that isn't shown opens it the next time it is. - **Keys** (keys.json section `layout:bercail`): + - `ctrl-shift-h` switches between the Shep view and the space view. - `ctrl-shift-a` focuses the desk agent, or starts one: bstack when `[bstack] enabled = true` in `~/.config/bercail/config.toml`, else `[agent] command` (`cursor-agent` when there is none). @@ -121,32 +169,68 @@ lights at 12,16): each space's desk first, then its pane agents. A desk is focused in its space's desk column. - `ctrl-shift-q` closes the space. -- **Clicks**: the empty desk's "Start the agent" button starts the desk agent. - Everything else is Herdr's own (sidebar, tabs, panes, panels). +- **Clicks**: the empty desk's "Start the agent" button starts the desk agent; + "Start Shep" and "Check again" in the Shep view; the Shep row; spaces and + agents as above. Everything else is Herdr's own (tabs, panes, panels). - When a desk starts, an unnamed first tab is renamed Shell. ## Built from - `layouts/bercail.json`, cloned from Herdr's `main` (0.1.6). Node tree: - `root` row → `sidebar` column (`brand` workspace-chrome, `sidebar-lists` - column of `spaces` and `agents`, `sidebar-footer` workspace-chrome with - `props: {footer: true}`) and `work` column (`tabs` mod, `work-body` row, - `status` workspace-status). `work-body`: `desk` column (`desk-header` + `root` row → `shep` column (padding 8; `shep-card` column: `shep-header` + block = `bercail-shep` with `props: {role: "header"}`, `window_drag`; + `shep-stack` stack of `shep-empty`, `bercail-shep` with + `props: {role: "empty"}`, plus the `{terminal: …}` block `shep-
` + the mod adds), `sidebar` column (`brand` workspace-chrome, `shep-nav` + block = `bercail-shep` with `props: {role: "nav"}`, `sidebar-lists` column + of `spaces` (`bercail-spaces`) and `agents`, `sidebar-footer` + workspace-chrome with `props: {footer: true}`) and `work` column (shipped + `hidden: true`; `tabs` mod, `work-body` row, `status` workspace-status). `work-body`: `desk` column (`desk-header` block = `bercail-desk` mod; `agent-stack` stack of `empty`, the `bercail-desk` mod with `props: {role: "empty"}`, plus one `{terminal: …}` block per desk, `desk-
`, that the mod adds), `main` block `{tab: "active", pane: {...}}`, `panel` column holding the `panel-body` stack of `files` and `changes` (the ids Herdr's `tabs` mod shows and hides). -- Shipped mods used: `workspace-chrome`, `spaces`, `tabs`, `pane`, - `files`, `changes`, `workspace-status`. +- Shipped mods used: `workspace-chrome`, `tabs`, `pane`, `files`, + `changes`, `workspace-status`. +- `mods/bercail-spaces`: Herdr's `spaces` mod (0.1.14) copied with + `mod.clone {"name":"spaces","as":"bercail-spaces"}`. Changed only so a + click on a space, + or a machine's + also runs `layout.hide {id: "shep"}` + and `layout.show {id: "work"}` (in a layout with both), and so no space + reads as selected while `shep` shows. +- `mods/bercail-shep` (`place: "none"`), one script, four roles: + - `nav` (default): the Shep row. It is always shown, so it holds the logic: + on its first render of the app session (a flag on `globalThis`, which a + mod reload keeps) it shows the Shep view; once `terminals()` lists + anything it starts Shep: `hc exec bash -lc 'command -v openclaw'`, the + session key (above), then `hc run --bg --json --no-parent --no-notify + --rm --name bercail-shep --cwd --label bercail.role=shep --label + bercail.shep_session= bash -lc bash + ` (the key is an argument, never spliced into the script; hc refuses a + second live `bercail-shep`: `name_taken` is no error). On every render it + adds Shep's block to `shep-stack` with `layout.set` (once per terminal, + already active), selects it, or drops it when Shep ends, and moves the + keyboard: `pane.focus {terminal: }` in the Shep view when it is in + a pane; on return to the space view, `pane.focus` to where it was in that + space (kept per space while the space view shows). Its commands + (`bercail-shep.toggle`, `.show`, `.space`, `.start`) switch views or + start Shep. + - `header`, `empty`: the Shep column's bar and placeholder. + - `modal`: the bar of an agent dialog, `props: {terminal, back}`. +- Agent dialogs: `openOverlay({kind: "dialog", width: 0.86, height: 0.86, + layout: {type: "column", children: [{size: {px: 34}, content: {mod: + "bercail-shep", props: {role: "modal", terminal, back: {pane, + terminal}}}}, {content: {terminal}, padding: [6, 6]}]}})`. Its `close()` + runs `pane.focus` back to `back`. - `mods/bercail-agents`: Herdr's `agents` mod (0.1.11) copied with `mod.clone {"name":"agents","as":"bercail-agents"}`, in the `agents` block. Changed only so it lists desks: a live terminal labelled `bercail.role=agent` that is not in `agents()` and whose parent is not either (a desk under a bash Shell) gets a top-level row in its space, before that space's agents, with its `terminals()` status (done reads - idle, error blocked, as for sub-agents). A click runs - `bercail-desk.agent:`, which selects the desk's space and desk. + idle, error blocked, as for sub-agents). A click on any row (desk, agent, + sub-agent) opens the agent dialog above instead of switching spaces. - `mods/bercail-desk` (`place: "none"`), one script, two roles: - `header` (default): draws the header row, takes the key commands (`nextCommand()`), and on every render shows the space's desk: the first @@ -215,6 +299,20 @@ lights at 12,16): ## Watch out +- `layout.set` writes the whole tree: a `layout.show`/`layout.hide` sent in the + same moment is undone. The Shep row writes a view it has just asked for + into the tree it sets. +- At a mod's `init`, `layout()` is null and `terminals()` empty; right after, + the layout arrives before the core's terminals. Starting Shep before + `terminals()` lists anything would start a second one. +- `pane.focus {terminal}` on a terminal shown in a dialog focuses its model + pane, switching spaces: never use it to put the keyboard in a dialog. The + dialog takes the keyboard when it opens. +- A hidden column's panes get no keys, but the keyboard stays there: move it + (into Shep, or back to the space) when the view changes. +- Hidden mods don't render, and `.` reaches shown copies only: + the `bercail-desk` keys do nothing in the Shep view. + - An agent block can't take the keyboard from a command, and `agent.focus` moves focus to the parent pane. The desk is a **terminal** block, not `{agent: "focused"}`: one per desk, never repointed. A single block @@ -246,10 +344,23 @@ lights at 12,16): ## Done when -- `herdr-app check mods/bercail-desk` and `herdr-app check - mods/bercail-agents` pass, and `layout.switch - {"name":"bercail"}` shows the sidebar, the desk, the tabs and Files. -- In a space with no desk agent, clicking "Start the agent" starts +- `herdr-app check` passes on `mods/bercail-desk`, `mods/bercail-shep`, + `mods/bercail-spaces` and `mods/bercail-agents`, and `layout.switch + {"name":"bercail"}` shows the Shep view: Shep's terminal running + `openclaw tui --session ` (one hc terminal `bercail-shep`, label + `bercail.shep_session=`) and the sidebar on the right. +- Restarting the app from the space view opens the Shep view again, on the + same `bercail-shep` terminal; `hc ls` shows one. +- Clicking a space shows the space view with that space; "Back to Shep" (or + `ctrl-shift-h`) shows the same Shep session with its history, the keyboard + in it; going back to the space puts the keyboard where it was (the desk + too). The desks and shells are untouched. +- Clicking an agent in the agents list, from either view, opens its terminal + in a dialog; typing reaches that agent; ✕ closes it with the keyboard back + where it was. +- With no `openclaw` on herdr-core's PATH, the Shep view says "OpenClaw is + not installed" and the spaces work. +- In the space view, in a space with no desk agent, clicking "Start the agent" starts `bercail-desk-agent` as an hc sub-agent of the space's Shell, labelled `bercail.role=agent` and `bercail.space=`; the desk shows it with its status, the agents list has a row for it, and the space gets no tab for it. @@ -276,7 +387,11 @@ Tell the person, briefly: `bercail bstack setup`, which asks before each sign-in. `bercail bstack status` shows what is connected and the login command for each plan that isn't. `bercail doctor` checks everything else. +- **Shep first.** The window opens on Shep (OpenClaw's terminal UI, so + OpenClaw needs a Gateway). Click a space to work in it; "Back to Shep" + returns. Click an agent to open its terminal over either view. - **Keys** in the bercail layout: + - `Ctrl+Shift+H`: Shep view / space view. - `Ctrl+Shift+A`: the desk agent (starts it if there is none); `Ctrl+Alt+Right` goes back to the pane. - `Ctrl+Shift+R`: Review. `Ctrl+Shift+G`: show or hide Changes. diff --git a/bercail/keys.json b/bercail/keys.json index 2da44e4..dd8341e 100644 --- a/bercail/keys.json +++ b/bercail/keys.json @@ -13,6 +13,7 @@ "ctrl-alt-down": "bercail-desk.subspace-next", "alt-shift-up": "bercail-desk.agent-prev", "alt-shift-down": "bercail-desk.agent-next", - "ctrl-shift-q": "space.close" + "ctrl-shift-q": "space.close", + "ctrl-shift-h": "bercail-shep.toggle" } } diff --git a/bercail/layouts/bercail.json b/bercail/layouts/bercail.json index ebb8687..e8151c2 100644 --- a/bercail/layouts/bercail.json +++ b/bercail/layouts/bercail.json @@ -6,6 +6,66 @@ }, "type": "row", "children": [ + { + "id": "shep", + "type": "column", + "children": [ + { + "id": "shep-card", + "type": "column", + "children": [ + { + "id": "shep-header", + "type": "block", + "content": { + "mod": "bercail-shep", + "props": { + "role": "header" + } + }, + "size": { + "px": 32 + }, + "window_drag": true + }, + { + "id": "shep-stack", + "type": "stack", + "children": [ + { + "id": "shep-empty", + "type": "block", + "content": { + "mod": "bercail-shep", + "props": { + "role": "empty" + } + } + } + ], + "active": "shep-empty" + } + ], + "background": "background", + "border": { + "width": 1, + "color": "border" + }, + "radius": 10, + "focus": { + "border": { + "width": 1, + "color": "accent" + } + } + } + ], + "size": { + "weight": 1 + }, + "min": 320, + "padding": 8 + }, { "id": "sidebar", "type": "column", @@ -21,6 +81,19 @@ }, "window_drag": true }, + { + "id": "shep-nav", + "type": "block", + "content": { + "mod": "bercail-shep", + "props": { + "role": "nav" + } + }, + "size": { + "px": 44 + } + }, { "id": "sidebar-lists", "type": "column", @@ -29,7 +102,7 @@ "id": "spaces", "type": "block", "content": { - "mod": "spaces" + "mod": "bercail-spaces" }, "size": { "weight": 1 @@ -218,7 +291,8 @@ } } ], - "resizable": false + "resizable": false, + "hidden": true } ], "background": "canvas", diff --git a/bercail/mods/bercail-agents/main.js b/bercail/mods/bercail-agents/main.js index 91bbc57..d1df9ca 100644 --- a/bercail/mods/bercail-agents/main.js +++ b/bercail/mods/bercail-agents/main.js @@ -2,6 +2,8 @@ // A viewed done agent stays in Done until left. // bercail's copy of Herdr's agents mod (mod.clone): it also lists each space's desk, // a sub-agent of the space's Shell, which agents() leaves out when the Shell isn't an agent. +// A click opens the agent's own terminal in a dialog over the Shep or space view +// (bercail-shep draws its bar) instead of switching spaces. import { View, div, image, svg } from "gpui-kit"; import { v_flex, h_flex } from "gpui-base"; @@ -80,8 +82,23 @@ export function withDesks(list, desks, spaceList) { return [...desks, ...list].sort((x, y) => rank(x) - rank(y)); } -// A desk has no pane to focus: bercail-desk selects its space and its desk block. -const open = (a) => (a.desk ? run(`bercail-desk.agent:${a.address}`) : run("pane.focus", { pane: a.pane })); +// The agent's terminal itself, in a dialog: its pane, desk and space stay as they are, +// and closing the dialog puts the keyboard back (`back`). +export function agentDialog(address, f) { + return { + kind: "dialog", + width: 0.86, + height: 0.86, + layout: { + type: "column", + children: [ + { type: "block", size: { px: 34 }, content: { mod: "bercail-shep", props: { role: "modal", terminal: address, back: { pane: f?.pane ?? null, terminal: f?.terminal ?? null } } } }, + { type: "block", content: { terminal: address }, padding: [6, 6] }, + ], + }, + }; +} +const open = (a) => openOverlay(agentDialog(a.address, focused())); const SORTS = [ { id: "priority", label: "Priority", icon: "clock" }, @@ -191,10 +208,6 @@ export default class Agents extends View { .when(s.id === on, (el) => el.child(svg("mods/workspace-chrome/check.svg").size(14).flex_none().text_color(t.foreground))))); } - peek(addr) { - run("terminal.peek", { terminal: addr }); - } - // Live sub-agents of `addr`, depth first, each with the tree lines in front of it. tree(all, addr, lead = []) { const kids = all.filter((k) => k.parent === addr && k.state === "live"); @@ -224,7 +237,7 @@ export default class Agents extends View { .text_size(12.5) .when(on, (el) => el.bg(t.raised)) .hover((s) => s.bg(t.hover)) - .on_click(() => this.peek(k.address)) + .on_click(() => open(k)) .children(s.lines.map(l => div().self_start().mt(-4).w(9).h(17).mr(4).flex_none() .when(Boolean(l), el => el.border_l(1.5).border_color(t.input)) .when(l === "└" || l === "├", el => el.border_b(1.5).rounded_bl(6)))) diff --git a/bercail/mods/bercail-shep/main.js b/bercail/mods/bercail-shep/main.js new file mode 100644 index 0000000..932a76f --- /dev/null +++ b/bercail/mods/bercail-shep/main.js @@ -0,0 +1,393 @@ +// bercail Shep: the bercail layout's control room. Two views share one window: +// +// - Shep view: Shep (OpenClaw's TUI, `openclaw tui`) on the left, the navigator +// (spaces and agents) on the right. The `work` column is hidden. +// - Space view: the navigator on the left, the shown space's desk, tabs and panels +// on the right. The `shep` column is hidden. +// +// The view is only which of the layout's `shep` and `work` columns is hidden: hidden +// columns keep their terminals and mods running, so switching never starts or stops +// anything. The app launches into the Shep view. Clicking a space (bercail-spaces) +// shows the space view; the navigator's Shep row (or Ctrl+Shift+H) comes back. +// +// Shep is one hc terminal, `bercail-shep` (label bercail.role=shep), not in any space. +// It is found again by its label, so the same session shows after a restart and no +// second TUI starts. It starts on its own once per app session; one the person quit +// stays quit until "Start Shep". Without OpenClaw the Shep view says how to get it. +// It always opens an agent-scoped session (`openclaw tui --session agent::`): +// `[shep] session` in bercail's config.toml, else the main session of OpenClaw's +// default agent. A bare `openclaw tui` would pick the agent from its folder. +// +// props.role: +// - "nav" (default): the navigator's Shep row. Always shown, so it runs the logic: the +// launch view, Shep's block in `shep-stack`, where the keyboard is, key commands. +// - "header": the bar above Shep's terminal. +// - "empty": the placeholder in `shep-stack` while Shep isn't running. +// - "modal": the bar of an agent's terminal opened over either view (bercail-agents +// opens it); props.terminal is the agent, props.back where the keyboard was. +import { View, div, image, svg } from "gpui-kit"; +import { h_flex, v_flex } from "gpui-base"; +import { + agents, closeOverlay, exec, focused, home, layout, nextCommand, notify, panes, props, + readTextFile, run, spaces, terminals, theme, +} from "herdr"; + +const NAME = "bercail-shep"; +const ROLE = "shep"; +// Asked of and run in herdr-core's environment (hc exec, hc run), which may lack the +// login shell's PATH (mise, ~/.local/bin). When the TUI fails (no Gateway, say) its +// message stays on screen until Enter; quitting it normally closes the terminal. +const OC_PATH = 'command -v openclaw >/dev/null || PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"'; +// The session key comes in as $1, never spliced into the script. +const OC_RUN = `${OC_PATH}; openclaw tui --session "$1"; code=$?; [ "$code" = 0 ] && exit 0 +printf '\nShep (openclaw tui) exited with code %s. Press Enter to close.\n' "$code"; read -r _`; +const KEY = "Ctrl+Shift+H"; +const CONFIG = "/.config/bercail/config.toml"; +const SESSION = /^agent:[A-Za-z0-9_-]+:[^\s]+$/; +const SESSION_FIX = 'set [shep] session = "agent::main" in ~/.config/bercail/config.toml'; + +// `[shep] session` from config.toml text, or "". +export function configSession(text) { + let section = ""; + for (const raw of text.split("\n")) { + const line = raw.replace(/#.*$/, "").trim(); + const sec = line.match(/^\[(.+)\]$/); + if (sec) { section = sec[1].trim(); continue; } + const kv = line.match(/^session\s*=\s*(.+)$/); + if (section === "shep" && kv) return kv[1].trim().replace(/^"(.*)"$/, "$1"); + } + return ""; +} + +// The agent-scoped session Shep opens, or throws with the fix. +async function shepSession() { + let text = ""; + try { text = await readTextFile(home() + CONFIG); } catch { /* no config: the default */ } + let key = configSession(text); + if (key && !SESSION.test(key)) throw new Error(`[shep] session "${key}" is not an agent:: session key; ${SESSION_FIX}`); + if (!key) { + const r = await exec("hc", ["exec", "bash", "-lc", `${OC_PATH}; openclaw agents list --json 2>/dev/null`]); + let id = null; + try { id = JSON.parse(r.stdout).find((a) => a.isDefault)?.id ?? null; } catch { /* below */ } + key = id ? `agent:${id}:main` : ""; + if (!SESSION.test(key)) throw new Error(`OpenClaw has no default agent (openclaw agents list --json); ${SESSION_FIX}`); + } + return key; +} + +export function find(node, id) { + if (!node || typeof node !== "object") return null; + if (node.id === id) return node; + for (const c of node.children ?? []) { + const hit = find(c, id); + if (hit) return hit; + } + return null; +} + +// "shep", "space", or null in a layout without the two views. +export function viewOf(tree) { + const shep = find(tree, "shep"); + if (!shep || !find(tree, "work")) return null; + return shep.hidden ? "space" : "shep"; +} + +export function shepTerminal(all = terminals()) { + return all.find((t) => t.state === "live" && t.labels?.["bercail.role"] === ROLE) ?? null; +} + +function setView(view) { + run("layout.hide", { id: view === "shep" ? "work" : "shep" }); + run("layout.show", { id: view === "shep" ? "shep" : "work" }); +} + +// The view written into a tree, for a layout.set made before a setView has shown. +export function withView(tree, view) { + const shep = find(tree, "shep"), work = find(tree, "work"); + if (shep && work) { shep.hidden = view !== "shep"; work.hidden = view === "shep"; } + return tree; +} + +// Shep's block in `shep-stack`: `shep-
`, added once per terminal and kept, +// like a desk's. Returns the tree to layout.set (with the child to show), a child to +// select, or nothing. `added`: block ids already added, never added twice (the app +// drops a block whose terminal ends; adding it back could loop). +export function stackPlan(tree, shep, added) { + const stack = find(tree, "shep-stack"); + if (!stack) return {}; + const id = shep ? `shep-${shep.address}` : null; + const kids = stack.children ?? []; + const stale = (c) => c.id !== "shep-empty" && c.id !== id; + const add = Boolean(id) && !kids.some((c) => c.id === id) && !added.has(id); + const want = id && (add || kids.some((c) => c.id === id)) ? id : "shep-empty"; + if (add || kids.some(stale)) { + stack.children = kids.filter((c) => !stale(c)); + if (add) stack.children.push({ id, type: "block", content: { terminal: shep.address }, padding: [6, 6] }); + stack.active = want; // with the tree: a separate select can lose to the file + return { set: tree, added: add ? id : null }; + } + if (stack.active !== want && kids.some((c) => c.id === want)) return { select: want }; + return {}; +} + +// Where the keyboard must go so it never types into a hidden terminal: in the Shep +// view, out of a (hidden) pane into Shep; coming back to the space view (`was`: the +// view at the last render), or still on Shep there, to where it was in the space +// (`back`: { pane } or { terminal } of its desk), else the shown tab's first pane. +// `ready`: Shep's block is in the layout (pane.focus fails before). Returns pane.focus +// args, or null. +export function focusPlan(view, was, f, shep, ready, back, ps, space) { + if (!f) return null; + if (view === "shep") return f.pane && shep && ready ? { terminal: shep.address } : null; + const onShep = Boolean(shep) && !f.pane && f.terminal === shep.address; + if (view !== "space" || (was !== "shep" && !onShep)) return null; + let to = null; + if (back?.pane && ps.some((p) => p.pane === back.pane)) to = { pane: back.pane }; + else if (back?.terminal) to = { terminal: back.terminal }; + else if (onShep) { + const tab = space?.tabs.find((t) => t.active); + const pane = ps.find((p) => p.tab === tab?.id && p.terminal) ?? ps.find((p) => p.tab === tab?.id); + if (pane) to = { pane: pane.pane }; + } + if (!to || (to.pane && to.pane === f.pane) || (to.terminal && !f.pane && to.terminal === f.terminal)) return null; + return to; +} + +// Shared by every copy: whether OpenClaw is installed, and a start in flight. +const S = { found: null, check: null, starting: false, error: null, session: null }; + +function checkOpenclaw() { + S.check ??= exec("hc", ["exec", "bash", "-lc", `${OC_PATH}; command -v openclaw`]) + .then((r) => { S.found = r.code === 0; }) + .catch(() => { S.found = false; }) + .finally(() => { S.check = null; }); + return S.check; +} + +// One Shep: none while one is live or starting. hc names are unique among live +// terminals, so a start that races one the list doesn't show yet is refused: fine. +async function startShep() { + if (S.starting || shepTerminal()) return; + S.starting = true; + S.error = null; + try { + await checkOpenclaw(); + if (!S.found) return; + S.session = await shepSession(); + const r = await exec("hc", ["run", "--bg", "--json", "--no-parent", "--no-notify", "--rm", "--name", NAME, + "--cwd", home(), "--label", `bercail.role=${ROLE}`, "--label", `bercail.shep_session=${S.session}`, + "bash", "-lc", OC_RUN, "bash", S.session]); + if (r.code !== 0 && !/"name_taken"/.test(r.stderr + r.stdout)) throw new Error((r.stderr || r.stdout).trim()); + } catch (e) { + S.error = String(e?.message ?? e); + notify({ title: "bercail: Shep did not start", body: S.error, level: "error" }); + } finally { + S.starting = false; + } +} + +function icon(p, size, color) { + if (!p?.icon) return null; + return p.tint ? svg(p.icon).size(size).flex_none().text_color(color) : image(p.icon).w(size).h(size).flex_none(); +} + +export default class BercailShep extends View { + init(_props, cx) { + this.role = props()?.role ?? "nav"; + if (this.role === "modal") return; + cx.spawn(async (cx) => { await checkOpenclaw(); cx.notify(); }); + if (this.role !== "nav") return; + this.added = new Set(); // Shep block ids this copy added + this.back = {}; // space id -> { pane } or { terminal }: where the keyboard was + this.pending = null; // { view, at }: a view this copy set that layout() doesn't show yet + // Once per app session (a mod reload keeps globalThis): the Shep view, and Shep. + // Done in sync(): layout() and terminals() are empty this early. + const app = (globalThis.bercailShep ??= {}); + this.launch = !app.launched; + this.autostart = !app.launched; + app.launched = true; + cx.spawn(async (cx) => { + for (;;) { + const c = await nextCommand(); + await this.command(c); + cx.notify(); + } + }); + } + + async command(c) { + const view = viewOf(layout()); + switch (c) { + case "toggle": if (view) this.show(view === "shep" ? "space" : "shep"); break; + case "show": if (view) this.show("shep"); break; + case "space": if (view) this.show("space"); break; + case "start": await startShep(); break; + } + } + + show(view) { + this.pending = { view, at: Date.now() }; + setView(view); + } + + // Runs on every render: Herdr re-renders on any live change. + sync() { + const tree = layout(); + const view = viewOf(tree); + if (!view) return view; + if (this.launch) { + this.launch = false; + if (view === "space") this.show("shep"); + } + if (this.pending?.view === view || Date.now() - (this.pending?.at ?? 0) > 2000) this.pending = null; + // Start Shep once the core has listed its terminals (it lists them after the + // layout): before that, terminals() can't tell whether Shep runs already. + if (this.autostart && terminals().length > 0) { + this.autostart = false; + startShep(); + } + const shep = shepTerminal(); + const plan = stackPlan(tree, shep, this.added); + if (plan.set) { + if (plan.added) this.added.add(plan.added); + // layout.set writes the whole tree: a view switch still on its way would be undone. + run("layout.set", { layout: this.pending ? withView(plan.set, this.pending.view) : plan.set }); + } else if (plan.select) { + run("layout.select", { stack: "shep-stack", child: plan.select }); + } + const f = focused(); + const ps = panes(); + const was = this.view; + this.view = view; + // Where the keyboard is in the space, kept while the space view stays: hiding the + // Shep view moves it on its own first. + if (view === "space" && was === "space" && f?.space) { + const desk = f.terminal && terminals().find((t) => t.address === f.terminal + && t.labels?.["bercail.role"] === "agent" && t.labels?.["bercail.space"] === f.space); + if (f.pane) this.back[f.space] = { pane: f.pane }; + else if (desk) this.back[f.space] = { terminal: f.terminal }; + } + const ready = Boolean(shep) && !plan.set + && Boolean(find(tree, "shep-stack")?.children?.some((c) => c.id === `shep-${shep.address}`)); + const space = spaces().find((s) => s.id === f?.space); + const to = focusPlan(view, was, f, shep, ready, this.back[f?.space], ps, space); + if (to) run("pane.focus", to); + return view; + } + + render(cx) { + const t = theme(); + switch (this.role) { + case "header": return this.header(t); + case "empty": return this.empty(t); + case "modal": return this.modal(t); + } + const view = this.sync(); + const shep = shepTerminal(); + const on = view === "shep"; + const dot = shep ? t.ansi2 : S.found === false ? t.blocked : t.dim; + // The sidebar's rail (narrower than 160px): the mark alone. + const rail = (find(layout(), "sidebar")?.size?.px ?? Infinity) < 160; + return h_flex().size_full().px(rail ? 0 : 8).items_center().justify_center().bg(t.chrome).text_size(13) + .child(h_flex().id("shep-row").h(34).px(8).gap(9).items_center().rounded(8) + .when(!rail, (el) => el.flex_1()) + .when(on, (el) => el.bg(t.selected)) + .when(!on, (el) => el.hover((s) => s.bg(t.hover))) + .cursor_pointer() + .tooltip(on ? "Shep, the orchestrator" : `Back to Shep (${KEY})`) + .on_click(() => { if (view) this.show("shep"); }) + .child(on || rail + ? h_flex().size(18).flex_none().rounded(5).bg(on ? t.solid : t.raised).items_center().justify_center() + .text_size(10.5).font_bold().text_color(on ? t.solid_foreground : t.foreground).child("S") + : svg("mods/workspace-chrome/chevron-left.svg").size(14).mx(2).flex_none().text_color(t.muted)) + .when(!rail, (el) => el + .child(div().flex_1().truncate().text_color(t.foreground).font_medium().child(on ? "Shep" : "Back to Shep")) + .when(!on, (el) => el.child(div().flex_none().text_size(11).text_color(t.dim).child(KEY))) + .child(div().size(7).mx(3).flex_none().rounded(4).bg(dot)))); + } + + header(t) { + const shep = shepTerminal(); + const word = shep ? "running" : S.found === false ? "OpenClaw missing" : S.starting ? "starting" : "stopped"; + const color = shep ? t.ansi2 : S.found === false ? t.blocked : t.dim; + return h_flex().size_full().px(12).gap(8).items_center().bg(t.chrome).text_size(12.5).text_color(t.muted) + .child(div().size(7).rounded(4).bg(color)) + .child(div().text_color(t.foreground).font_semibold().child("Shep")) + .child(div().flex_1().truncate().text_color(t.dim).child(shep?.title || "OpenClaw TUI, the orchestrator for every desk")) + .when(Boolean(shep?.labels?.["bercail.shep_session"]), (el) => el + .child(div().flex_none().text_color(t.dim).child(shep.labels["bercail.shep_session"]))) + .child(div().text_color(color).child(word)) + .child(div().text_size(11).text_color(t.dim).child(`${KEY} spaces`)); + } + + empty(t) { + const missing = S.found === false; + const busy = S.starting || S.found === null; + const act = missing + ? (cx) => cx.spawn(async (cx) => { await checkOpenclaw(); cx.notify(); }) + : (cx) => cx.spawn(async (cx) => { await startShep(); cx.notify(); }); + const button = (id, label) => div().id(id).px(14).py(6).rounded(6).bg(t.solid).text_color(t.solid_foreground) + .hover((s) => s.opacity(0.85)).on_click((_e, cx) => act(cx)).child(label); + const body = v_flex().size_full().items_center().justify_center().gap(10).px(24).bg(t.background) + .text_color(t.muted).text_size(13); + if (missing) { + return body + .child(div().text_color(t.foreground).font_semibold().child("OpenClaw is not installed")) + .child(div().max_w(460).text_center().child("Shep runs in OpenClaw's terminal UI (openclaw tui). Install OpenClaw, set it up with openclaw onboard, then check again.")) + .child(button("shep-check", "Check again")) + .child(div().text_color(t.dim).text_size(12).child("Your spaces work without it: pick one on the right.")); + } + if (busy) return body.child("Starting Shep…"); + return body + .child(div().text_color(t.foreground).font_semibold().child("Shep is not running")) + .child(button("shep-start", "Start Shep")) + .child(div().text_color(t.dim).text_size(12).child(S.session + ? `Runs openclaw tui --session ${S.session}.` + : "Runs openclaw tui on [shep] session, else your default agent's main session.")) + .when(Boolean(S.error), (el) => el.child(div().max_w(460).text_center().text_color(t.blocked).text_size(12).child(S.error))); + } + + // The bar of an agent opened in a dialog. Its terminal is the one in its pane or + // desk: typing here types there. Closing puts the keyboard back where it was. + modal(t) { + const { terminal, overlay } = props() ?? {}; + const k = terminals().find((x) => x.address === terminal); + const a = agents().find((x) => x.address === terminal); + const status = a?.status ?? (k?.status === "error" ? "blocked" : k?.status) ?? null; + const color = t[status] ?? t.dim; + const spaceId = a?.space ?? k?.labels?.["bercail.space"] ?? null; + const space = spaces().find((s) => s.id === spaceId); + const desk = k?.labels?.["bercail.role"] === "agent"; + const pic = icon(k?.program ?? a?.program, 14, t.foreground); + const button = (id, label, onClick) => div().id(id).px(8).py(2).rounded(5).text_color(t.muted) + .cursor_pointer().hover((s) => s.bg(t.hover).text_color(t.foreground)).on_click(onClick).child(label); + return h_flex().size_full().px(12).gap(8).items_center().bg(t.chrome).border_b(1).border_color(t.border) + .text_size(12.5).text_color(t.muted) + .child(div().size(7).flex_none().rounded(4).bg(color)) + .when(Boolean(pic), (el) => el.child(pic)) + .child(div().flex_none().font_semibold().text_color(t.foreground) + .child(desk ? "Desk" : (k?.name || a?.program?.name || k?.program?.name || terminal || ""))) + .when(Boolean(space), (el) => el.child(div().flex_none().max_w(220).truncate().text_color(t.dim).child(space.name))) + .child(div().flex_1().truncate().text_color(t.dim).child(a?.title ?? k?.title ?? "")) + .when(Boolean(status), (el) => el.child(div().flex_none().text_color(color).child(status))) + .when(Boolean(space), (el) => el.child(button("modal-space", "Show in space", () => { + this.left = true; + closeOverlay(overlay); + run("space.select", { id: space.id }); + if (viewOf(layout())) setView("space"); + if (!desk) run("agent.focus", { terminal }); + }))) + .child(button("modal-close", "✕", () => closeOverlay(overlay))); + } + + // The dialog closed (✕, a click beside it, its terminal ended): the keyboard goes + // back where it was, unless "Show in space" took it elsewhere. + async close() { + if (this.role !== "modal" || this.left) return; + const back = props()?.back; + if (back?.pane && panes().some((p) => p.pane === back.pane)) run("pane.focus", { pane: back.pane }); + else if (back?.terminal && terminals().some((x) => x.address === back.terminal && x.state === "live")) { + run("pane.focus", { terminal: back.terminal }); + } + } +} diff --git a/bercail/mods/bercail-shep/mod.json b/bercail/mods/bercail-shep/mod.json new file mode 100644 index 0000000..777f248 --- /dev/null +++ b/bercail/mods/bercail-shep/mod.json @@ -0,0 +1 @@ +{ "place": "none", "title": "Shep" } diff --git a/bercail/mods/bercail-spaces/main.js b/bercail/mods/bercail-spaces/main.js new file mode 100644 index 0000000..82c440b --- /dev/null +++ b/bercail/mods/bercail-spaces/main.js @@ -0,0 +1,491 @@ +// Workspace spaces: stable colored avatars, worktrees under their checkout, +// grouped by machine when more than one is known. After a space's name, the repo +// its first terminal is in now, only where unusual: branch, ahead/behind. Agent state lives in the agents list, not here. Click to +// switch, right-click for its menu, drag to reorder. +// bercail's copy of Herdr's spaces mod (mod.clone): in the bercail layout a click on a +// space (or a new space) also leaves the Shep view for the space view, and while Shep +// shows no space reads as selected. + +import { View, div, svg } from "gpui-kit"; +import { v_flex, h_flex } from "gpui-base"; +import { spaces, machines, panes, run, openOverlay, exec, hc, theme, shared, setShared, watchFolder, window, layout } from "herdr"; + +function find(node, id) { + if (node?.id === id) return node; + for (const child of node?.children ?? []) { + const found = find(child, id); + if (found) return found; + } + return null; +} + +// The sidebar's rail goes by its width, not a flag: dragging its edge narrows it too. +const narrow = () => (find(layout(), "sidebar")?.size?.px ?? Infinity) < 160; + +// The bercail layout's two views: `shep` (Shep's TUI) or `work` (the shown space) is hidden. +const shepShown = () => { const n = find(layout(), "shep"); return Boolean(n && !n.hidden); }; +function spaceView() { + if (!find(layout(), "shep")) return; + run("layout.hide", { id: "shep" }); + run("layout.show", { id: "work" }); +} +const select = (id) => { run("space.select", { id }); spaceView(); }; +const newSpace = (args) => { run("space.new", args); spaceView(); }; + +// Where a space is now: its first terminal's live folder, else where it was made. +// `ps`: panes(), read once by the caller (each read copies every pane). +function place(s, ps) { + const tabs = new Set((s.tabs ?? []).map((c) => c.id)); + return ps.find((p) => tabs.has(p.tab) && p.cwd)?.cwd || s.folder; +} +const rowHeight = ({ tree }) => (tree ? 28 : 30); +const RAIL_CELL = 32; +const railCell = (el) => h_flex().flex_none().justify_center().child(el); +const where = (s, ps) => JSON.stringify([s.machine ?? null, place(s, ps)]); +const base = (path) => path.replace(/\/+$/, "").split("/").pop(); +// Changes made outside Herdr (an editor's save, a commit in another app): looked +// for this often, in repos the list shows, while the window is in front. +const FALLBACK = 60000; + +// What a stored color name looks like on an avatar (the same in every mod +// that draws space avatars): one strong color per hue, light and dark alike. +const MARK = { ansi6: "#22a99a", ansi3: "#e3814f", ansi4: "#5b8def", ansi5: "#8b7cf6", ansi2: "#3fae6a", ansi1: "#d9649a" }; + +// A space speaks up only when something in it needs you. +export function glyph(status) { + return status === "blocked" || status === "done" ? "●" : "·"; +} + +export function tone(t, status) { + return t[status] ?? t.unknown; +} + +export default class Spaces extends View { + init(_props, cx) { + this.git = {}; // where() -> { name, branch, ahead, behind, repo, linked }, null: not a repo + this.checked = {}; // where() -> when it was last looked at + this.unsure = new Set(); // where()s git couldn't answer for (a machine away) + this.pending = new Set(); // where()s to look at next + this.busy = {}; // pane -> an agent working or a program in front of its shell + this.ready = {}; // machine -> its layout was readable at the last look + this.at = {}; // space -> its where() at the last look + this.watching = new Set(); // local repos' git folders + // Spaces made while this runs wait (at most a moment) for their git, so a + // worktree appears under its repo instead of jumping there. + this.shown = new Set(spaces().map((s) => s.id)); + this.born = {}; + this.pressed = null; + this.over = null; + this.machines = []; + this.reconnecting = JSON.parse(localStorage.getItem("machines.reconnecting") ?? "{}"); + for (const state of Object.values(this.reconnecting)) state.busy = false; + this.folded = new Set(JSON.parse(localStorage.getItem("machines.folded") ?? "[]")); + this.refreshMachines(cx); + cx.timer.every(15000, cx => this.refreshMachines(cx)); + // `git status` walks the whole tree, on another machine a process there: + // only when its answer could have changed (see `due`). + cx.timer.every(250, (cx) => this.checkNew(cx)); + } + + checkNew(cx) { + const now = Date.now(); + const sp = spaces(); + for (const s of sp) if (!this.shown.has(s.id)) this.born[s.id] ??= now; + const due = this.due(now, sp, panes()); + if (due.size) this.refreshGit(due, cx); + if (sp.some((s) => !this.shown.has(s.id)) || this.orderingDue(now)) cx.notify(); + } + + // Places whose git could have changed: new ones (a new space, a cd), where a + // command or an agent's turn just ended (a checkout, a commit, edits), those on + // a machine that is back, and repos in view whose fallback is due. + due(now, sp, ps) { + // Moved there: what it knew of that place may be old. + const at = {}; + for (const s of sp) at[s.id] = where(s, ps); + const keys = new Set(Object.values(at).filter((k) => JSON.parse(k)[1])); + const due = new Set([...keys].filter((k) => !(k in this.git))); + for (const [id, k] of Object.entries(at)) if (this.at[id] && this.at[id] !== k && keys.has(k)) due.add(k); + this.at = at; + const spaceOf = {}; + for (const s of sp) for (const t of s.tabs ?? []) spaceOf[t.id] = s; + const busy = {}; + for (const p of ps) { + if (!p.terminal || !spaceOf[p.tab]) continue; + busy[p.pane] = p.status === "working" || !(p.program?.shell || p.program?.agent); + if (this.busy[p.pane] && !busy[p.pane]) this.sameRepo(at[spaceOf[p.tab].id], keys).forEach((k) => due.add(k)); + } + this.busy = busy; + for (const m of machines()) { + const ready = m.layout === "ready"; + if (ready && this.ready[m.name] === false) for (const k of keys) if (JSON.parse(k)[0] === m.name) due.add(k); + this.ready[m.name] = ready; + } + if (window().active) { + for (const k of keys) { + const asks = this.git[k] || this.unsure.has(k); + if (asks && !this.folded.has(JSON.parse(k)[0]) && now - (this.checked[k] ?? 0) > FALLBACK) due.add(k); + } + } + return due; + } + + // `key` and the other places in its repo (a commit there moves their ahead/behind). + sameRepo(key, keys) { + const repo = this.git[key]?.repo; + return [key, ...(repo ? [...keys].filter((k) => this.git[k]?.repo === repo) : [])]; + } + + refreshMachines(cx) { + cx.spawn(async cx => { + try { const r = await hc(["machine", "ls", "--json"]); if (r.code === 0) this.machines = (JSON.parse(r.stdout).machines ?? []).filter((m) => !m.temporary); } catch {} + cx.notify(); + }); + } + + rebuilt(machine) { + return machine?.state === "unreachable" && /^a different core answers there \("[^"]+"\)$/.test(machine.why ?? ""); + } + + reconnect(machine, cx) { + const name = machine.name; + if (this.reconnecting[name]?.busy) return; + const state = this.reconnecting[name] ??= { machine, removed: false }; + state.busy = true; + state.error = null; + localStorage.setItem("machines.reconnecting", JSON.stringify(this.reconnecting)); + cx.notify(); + cx.spawn(async cx => { + try { + const ask = async args => { + const r = await hc([...args, "--json"]); + if (r.code !== 0) { + let error; + try { error = JSON.parse(r.stdout || r.stderr).error?.message; } catch {} + throw new Error(error || r.stderr || r.stdout || "Couldn't connect."); + } + }; + if (!state.removed) { + await ask(["machine", "rm", name]); + state.removed = true; + localStorage.setItem("machines.reconnecting", JSON.stringify(this.reconnecting)); + } + const args = ["machine", "add"]; + if (machine.via) args.push("--via", machine.target); + else args.push(machine.target); + args.push("--name", name); + if (machine.session) args.push("--session", machine.session); + if (machine.temporary) args.push("--temp"); + await ask(args); + this.folded.delete(name); + localStorage.setItem("machines.folded", JSON.stringify([...this.folded])); + delete this.reconnecting[name]; + } catch (e) { + state.error = String(e.message ?? e); + state.busy = false; + } + localStorage.setItem("machines.reconnecting", JSON.stringify(this.reconnecting)); + this.refreshMachines(cx); + cx.notify(); + }); + } + + // One look at a time, each place once however often it was asked for meanwhile. + refreshGit(keys, cx) { + for (const k of keys) this.pending.add(k); + if (this.refreshing) return; + this.refreshing = true; + cx.spawn(async (cx) => { + while (this.pending.size) { + const keys = [...this.pending]; + this.pending.clear(); + for (const key of keys) await this.check(key, cx); + } + const used = new Set(Object.values(this.git).filter(Boolean).map((g) => JSON.parse(g.repo)).filter(([on]) => !on).map(([, repo]) => repo)); + for (const repo of this.watching) if (!used.has(repo)) this.watching.delete(repo); + this.refreshing = false; + cx.notify(); + }); + } + + // One place's git: branch, ahead/behind, its repo. + async check(key, cx) { + const [on, cwd] = JSON.parse(key); + this.checked[key] = Date.now(); + this.unsure.delete(key); + try { + // No optional locks: a status that touched the index would wake the watch again. + // Writes nothing under .git (index lock, fsmonitor cookie): watch() would wake on it. + const r = await exec("git", ["--no-optional-locks", "-c", "core.fsmonitor=false", "status", "--porcelain=v2", "--branch"], { cwd, on }); + if (r.code !== 0) { + // Only git's own "no" is remembered as no repo; anything else is asked again. + if (!/not a git repository/i.test(r.stderr)) this.unsure.add(key); + this.git[key] = null; + return; + } + // The repo's own .git folder names it; a worktree's top differs from it. + const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd, on }); + const common = await exec("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, on }); + const repo = common.stdout.trim(); + const name = repo.endsWith("/.git") ? base(repo.slice(0, -5)) : base(repo).replace(/\.git$/, ""); + const info = { name, branch: null, ahead: 0, behind: 0, repo: JSON.stringify([on, repo]), linked: repo !== `${top.stdout.trim()}/.git` }; + for (const line of r.stdout.split("\n")) { + if (line.startsWith("# branch.head ")) info.branch = line.slice(14); + else if (line.startsWith("# branch.ab ")) { + const [a, b] = line.slice(12).split(" "); + info.ahead = Math.abs(parseInt(a, 10)) || 0; + info.behind = Math.abs(parseInt(b, 10)) || 0; + } + } + this.git[key] = info; + if (!on) this.watch(repo, cx); + } catch { + // Couldn't ask (the machine is away): keep what it showed, look again later. + this.git[key] ??= null; + this.unsure.add(key); + } + } + + watch(repo, cx) { + if (this.watching.has(repo)) return; + this.watching.add(repo); + cx.spawn(async (cx) => { + try { + while (this.watching.has(repo)) { + await watchFolder(repo); + const mine = JSON.stringify([null, repo]); + if (this.watching.has(repo)) this.refreshGit(Object.keys(this.git).filter((k) => this.git[k]?.repo === mine), cx); + } + } catch { this.watching.delete(repo); } + }); + } + + menu(w) { + // A popup is as tall as its items; height is only the most. + openOverlay({ mod: "menu", kind: "popup", width: 200, height: 260, props: { tab: "space", target: { id: w.id } } }); + } + + orderingDue(now) { + return this.ordering && now - this.ordering.at >= 1000 + && this.ordering.machines.every(name => machines().some(m => m.name === name && m.layout === "ready")); + } + + // Spaces in order, each worktree space moved under its main checkout's. + ordered() { + const sp = spaces(), ps = panes(); + const ready = (w) => this.shown.has(w.id) || where(w, ps) in this.git || Date.now() - (this.born[w.id] ??= Date.now()) > 1500; + for (const w of sp) if (!this.shown.has(w.id) && ready(w)) this.shown.add(w.id); + const list = sp.map((w, i) => ({ w, i, g: this.git[where(w, ps)] ?? null })); + const mains = new Set(list.filter((r) => r.g && !r.g.linked).map((r) => r.g.repo)); + const nested = (r) => r.g?.linked && mains.has(r.g.repo); + const out = []; + const placed = new Set(); // repos whose worktrees are listed (under the first of its spaces) + for (const r of list) { + if (nested(r)) continue; + out.push({ ...r, tree: null }); + if (!r.g || r.g.linked || placed.has(r.g.repo)) continue; + placed.add(r.g.repo); + const kids = list.filter((k) => nested(k) && k.g.repo === r.g.repo); + kids.forEach((k, n) => out.push({ ...k, tree: n === kids.length - 1 ? "└─" : "├─" })); + } + // Simulate the batch so later indexes include earlier moves. An attempted + // write can lose a sync conflict: allow the echo to settle before retrying. + const order = sp.map(w => w.id); + const target = out.map(r => r.w.id); + const key = JSON.stringify([order, target, machines()]); + if (order.every((id, i) => id === target[i])) { + this.ordering = null; + } else if (this.ordering?.key !== key || this.orderingDue(Date.now())) { + const moved = new Set(); + this.ordering = { key, at: Date.now(), machines: [] }; + for (let i = 0; i < out.length; i++) { + const from = order.indexOf(out[i].w.id); + if (from === i) continue; + run("space.move", { id: out[i].w.id, index: i + 1 }); + moved.add(out[i].w.machine ?? null); + order.splice(i, 0, order.splice(from, 1)[0]); + } + this.ordering.machines = [...moved]; + } + return out.filter(r => this.shown.has(r.w.id)); + } + + // The rows, under a header per machine once one isn't this computer. + grouped(t, collapsed) { + const rows = this.ordered(); + const draw = (r) => (collapsed ? this.compact(r, t) : this.row(r, t)); + const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name), ...Object.keys(this.reconnecting)])].sort(); + const loading = m => { + const layout = machines().find(k => k.name === m)?.layout; + return layout === "loading" || (!layout && this.machines.some(k => k.name === m && k.state === "connected")) + || spaces().some(s => (s.machine ?? null) === m && !this.shown.has(s.id)); + }; + const loadingRow = m => h_flex().id(`machine-loading-${m ?? "local"}`).h(28).flex_none().px(collapsed ? 0 : 8).items_center().when(collapsed, el => el.justify_center()).text_size(12).text_color(t.dim).child(collapsed ? "…" : "Loading…"); + if (!remote.length) return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw); + const machineNames = [null, ...remote]; + return machineNames.flatMap((m) => { + const mine = rows.filter((r) => (r.w.machine ?? null) === m); + const repair = this.reconnecting[m]; + const machine = this.machines.find(k => k.name === m) ?? repair?.machine; + const connected = !m || machine?.state === "connected"; + const rebuilt = this.rebuilt(machine) || !!repair; + const folded = this.folded.has(m); + const icon = svg(`mods/workspace-chrome/${m ? "server" : "monitor"}.svg`).size(13).flex_none(); + const badge = div().absolute().right(-3).bottom(-2).size(6).rounded(3).border_1().border_color(t.chrome).bg(connected ? t.ansi2 : t.dim); + const head = h_flex().id(`machine-${m ?? "local"}`).h(26).flex_none().px(collapsed ? 0 : 8).gap(7).items_center().text_size(11).text_color(t.dim) + .when(collapsed, el => el.mt(4).w(RAIL_CELL).justify_center().rounded(6).tooltip(m ? `${m} · ${connected ? "connected" : (machine?.state ?? "asleep").replace(/_/g, " ")}` : "This computer")) + .cursor_pointer().hover(s => s.bg(t.hover)) + .on_click((_e, cx) => { + folded ? this.folded.delete(m) : this.folded.add(m); + localStorage.setItem("machines.folded", JSON.stringify([...this.folded])); + cx.notify(); + }) + .when(!collapsed, el => el.child(icon)) + .when(collapsed, el => el.child(div().relative().flex_none().child(icon).when(Boolean(m), el => el.child(badge)))) + .when(!collapsed, el => el.child(div().flex_1().truncate().child(m ?? "This computer")) + .when(!connected && !rebuilt, el => el.child(div().flex_none().child((machine?.state ?? "asleep").replace(/_/g, " ")))) + .when(rebuilt, el => el.child(h_flex().flex_none().gap(3).items_center() + .child(div().max_w(140).truncate().child(repair?.error || "rebuilt ·")) + .child(div().id(`machine-connect-${m}`).text_color(repair?.error ? t.blocked : t.muted) + .hover(s => s.text_color(t.foreground)) + .tooltip(repair?.error || "This machine was rebuilt or replaced. Click to connect to the new one.") + .on_click((_e, cx) => { cx.stop_propagation(); this.reconnect(machine, cx); }) + .child(repair?.busy ? "Connecting…" : "Connect")))) + .when(connected, el => el.child(div().id(`machine-add-${m ?? "local"}`).flex_none().px(4).rounded(4).hover(s => s.bg(t.hover).text_color(t.foreground)) + .on_click((_e, cx) => { cx.stop_propagation(); newSpace({ machine: m ?? "" }); }).child("+"))) + .child(div().size(6).rounded(3).flex_none().bg(connected ? t.ansi2 : t.dim)) + .child(svg(`mods/workspace-chrome/chevron-${folded ? "right" : "down"}.svg`).size(11).flex_none())); + const empty = h_flex().id(`machine-new-${m ?? "local"}`).h(28).flex_none().px(collapsed ? 0 : 8).gap(7).items_center().rounded(7) + .text_size(12).text_color(t.dim).hover(s => s.bg(t.hover).text_color(t.foreground)) + .on_click(() => newSpace({ machine: m ?? "" })) + .when(collapsed, el => el.w(RAIL_CELL).rounded(6).justify_center().tooltip(`New space on ${m ?? "this computer"}`) + .child(h_flex().size(18).rounded(5).border_1().border_color(t.dim).items_center().justify_center() + .child(svg("mods/workspace-chrome/plus.svg").size(10)))) + .when(!collapsed, el => el.child(svg("mods/workspace-chrome/plus.svg").size(12)).child("New space there")); + if (!collapsed && rebuilt) return [head]; + const cell = collapsed ? railCell : (el) => el; + return [cell(head), ...(folded ? [] : mine.length ? mine.map(draw) : [loading(m) ? loadingRow(m) : cell(empty)])]; + }); + } + + // Only what's unusual, after the name: "herdr \u00b7 spike \u21912 \u21931". The repo or + // branch goes when the name already says it (a worktree is named after its branch), + // and master/main go: nearly every space is on one. + gitTail(g, tree, name, t) { + const raw = g.branch ?? "detached"; + const same = (a) => a.toLowerCase() === name.toLowerCase(); + const branch = same(raw) || raw === "master" || raw === "main" ? "" : raw; + const repo = tree || same(g.name) ? "" : g.name; + const where = [repo, branch].filter(Boolean).join(" \u00b7 "); + return h_flex() + .flex_1() + .min_w(0) + .gap(5) + .justify_end() + .items_center() + .text_size(11.5) + .text_color(t.dim) + .when(Boolean(where), (el) => el.child(div().min_w(0).truncate().child(where))) + .when(g.ahead > 0, (el) => el.child(div().flex_none().text_color(t.ansi2).child(`\u2191${g.ahead}`))) + .when(g.behind > 0, (el) => el.child(div().flex_none().text_color(t.ansi1).child(`\u2193${g.behind}`))); + } + + row({ w, i, g, tree }, t) { + const on = w.active && !shepShown(); + const fromIndex = this.pressed === null ? -1 : spaces().findIndex(s => s.id === this.pressed); + const colors = shared("space.colors") ?? {}; + const color = colors[w.id] ?? "ansi6"; + const fill = color.startsWith("#") ? color : (MARK[color] ?? t[color]); + return v_flex() + .relative() + .id(`space-${w.id}`) + .px(8) + .h(rowHeight({ tree })) + .flex_none() + .justify_center() + .rounded(8) + .when(on, (el) => el.bg(t.selected)) + .when(!on, (el) => el.hover((s) => s.bg(t.hover))) + .when(this.pressed === w.id && this.over !== w.id, (el) => el.opacity(0.5)) + .when(this.pressed !== null && this.over === w.id && this.pressed !== w.id, (el) => el.child( + div().absolute().left(0).right(0).h(2).bg(t.accent) + .when(fromIndex < i, line => line.bottom(0)) + .when(fromIndex > i, line => line.top(0)), + )) + .on_mouse_down("left", () => { + this.pressed = w.id; + this.over = w.id; + }) + .on_mouse_move((_e, cx) => { + const source = spaces().find(s => s.id === this.pressed); + const over = source && (source.machine ?? null) === (w.machine ?? null) ? w.id : null; + if (this.pressed !== null && this.over !== over) { + this.over = over; + cx.notify(); + } + }) + .on_mouse_up("left", (_e, cx) => { + const from = this.pressed; + const over = this.over; + this.pressed = null; + this.over = null; + if (from !== null && over === w.id && from !== w.id) { + const index = spaces().findIndex(s => s.id === w.id); + if (index !== -1) run("space.move", { id: from, index: index + 1 }); + } else if (from === w.id) select(w.id); + cx.notify(); + }) + .on_mouse_down("right", () => this.menu(w)) + .child( + h_flex() + .gap(9) + .items_center() + .when(Boolean(tree), el => el.pl(22).child(svg("mods/workspace-chrome/branch.svg").size(13).text_color(t.dim))) + .when(!tree, el => el.child(h_flex().size(18).flex_none().rounded(5).bg(fill).items_center().justify_center().text_size(10.5).font_bold().text_color("#ffffff").child([...w.name][0]?.toUpperCase() ?? "?"))) + .child(div().flex_shrink(0).max_w(150).truncate().text_color(t.foreground).child(w.name)) + .child(g ? this.gitTail(g, tree, w.name, t) : div().flex_1()), + ); + } + + compact({ w, i }, t) { + return railCell(h_flex() + .id(`space-${w.id}`) + .relative() + .w(RAIL_CELL) + .h(26) + .rounded(6) + .justify_center() + .items_center() + .when(w.active && !shepShown(), (el) => el.bg(t.selected)) + .when(!w.active || shepShown(), (el) => el.hover((s) => s.bg(t.hover))) + .on_click(() => select(w.id)) + .child(div().text_color(w.active && !shepShown() ? t.foreground : t.muted).child(String(i + 1))) + .child(div().absolute().right(5).text_color(tone(t, w.status)).child(glyph(w.status)))); + } + + render() { + const t = theme(); + const collapsed = narrow(); + const palette = ["ansi6", "ansi3", "ansi4", "ansi5", "ansi2", "ansi1"]; + const sp = spaces(); + const ids = new Set(sp.map(p => p.id)); + const kept = Object.fromEntries(Object.entries(shared("space.colors") ?? {}).filter(([id]) => ids.has(id))); + let changed = Object.keys(kept).length !== Object.keys(shared("space.colors") ?? {}).length; + for (const p of sp) { + if (kept[p.id] || p.name === "~") continue; + const uses = c => Object.values(kept).filter(v => v === c).length; + kept[p.id] = palette.reduce((best, c) => uses(c) < uses(best) ? c : best); + changed = true; + } + if (changed) setShared("space.colors", kept); + return v_flex().size_full().min_h(0).bg(t.chrome).text_size(13) + .child(h_flex().h(31).pl(16).pr(10).items_center().justify_between().text_size(11.5).text_color(t.dim) + .when(collapsed, el => el.pl(0).pr(0).justify_center()) + .when(!collapsed, el => el.child("Spaces")) + .child(div().id("space-new").size(26).flex_none().flex().items_center().justify_center().rounded(6).hover(s => s.bg(t.hover).text_color(t.foreground)) + .on_click(() => newSpace()).child(svg("mods/workspace-chrome/plus.svg").size(14)))) + .child(v_flex().flex_1().min_h(0).px(collapsed ? 0 : 8).pb(14).overflow_y_scrollbar().mode("always") + .on_mouse_up("left", (_e, cx) => { this.pressed = null; cx.notify(); }) + .children(this.grouped(t, collapsed))); + } +} diff --git a/bercail/mods/bercail-spaces/mod.json b/bercail/mods/bercail-spaces/mod.json new file mode 100644 index 0000000..7ff8f70 --- /dev/null +++ b/bercail/mods/bercail-spaces/mod.json @@ -0,0 +1,8 @@ +{ + "place": "none", + "title": "Spaces", + "cloned_from": { + "name": "spaces", + "version": "0.1.14" + } +} \ No newline at end of file diff --git a/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index 1773071..8f4a31f 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -38,6 +38,11 @@ judge = "claude" # works when off: the lead reviews alone. council = false +# [shep] +# The OpenClaw session the Shep view opens, by its full agent:: key. +# Unset: the main session of OpenClaw's default agent (openclaw agents list). +# session = "agent:main:main" + # [git] # Git backend per repo: gh (git + GitHub CLI), gt (Graphite), or origin (Cursor # Origin). Keys are main-checkout paths; linked worktrees share that choice. diff --git a/docs/README.md b/docs/README.md index 7e35d0e..4fa30ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,12 +4,12 @@ The [README](../README.md) is the tour. These pages are the reference. - [install](install.md): mise + packslip, curl, what the installer touches, coming from 0.8, first run. - [commands](cli.md): the `bercail` CLI, the shell helpers (`dev`, `wtc`, …), and the skills. -- [keys](keys.md): the layout at a glance and every key. +- [keys](keys.md): the layout's two views at a glance and every key. - [config](config.md): `config.toml` and the other files bercail manages. - [review](review.md): tuicr when the desk is done, and `bercail review-pr` for someone else's PR. - [handoff](handoff.md): one beads issue per job, headless or in a sibling desk. - [bstack](bstack.md): opencode on your own Claude, ChatGPT, and X plans. - [the bstack guide](bstack/README.md): a walk-through, with prompts to copy. -- [Shep](shep.md): the Grok bot that runs bercail, and agent status webhooks. +- [Shep](shep.md): the Grok bot that runs bercail, the Shep view, and agent status webhooks. - [development](development.md): hacking on bercail, dependencies, platforms. - [Herdr agents and subagents](herdr-agents-subagents.md): a proposal for Herdr, so desks get their own row in the agents list. diff --git a/docs/config.md b/docs/config.md index f386bac..891da30 100644 --- a/docs/config.md +++ b/docs/config.md @@ -22,15 +22,18 @@ lead = "claude" build = "grok" judge = "claude" +[shep] # optional +session = "agent:sheep:main" # the OpenClaw session the Shep view opens + [git] # optional, one line per main checkout "~/Work/some-repo" = "gh" # gh | gt | origin ``` -After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md); `[git]` in [handoff](handoff.md#git-backend). Legacy `[graphite]` booleans still work (`true` → `gt`, `false` → `gh`), but `[git]` takes precedence. +After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md); `[shep] session` in [Shep](shep.md#the-shep-view) (unset: your default OpenClaw agent's `main` session); `[git]` in [handoff](handoff.md#git-backend). Legacy `[graphite]` booleans still work (`true` → `gt`, `false` → `gh`), but `[git]` takes precedence. ## Files bercail manages -- `~/.config/herdr-app/layouts/bercail.json`, `mods/bercail-desk/`, `mods/bercail-agents/` and `mods/bercail-review/` (the app rewrites the layout as you use it, so `bercail update` replaces it only when bercail ships a new one), and the `layout:bercail` section of `keys.json` (see [keys](keys.md)). +- `~/.config/herdr-app/layouts/bercail.json`, `mods/bercail-desk/`, `mods/bercail-shep/`, `mods/bercail-spaces/`, `mods/bercail-agents/` and `mods/bercail-review/` (the app rewrites the layout as you use it, so `bercail update` replaces it only when bercail ships a new one), and the `layout:bercail` section of `keys.json` (see [keys](keys.md)). - `~/.config/bercail/` (`config.toml`, `shell/`, `config-reader.sh`, `config.toml.example`, `notify.d/`), and the marker block in your `~/.bashrc` or `~/.zshrc` that sources the shell helpers. - worktrunk hooks (`~/.config/worktrunk/config.toml`, `herdr-layout.sh`). - `~/.local/bin/{bercail,agentic-dev,bercail-desk-agent,bercail-desk-claude-hook}` (a mise or packslip install keeps its own `bercail`), and `~/.local/share/bercail/`. diff --git a/docs/development.md b/docs/development.md index 41d79a1..15e50e7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,6 +5,8 @@ ```bash shellcheck install.sh lib/*.sh bin/bercail bin/bercail-desk-agent bin/bercail-desk-claude-hook config/shell/bercail.inc.sh config/worktrunk/herdr-layout.sh herdr-app check bercail/mods/bercail-desk +herdr-app check bercail/mods/bercail-shep +herdr-app check bercail/mods/bercail-spaces herdr-app check bercail/mods/bercail-agents herdr-app check bercail/mods/bercail-review for t in tests/*_test.sh; do bash "$t" || break; done diff --git a/docs/keys.md b/docs/keys.md index a288a6b..a2a531a 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -2,22 +2,37 @@ Keyboard and mouse both work: `Ctrl+Shift` keys like the rest of the Herdr app, a file tree you can click, and `j`/`k` in tuicr. +The window has two views. It opens on the **Shep view**: Shep, OpenClaw's terminal UI, on the left, and the navigator (Shep, spaces, agents) on the right. + ``` -┌─────────┬─────────────────────────────────────────────────────┐ -│ │ tabs: Shell | Review* | … Ctrl+Shift+S / R │ -│ sidebar ├──────────────┬────────────────────────┬─────────────┤ -│ spaces │ desk agent │ the tab's panes │ changes │ -│ agents │ Ctrl+Shift+A │ (tuicr on agent done) │ or files │ -│ │ │ │ +G / +F │ -└─────────┴──────────────┴────────────────────────┴─────────────┘ +┌──────────────────────────────────────────┬─────────────┐ +│ Shep: openclaw tui --session │ ● Shep │ +│ ([shep] session or agent::main) │ spaces │ +│ │ agents │ +└──────────────────────────────────────────┴─────────────┘ ``` -The desk (bstack when it is on, else your `[agent] command`) sits under the tab bar, beside the current tab's panes. It is an hc terminal, so it keeps running when the window closes or the Shell's program ends. Supported sessions [come back after a reboot](config.md#desks-after-a-reboot). The sidebar's agents list (bercail's `bercail-agents` mod) has a row for each desk; click it to focus that desk. +Click a space and the **space view** shows it: the navigator moves to the left, beside that space's desk, tabs and panels. The navigator's top row reads **Back to Shep**. + +``` +┌─────────────┬─────────────────────────────────────────────────────┐ +│ ‹ Back to │ tabs: Shell | Review* | … Ctrl+Shift+S / R │ +│ Shep ├──────────────┬────────────────────────┬─────────────┤ +│ spaces │ desk agent │ the tab's panes │ changes │ +│ agents │ Ctrl+Shift+A │ (tuicr on agent done) │ or files │ +│ │ │ │ +G / +F │ +└─────────────┴──────────────┴────────────────────────┴─────────────┘ +``` + +Switching views starts and stops nothing. Shep, the desks and every shell keep running while hidden, and each view comes back as you left it, the keyboard included. Shep is one terminal for every desk, not part of any space; it is found again after a restart, so no second TUI starts. It starts on its own once per app session; quit it and it stays quit until **Start Shep**. Without OpenClaw the Shep view says how to install it, and the spaces work as before. See [Shep](shep.md#the-shep-view). + +The desk (bstack when it is on, else your `[agent] command`) sits under the tab bar, beside the current tab's panes. It is an hc terminal, so it keeps running when the window closes or the Shell's program ends. Supported sessions [come back after a reboot](config.md#desks-after-a-reboot). The agents list (bercail's `bercail-agents` mod) has a row for each desk. Click any agent, desk or not, to open its own terminal in a dialog over either view: while it is open, what you type goes to that agent. ✕ or a click beside the dialog closes it and puts the keyboard back; **Show in space** goes to its space instead. In the bercail layout (`~/.config/herdr-app/keys.json`, section `layout:bercail`): | Key | Action | |-----|--------| +| `Ctrl+Shift+H` | Shep view / space view | | `Ctrl+Shift+A` | Focus the desk agent, or start it | | `Ctrl+Shift+R` | Review | | `Ctrl+Shift+S` | Shell tab | @@ -28,6 +43,8 @@ In the bercail layout (`~/.config/herdr-app/keys.json`, section `layout:bercail` | `Alt+Shift+Up` / `Down` | Previous/next agent, desks included, in sidebar order | | `Ctrl+Shift+Q` | Close this space | +The space and agent keys act in the space view. + `bercail update` keeps the keys you changed or removed in that section and warns about each one, and about any `terminal` key that takes one of bercail's. `bercail doctor` lists them too. The rest are the Herdr app's: `Ctrl+Shift+T`/`W` new/close tab, `Ctrl+Shift+O`/`E` split, `Ctrl+Shift+X` close pane, `Ctrl+Shift+Z` zoom, `Alt+1`…`9` tabs, `Alt+H`/`J`/`K`/`L` panes, `Ctrl+Shift+B` sidebar, `Ctrl+Shift+L` layout, `Ctrl+Shift+?` all keys. diff --git a/docs/shep.md b/docs/shep.md index f1cc055..2ec2065 100644 --- a/docs/shep.md +++ b/docs/shep.md @@ -21,6 +21,28 @@ To review someone else's PR there is no issue and no stage: Shep runs `bercail r Bercail works without Shep, but Shep is the intended orchestrator. To do Shep's part by hand, write the issue with `bd create`, then run the same `handoff-spawn` command. +## The Shep view + +The bercail layout opens on Shep: OpenClaw's terminal UI (`openclaw tui --session `) fills the window, with the spaces and agents on the right. It is the control room for every desk; the desks stay in their spaces. + +- **Start.** The app starts Shep once per session as one hc terminal, `bercail-shep` (label `bercail.role=shep`), outside every space. A Shep that is already running, after a window restart for example, is shown again rather than started twice. Quit the TUI and it stays quit until you click **Start Shep**. +- **Spaces.** Click a space to see it: the navigator moves left and the space's desk, tabs and panels fill the rest. **Back to Shep** at the top of the navigator, or `Ctrl+Shift+H`, returns to the same Shep session. Nothing is started or stopped by switching, and the keyboard goes back where it was in each view. +- **Agents.** Click an agent or a desk in the agents list to open its own terminal in a dialog over either view. While it is open, what you type goes to that agent; nothing is sent for you. ✕ or a click beside the dialog closes it and puts the keyboard back where it was; **Show in space** goes to its space. +- **Authority.** The view only shows things. Shep's tools, approvals and channels are OpenClaw's, the same as in any other terminal, and the desks' rules don't change. + +It needs: + +- **OpenClaw** on the PATH that herdr-core gives its terminals (`~/.local/bin` and mise shims are added). Without it, the Shep view says so and has a **Check again** button; the spaces work as usual. +- **A Gateway** that `openclaw tui` reaches with its own settings (`openclaw onboard`, then `openclaw gateway status`). bercail passes no URL, token or password. When the TUI exits with an error, its message stays on screen until you press Enter. +- **An agent session.** Shep always opens one session by its full key, so it joins the conversation you already have with that agent (the same one as its WebChat) instead of whatever agent `openclaw tui` would pick from its folder. Set it in `~/.config/bercail/config.toml`: + + ```toml + [shep] + session = "agent:sheep:main" # agent:: + ``` + + Without it, Shep opens the main session of OpenClaw's default agent (`openclaw agents list` marks it), `agent::main`. `openclaw sessions --agent ` lists an agent's sessions. A key that is not `agent::…`, or no default agent, keeps Shep stopped and the Shep view says what to set. The header shows the key Shep runs on. A Shep that is already running keeps its session: to switch, quit it and click **Start Shep**. + ## agent status webhooks `bercail notify run` follows the desk agents (hc terminals labelled `bercail.role=agent`) with `hc events` and posts their status changes to webhooks, one sink per bot. The desk mod keeps it running as the hidden hc terminal `bercail-notify`. It sends only these: diff --git a/lib/config.sh b/lib/config.sh index 24aad01..9d9e6be 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -82,12 +82,15 @@ read_layout_review() { # shellcheck disable=SC2034 # Read by the installer that sources this library. RECONFIGURE=0 -# The Herdr app half of bercail: the bercail layout, its mods (the desk, and the -# agents list, which also lists each desk), and its keys. +# The Herdr app half of bercail: the bercail layout, its mods (the desk, Shep's +# view, the spaces list that switches to a space, and the agents list, which also +# lists each desk and opens agents in a dialog), and its keys. # Paths are relative to bercail/ (the shared layout folder) in the source and to the app's home. HERDR_APP_LAYOUT="layouts/bercail.json" -HERDR_APP_MODS=(bercail-desk bercail-agents bercail-review) +HERDR_APP_MODS=(bercail-desk bercail-shep bercail-spaces bercail-agents bercail-review) HERDR_APP_MOD_FILES=(mods/bercail-desk/main.js mods/bercail-desk/mod.json + mods/bercail-shep/main.js mods/bercail-shep/mod.json + mods/bercail-spaces/main.js mods/bercail-spaces/mod.json mods/bercail-agents/main.js mods/bercail-agents/mod.json mods/bercail-review/main.js mods/bercail-review/mod.json) HERDR_APP_KEYS_SECTION="layout:bercail" @@ -99,12 +102,15 @@ _herdr_app_shipped_layout() { printf '%s/herdr-app/%s' "$AGENTIC_DEV_SHARE_DIR" "$HERDR_APP_LAYOUT" } -# The layout without what the app changes at run time: the desks' blocks (and the -# shared agent block of older layouts), which stack child shows, and column weights. +# The layout without what the app changes at run time: the desks' and Shep's blocks +# (and the shared agent block of older layouts), which stack child shows, which view +# shows, and column weights. _herdr_app_layout_shape() { jq -S 'walk(if type == "object" then del(.active) + | if .id == "shep" or .id == "work" then del(.hidden) else . end | if .id == "agent-stack" then .children |= map(select(.id != "agent" and (.id | startswith("desk-") | not))) else . end + | if .id == "shep-stack" then .children |= map(select(.id == "shep-empty")) else . end | if (.size | type) == "object" then del(.size.weight) else . end else . end)' "$1" 2>/dev/null } diff --git a/lib/help.sh b/lib/help.sh index c812f80..7f49427 100644 --- a/lib/help.sh +++ b/lib/help.sh @@ -95,6 +95,7 @@ Config: ~/.config/bercail/config.toml desk agent default, review, [bstack] ~/.config/herdr-app/layouts/bercail.json the layout ~/.config/herdr-app/mods/bercail-desk/ the desk mod + ~/.config/herdr-app/mods/bercail-shep/ the Shep view (openclaw tui) and Back to Shep ~/.config/herdr-app/keys.json its "layout:bercail" keys (update keeps yours) ~/.config/worktrunk/herdr-layout.sh ~/.config/worktrunk/config.toml worktrunk hooks diff --git a/tests/agents_mod_test.sh b/tests/agents_mod_test.sh index 33875d2..b1ce30d 100755 --- a/tests/agents_mod_test.sh +++ b/tests/agents_mod_test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # shellcheck shell=bash -# The bercail-agents mod: a desk under a bash Shell gets its own row in the agents list. +# The bercail-agents mod: a desk under a bash Shell gets its own row in the agents list, +# and a click opens the agent's terminal in a dialog. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -25,7 +26,7 @@ export const theme = () => new Proxy({}, { get: (_t, k) => String(k) }); export const shared = () => null; export const setShared = () => {}; export const setAgentOrder = (order) => { h().order = order; }; -export const openOverlay = () => {}; +export const openOverlay = (spec) => { h().overlays.push(spec); return "o1"; }; export const closeOverlay = () => {}; export const props = () => ({}); export const window = () => ({ platform: "linux" }); @@ -72,7 +73,7 @@ const agent = (address, space, status = "idle") => ({ }); function render(state) { - globalThis.herdr = { calls: [], focused: { space: "s1" }, spaces, ...state }; + globalThis.herdr = { calls: [], overlays: [], focused: { space: "s1" }, spaces, ...state }; els.length = 0; const v = new Agents(); v.init({}, { timer: { every: () => {} } }); @@ -95,12 +96,21 @@ if (!row("t2")) fail("a Shell sub-agent desk has no row"); if (!row("t9")) fail("a pane agent lost its row"); const order = globalThis.herdr.order; if (order.indexOf("t2") < 0) fail(`the desk is not in the list order: ${order}`); +console.log("PASS: a Shell sub-agent desk gets a row"); + +// A click opens the agent's own terminal in a dialog, with bercail-shep's bar, and +// switches nothing: no space, pane or desk command runs. +globalThis.herdr.focused = { space: "s1", pane: "p7", terminal: "t7" }; row("t2").click(); -const [name, args] = globalThis.herdr.calls.at(-1); -if (name !== "bercail-desk.agent:t2") fail(`clicking a desk row runs ${name} ${JSON.stringify(args)}`); row("t9").click(); -if (globalThis.herdr.calls.at(-1)[0] !== "pane.focus") fail("clicking a pane agent must still focus its pane"); -console.log("PASS: a Shell sub-agent desk gets a row; clicking it shows the desk"); +const terms = globalThis.herdr.overlays.map((o) => o.layout.children[1].content.terminal); +if (terms.join() !== "t2,t9") fail(`clicks opened ${JSON.stringify(globalThis.herdr.overlays)}`); +const [o] = globalThis.herdr.overlays; +const bar = o.layout.children[0].content; +if (o.kind !== "dialog" || bar.mod !== "bercail-shep" || bar.props.role !== "modal" || bar.props.terminal !== "t2" + || bar.props.back.pane !== "p7" || bar.props.back.terminal !== "t7") fail(`dialog: ${JSON.stringify(o)}`); +if (globalThis.herdr.calls.length) fail(`a click ran ${JSON.stringify(globalThis.herdr.calls)}`); +console.log("PASS: clicking a desk or an agent opens its terminal in a dialog and switches nothing"); // Its status follows terminals(): working, then idle (done reads idle, error blocked). const st = (s) => deskRows([], [desk("t2", "s1", "t1", s)], spaces, null)[0].status; @@ -125,6 +135,8 @@ if (!row("t5") || !row("t6")) fail("pane agents must keep their rows"); if (row("t7") || !sub("t7")) fail("a desk under an agent stays that agent's sub-agent row"); if (row("t8") || row("t3")) fail("only live bercail desks get rows"); if (els.filter((e) => e.id === "ag-t5").length !== 1) fail("a desk with a pane got two rows"); +sub("t7").click(); +if (globalThis.herdr.overlays.at(-1)?.layout.children[1].content.terminal !== "t7") fail("a sub-agent row must open its dialog"); console.log("PASS: pane agents, agents' sub-agents and old desks keep their rows, once"); // In spaces() order (what grouped-by-space draws): the desk sits with its space's agents, before them. diff --git a/tests/herdr_app_deploy_test.sh b/tests/herdr_app_deploy_test.sh index 4061a06..4f63e5c 100644 --- a/tests/herdr_app_deploy_test.sh +++ b/tests/herdr_app_deploy_test.sh @@ -54,6 +54,8 @@ cmp -s "$ROOT/bercail/layouts/bercail.json" "$APP/layouts/bercail.json" || fail cmp -s "$ROOT/bercail/mods/bercail-desk/main.js" "$APP/mods/bercail-desk/main.js" || fail "mod not installed" cmp -s "$ROOT/bercail/mods/bercail-agents/main.js" "$APP/mods/bercail-agents/main.js" || fail "agents mod not installed" cmp -s "$ROOT/bercail/mods/bercail-review/main.js" "$APP/mods/bercail-review/main.js" || fail "review mod not installed" +cmp -s "$ROOT/bercail/mods/bercail-shep/main.js" "$APP/mods/bercail-shep/main.js" || fail "Shep mod not installed" +cmp -s "$ROOT/bercail/mods/bercail-spaces/main.js" "$APP/mods/bercail-spaces/main.js" || fail "spaces mod not installed" jq -e '.global["ctrl-shift-t"] == "tab.new"' "$APP/keys.json" >/dev/null || fail "the person's keys must stay" jq -e --slurpfile ours "$ROOT/bercail/keys.json" '.["layout:bercail"] == ($ours[0]["layout:bercail"] + {"x":"y"})' \ "$APP/keys.json" >/dev/null || fail "layout:bercail must get bercail's keys and keep the person's: $(cat "$APP/keys.json")" @@ -138,9 +140,12 @@ deploy_herdr_app >/dev/null jq -e '.. | objects | select(.id == "desk-t5p")' "$APP/layouts/bercail.json" >/dev/null || fail "a second update must keep the desks" printf 'PASS: a new shipped layout keeps the live desk blocks\n' -# bercail's layout as the app left it (desk blocks added, columns resized) is adopted. +# bercail's layout as the app left it (desk and Shep blocks added, the space view +# shown, columns resized) is adopted. rm -f "$AGENTIC_DEV_SHARE_DIR/herdr-app/layouts/bercail.json" jq '(.. | objects | select(.id == "agent-stack")) |= (.children += [{id: "desk-t9z", type: "block", content: {terminal: "t9z"}}] | .active = "desk-t9z") + | (.. | objects | select(.id == "shep-stack")) |= (.children += [{id: "shep-t3", type: "block", content: {terminal: "t3"}}] | .active = "shep-t3") + | (.. | objects | select(.id == "shep") | .hidden) = true | (.. | objects | select(.id == "work") | .hidden) = false | (.. | objects | select(.id == "desk") | .size.weight) = 4.2' \ "$ROOT/bercail/layouts/bercail.json" >"$APP/layouts/bercail.json" out="$(deploy_herdr_app)" @@ -188,7 +193,8 @@ printf 'PASS: no Herdr app skips the step\n' deploy_herdr_app >/dev/null jq '.global = {"ctrl-shift-t":"tab.new"}' "$APP/keys.json" >"$TMP_DIR/k" && mv "$TMP_DIR/k" "$APP/keys.json" uninstall_agentic_dev >/dev/null 2>&1 -[[ ! -e "$APP/layouts/bercail.json" && ! -e "$APP/mods/bercail-desk" && ! -e "$APP/mods/bercail-agents" && ! -e "$APP/mods/bercail-review" ]] \ +[[ ! -e "$APP/layouts/bercail.json" && ! -e "$APP/mods/bercail-desk" && ! -e "$APP/mods/bercail-agents" && ! -e "$APP/mods/bercail-review" + && ! -e "$APP/mods/bercail-shep" && ! -e "$APP/mods/bercail-spaces" ]] \ || fail "uninstall leaves layout or mods" jq -e 'has("layout:bercail") | not' "$APP/keys.json" >/dev/null || fail "uninstall leaves layout:bercail keys" jq -e '.global["ctrl-shift-t"] == "tab.new"' "$APP/keys.json" >/dev/null || fail "uninstall must keep the person's keys" diff --git a/tests/shep_mod_test.sh b/tests/shep_mod_test.sh new file mode 100644 index 0000000..723f908 --- /dev/null +++ b/tests/shep_mod_test.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# The bercail-shep mod (the Shep and space views) and bercail-spaces' click, against +# stubbed herdr and gpui modules and the shipped layout. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if ! command -v node >/dev/null 2>&1; then + printf 'SKIP: node not found\n' + exit 0 +fi + +# Each stub reads the state the test sets on globalThis.herdr. +cat >"$TMP_DIR/herdr.mjs" <<'EOF' +const h = () => globalThis.herdr; +const find = (n, id) => n?.id === id ? n : (n?.children ?? []).map((c) => find(c, id)).find(Boolean); +export const layout = () => structuredClone(h().layout); +export const run = (name, args) => { + h().calls.push([name, args]); + if (name === "layout.set") h().layout = structuredClone(args.layout); + if (name === "layout.show" || name === "layout.hide") find(h().layout, args.id).hidden = name === "layout.hide"; + if (name === "layout.select") find(h().layout, args.stack).active = args.child; +}; +export const spaces = () => h().spaces ?? []; +export const focused = () => h().focused; +export const terminals = () => h().terminals; +export const panes = () => h().panes ?? []; +export const agents = () => []; +export const machines = () => []; +export const notify = (n) => (h().notes ??= []).push(n); +export const theme = () => new Proxy({}, { get: (_t, k) => String(k) }); +export const props = () => h().props ?? {}; +export const shared = () => null; +export const setShared = () => {}; +export const openOverlay = () => "o1"; +export const closeOverlay = (id) => h().calls.push(["closeOverlay", id]); +export const exec = async (cmd, args) => { h().execs.push([cmd, ...args]); return h().exec(cmd, args); }; +export const hc = async () => ({ code: 0, stdout: "", stderr: "" }); +export const watchFolder = () => new Promise(() => {}); +export const window = () => ({ platform: "linux", active: true }); +export const home = () => "/home/me"; +export const readTextFile = async (path) => { + h().reads = [...(h().reads ?? []), path]; + if (h().config === undefined) throw new Error("ENOENT"); + return h().config; +}; +export const nextCommand = () => new Promise(() => {}); +EOF +cat >"$TMP_DIR/gpui.mjs" <<'EOF' +// An element records its id and click handler; every other method chains. +export const els = []; +const el = () => { + const e = {}; + els.push(e); + const p = new Proxy(e, { + get: (t, k) => k in t ? t[k] : (...a) => { + if (k === "id") t.id = a[0]; + if (k === "on_click") t.click = a[0]; + if (k === "on_mouse_down" || k === "on_mouse_up") t[`${k}:${a[0]}`] = a[1]; + if (k === "when" && a[0]) a[1](p); + return p; + }, + }); + return p; +}; +export class View {} +export const div = el, image = el, svg = el, h_flex = el, v_flex = el; +EOF +for mod in bercail-shep bercail-spaces; do + sed -e 's|from "herdr"|from "./herdr.mjs"|' -e 's|from "gpui-kit"|from "./gpui.mjs"|' \ + -e 's|from "gpui-base"|from "./gpui.mjs"|' "$ROOT/bercail/mods/$mod/main.js" >"$TMP_DIR/$mod.mjs" +done +cp "$ROOT/bercail/layouts/bercail.json" "$TMP_DIR/layout.json" + +cat >"$TMP_DIR/test.mjs" <<'EOF' +import { readFileSync } from "node:fs"; +import Shep, { find, viewOf, stackPlan, focusPlan, withView, configSession } from "./bercail-shep.mjs"; +import Spaces from "./bercail-spaces.mjs"; +import { els } from "./gpui.mjs"; + +const shipped = JSON.parse(readFileSync(new URL("./layout.json", import.meta.url))); +const fail = (msg) => { console.error(`FAIL: ${msg}`); process.exit(1); }; +const calls = (name) => globalThis.herdr.calls.filter(([n]) => n === name); +const tick = () => new Promise((r) => setImmediate(r)); +const SHEP = (address, state = "live") => ({ address, state, labels: { "bercail.role": "shep" } }); +const DESK = (address, space) => ({ address, state: "live", labels: { "bercail.role": "agent", "bercail.space": space } }); +const SPACES = [{ id: "s1", tabs: [{ id: "tab1", active: true }] }, { id: "s2", tabs: [{ id: "tab2", active: true }] }]; +const PANES = [{ pane: "p1", tab: "tab1", terminal: "t1" }, { pane: "p2", tab: "tab2", terminal: "t2" }]; +globalThis.localStorage = { getItem: () => null, setItem: () => {} }; + +// OpenClaw's agents: `sheep` is the default. +const AGENTS = JSON.stringify([{ id: "other", isDefault: false }, { id: "sheep", isDefault: true }]); +const ocExec = (_c, a) => ({ code: 0, stdout: /openclaw agents list --json/.test(a.at(-1)) ? AGENTS : "", stderr: "" }); +function setup(state = {}) { + globalThis.herdr = { + layout: structuredClone(shipped), calls: [], execs: [], spaces: SPACES, panes: PANES, + focused: { space: "s1", pane: "p1", terminal: "t1" }, terminals: [], + exec: ocExec, ...state, + }; +} +const cx = { spawn: (f) => f(cx), notify: () => {}, timer: { every: () => {} } }; +function nav() { + const v = new Shep(); + v.init({}, cx); + return v; +} + +// The shipped layout opens on the Shep view: Shep's column shown, the work column hidden, +// the navigator (with its Shep row and bercail's spaces list) beside them. +setup(); +if (viewOf(shipped) !== "shep") fail(`the shipped layout opens on ${viewOf(shipped)}`); +if (find(shipped, "shep-nav")?.content?.props?.role !== "nav") fail("no Shep row in the sidebar"); +if (find(shipped, "spaces")?.content?.mod !== "bercail-spaces") fail("the sidebar must use bercail-spaces"); +if (shipped.children.map((c) => c.id).join() !== "shep,sidebar,work") fail(`root order: ${shipped.children.map((c) => c.id)}`); +console.log("PASS: the shipped layout opens on the Shep view, navigator on the right"); + +// Launch: once per app session the space view gives way to the Shep view, and Shep +// starts once the core lists terminals. A second copy (a mod reload) does neither. +delete globalThis.bercailShep; +setup({ terminals: [] }); +globalThis.herdr.layout = withView(structuredClone(shipped), "space"); +let v = nav(); +if (calls("layout.show").length) fail("the view changed before layout() was known"); +v.sync(); +if (viewOf(globalThis.herdr.layout) !== "shep") fail("launch must show the Shep view"); +const runs = () => globalThis.herdr.execs.filter((e) => e[1] === "run"); +await tick(); +if (runs().length) fail("Shep started before the core listed its terminals"); +globalThis.herdr.terminals = [{ address: "t1", state: "live", labels: {} }]; +v.sync(); +await tick(); +if (runs().length !== 1) fail(`expected one hc run, got ${JSON.stringify(globalThis.herdr.execs)}`); +const argv = runs()[0]; +for (const a of ["--bg", "--no-parent", "--rm", "--name", "bercail-shep", "--label", "bercail.role=shep"]) { + if (!argv.includes(a)) fail(`hc run lacks ${a}: ${argv}`); +} +if (/--token|--password|--url/.test(argv.join(" "))) fail(`Shep passes credentials: ${argv}`); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "space"); +globalThis.herdr.execs = []; +v = nav(); +v.sync(); +v.sync(); +await tick(); +if (viewOf(globalThis.herdr.layout) !== "space" || runs().length) fail("a second copy must not switch the view or start Shep"); +console.log("PASS: launch shows the Shep view and starts Shep once, after the core lists terminals"); + +// A live Shep is found by its label: no second start. A start that races one the list +// doesn't show yet (hc: name_taken) is no error. Missing OpenClaw starts nothing. +for (const [label, state, wantRun, wantNote] of [ + ["live", { terminals: [SHEP("t9")] }, false, false], + ["name_taken", { exec: (c, a) => a[0] === "run" ? { code: 1, stdout: "", stderr: '{"error":{"kind":"name_taken"}}' } : ocExec(c, a) }, true, false], + ["missing", { exec: (_c, a) => ({ code: a[0] === "exec" ? 1 : 0, stdout: "", stderr: "" }) }, false, false], +]) { + delete globalThis.bercailShep; + setup({ terminals: [{ address: "t1", state: "live", labels: {} }], ...state }); + v = nav(); + v.sync(); + await tick(); + if (Boolean(runs().length) !== wantRun) fail(`${label}: hc run ${JSON.stringify(globalThis.herdr.execs)}`); + if (Boolean(globalThis.herdr.notes?.length) !== wantNote) fail(`${label}: notes ${JSON.stringify(globalThis.herdr.notes)}`); +} +console.log("PASS: one Shep: a live one is reused, a racing start is quiet, no OpenClaw starts nothing"); + +// Shep opens an agent-scoped session, never bare `openclaw tui` (which picks the agent +// from its folder): [shep] session from config.toml, else OpenClaw's default agent's +// main session. The key goes in as an argument, not into the script. +async function started(state) { + delete globalThis.bercailShep; + setup({ terminals: [{ address: "t1", state: "live", labels: {} }], ...state }); + v = nav(); + v.sync(); + await tick(); + await tick(); + return runs()[0] ?? null; +} +function routes(argv, key, label) { + const script = argv?.[argv.indexOf("-lc") + 1] ?? ""; + if (!/openclaw tui --session "\$1"/.test(script) || /openclaw tui(;|\s*$)/m.test(script)) fail(`${label}: script ${script}`); + if (argv.at(-1) !== key || argv.at(-2) !== "bash" || script.includes(key)) fail(`${label}: argv ${argv}`); + if (!argv.includes(`bercail.shep_session=${key}`)) fail(`${label}: no session label: ${argv}`); +} +routes(await started({}), "agent:sheep:main", "default agent"); +if (!globalThis.herdr.reads?.includes("/home/me/.config/bercail/config.toml")) fail("the config was not read"); +const config = '[layout]\nsession = "agent:no:main"\n\n[shep]\n# the orchestrator\nsession = "agent:sheep:main" # Simon\n'; +if (configSession(config) !== "agent:sheep:main" || configSession("[layout]\nsession = \"x\"\n") !== "") fail("configSession"); +let argv2 = await started({ config: config.replace("agent:sheep:main", "agent:ops:work") }); +routes(argv2, "agent:ops:work", "[shep] session"); +if (globalThis.herdr.execs.some((e) => /agents list/.test(e.at(-1)))) fail("a set [shep] session must not ask OpenClaw for its default"); +for (const [label, state] of [ + ["not agent-scoped", { config: '[shep]\nsession = "main"\n' }], + ["injection", { config: '[shep]\nsession = "agent:a:b; rm -rf ~"\n' }], + ["no default agent", { exec: (c, a) => /agents list/.test(a.at(-1)) ? { code: 0, stdout: "[]", stderr: "" } : ocExec(c, a) }], +]) { + if (await started(state)) fail(`${label}: Shep started`); + const note = globalThis.herdr.notes?.[0]?.body ?? ""; + if (!/\[shep\] session = "agent::main"/.test(note)) fail(`${label}: note ${note}`); +} +console.log("PASS: Shep opens [shep] session or the default agent's main session, explicitly, else says how to set it"); + +// Shep's block: added once (already shown), selected after, dropped when Shep ends, +// never added again for the same terminal. +setup({ terminals: [SHEP("t9")] }); +let added = new Set(); +let tree = structuredClone(shipped); +let plan = stackPlan(tree, SHEP("t9"), added); +const stack = (t) => find(t, "shep-stack"); +if (!plan.set || stack(plan.set).active !== "shep-t9" || find(plan.set, "shep-t9")?.content?.terminal !== "t9") fail(`add: ${JSON.stringify(plan)}`); +added.add(plan.added); +tree = plan.set; +if (Object.keys(stackPlan(structuredClone(tree), SHEP("t9"), added)).length) fail("a shown block must not change again"); +stack(tree).active = "shep-empty"; +if (stackPlan(structuredClone(tree), SHEP("t9"), added).select !== "shep-t9") fail("a hidden block must be selected"); +plan = stackPlan(structuredClone(tree), null, added); +if (!plan.set || stack(plan.set).children.map((c) => c.id).join() !== "shep-empty" || stack(plan.set).active !== "shep-empty") fail(`drop: ${JSON.stringify(plan)}`); +const dropped = structuredClone(shipped); +if (Object.keys(stackPlan(dropped, SHEP("t9"), added)).length) fail("a block the app dropped must not be added again"); +plan = stackPlan(structuredClone(shipped), SHEP("t10"), added); +if (find(plan.set, "shep-t10")?.content?.terminal !== "t10") fail("a new Shep gets its block"); +console.log("PASS: Shep's block is added once, selected after, dropped when Shep ends"); + +// A layout.set while a view switch is on its way carries that view. +setup({ terminals: [SHEP("t9")] }); +v = nav(); +v.show("space"); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "shep"); // the switch hasn't landed +v.sync(); +const [[, set]] = calls("layout.set"); +if (viewOf(set.layout) !== "space") fail("layout.set undid a view switch on its way"); +console.log("PASS: adding Shep's block keeps a view switch on its way"); + +// The keyboard: into Shep in the Shep view (once its block is there); back where it +// was in the space when the space view comes back, else the shown tab's first pane. +const shep = SHEP("t9"); +const f = (o) => ({ space: "s1", pane: null, terminal: null, ...o }); +const eq = (a, b, label) => { if (JSON.stringify(a) !== JSON.stringify(b)) fail(`${label}: ${JSON.stringify(a)}`); }; +setup(); +eq(focusPlan("shep", "space", f({ pane: "p1" }), shep, true, null, PANES, SPACES[0]), { terminal: "t9" }, "into Shep"); +eq(focusPlan("shep", "space", f({ pane: "p1" }), shep, false, null, PANES, SPACES[0]), null, "not before its block"); +eq(focusPlan("shep", "shep", f({ terminal: "t9" }), shep, true, null, PANES, SPACES[0]), null, "already on Shep"); +eq(focusPlan("space", "shep", f({ terminal: "t9" }), shep, true, { terminal: "t5" }, PANES, SPACES[0]), { terminal: "t5" }, "back to the desk"); +eq(focusPlan("space", "shep", f({ pane: "p1" }), shep, true, { terminal: "t5" }, PANES, SPACES[0]), { terminal: "t5" }, "over the app's own pick"); +eq(focusPlan("space", "shep", f({ terminal: "t9" }), shep, true, { pane: "gone" }, PANES, SPACES[0]), { pane: "p1" }, "a closed pane"); +eq(focusPlan("space", "shep", f({ pane: "p1" }), shep, true, { pane: "p1" }, PANES, SPACES[0]), null, "already there"); +eq(focusPlan("space", "space", f({ pane: "p1" }), shep, true, { terminal: "t5" }, PANES, SPACES[0]), null, "the person moved it"); +console.log("PASS: the keyboard follows the view and comes back where it was"); + +// The nav copy remembers where the keyboard was in each space, desks too, while the +// space view stays. +setup({ terminals: [shep, DESK("t5", "s1")], focused: f({ terminal: "t5" }) }); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "space"); +v = nav(); +v.sync(); +v.sync(); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "shep"); +globalThis.herdr.focused = f({ pane: "p1" }); // the app moved it +v.sync(); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "space"); +globalThis.herdr.calls = []; +v.sync(); +eq(calls("pane.focus").at(-1)?.[1], { terminal: "t5" }, "desk restored after a round trip"); +console.log("PASS: a round trip through Shep puts the keyboard back on the desk"); + +// Keys: toggle flips the views; the Shep row's click shows Shep. +setup({ terminals: [shep] }); +v = nav(); +await v.command("toggle"); +if (viewOf(globalThis.herdr.layout) !== "space") fail("toggle from Shep"); +await v.command("toggle"); +if (viewOf(globalThis.herdr.layout) !== "shep") fail("toggle back"); +globalThis.herdr.layout = withView(globalThis.herdr.layout, "space"); +els.length = 0; +v.render(cx); +els.find((e) => e.id === "shep-row").click(); +if (viewOf(globalThis.herdr.layout) !== "shep") fail("the Shep row must show Shep"); +console.log("PASS: Ctrl+Shift+H toggles the views; the Shep row comes back to Shep"); + +// The agent dialog's bar: close puts the keyboard back; "Show in space" goes there. +setup({ terminals: [{ address: "t6", state: "live", labels: {} }], spaces: [{ id: "s2", name: "two", tabs: [] }], + props: { role: "modal", terminal: "t6", overlay: "o1", back: { pane: "p1", terminal: "t1" } } }); +v = nav(); +await v.close(); +eq(calls("pane.focus").at(-1)?.[1], { pane: "p1" }, "close restores"); +globalThis.herdr.calls = []; +globalThis.herdr.terminals[0].labels = { "bercail.space": "s2" }; +els.length = 0; +v.render(cx); +els.find((e) => e.id === "modal-space").click(); +await v.close(); +const names = globalThis.herdr.calls.map(([n]) => n).join(); +if (names !== "closeOverlay,space.select,layout.hide,layout.show,agent.focus") fail(`Show in space ran ${names}`); +if (viewOf(globalThis.herdr.layout) !== "space") fail("Show in space must show the space view"); +console.log("PASS: the dialog's bar closes back to where the keyboard was, or shows the space"); + +// bercail-spaces: a click on a space selects it and shows the space view; while Shep +// shows, no space reads as selected. +setup({ spaces: [{ id: "s1", name: "one", tabs: [], active: true, machine: null }] }); +const sp = new Spaces(); +sp.init({}, cx); +els.length = 0; +sp.render(cx); +const rowEl = els.find((e) => e.id === "space-s1"); +rowEl["on_mouse_down:left"](); +rowEl["on_mouse_up:left"](null, cx); +if (calls("space.select")[0]?.[1]?.id !== "s1" || viewOf(globalThis.herdr.layout) !== "space") fail(`space click: ${JSON.stringify(globalThis.herdr.calls)}`); +// Herdr's own layouts have no Shep view: the click only selects. +setup({ layout: { id: "root", children: [{ id: "sidebar", size: { px: 268 } }] } }); +rowEl["on_mouse_down:left"](); +rowEl["on_mouse_up:left"](null, cx); +if (calls("layout.hide").length || calls("layout.show").length) fail("a layout without the views got layout commands"); +console.log("PASS: clicking a space shows it in the space view; other layouts only select"); +EOF +node "$TMP_DIR/test.mjs"