pi-straw-boss 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,32 @@
1
1
  # pi-straw-boss
2
2
 
3
- Straw Boss coordination for [pi](https://pi.dev) sessions that run in [Herdr](https://herdr.dev): a session **joins** a small net when its work competes or collaborates with other work in the workspace, the first to join a tab becomes its **main agent**, one **tower** per workspace opens on demand, and `boss_broadcast` reaches the rest. No tracking, no scheduler, no new service: the extension is glue over pi-intercom and Herdr metadata.
3
+ **Many pi agents, one project, no collisions.** pi-straw-boss gives the agents you run across [Herdr](https://herdr.dev) tabs a light chain of command: a main agent per tab, a tower per workspace, and a live view of who is doing what.
4
+
5
+ <!-- /boss gui screenshot goes here -->
6
+
7
+ ```text
8
+ ╭─ ⠸ tower · Alpha ─────────────────────────────────────────── head tower ─╮
9
+ │ this net │
10
+ │ ├─ tab api-main │
11
+ │ │ ├─ ○ api-main main │
12
+ │ │ │ └─ ⠏ psb-sleeper subagent bash │
13
+ │ │ └─ ○ docs-member member │
14
+ │ └─ tab other │
15
+ │ └─ ? ops-main main waiting on a question │
16
+ │ other towers │
17
+ │ ✓ Beta tower │
18
+ ╰──────────────────────────────────────────────────────────────────────────╯
19
+ ```
4
20
 
5
- ## Requirements
21
+ ## Why
6
22
 
7
- - pi, running inside a Herdr pane
8
- - [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer
9
- - Herdr 0.9.4 or newer
23
+ Run a few agents on the same project and they start stepping on each other: two dev servers on one port, two branches editing the same migration, and no agent knows the others exist. pi-straw-boss lets them find each other and coordinate, without a scheduler or a new service.
10
24
 
11
- Outside Herdr the extension does nothing.
25
+ - **Opt-in net.** Each session is asked once whether its work shares resources or needs to collaborate. Only those that say yes join.
26
+ - **A main agent per tab.** The first to join a tab coordinates it; `/boss` takes over.
27
+ - **A tower per workspace.** It opens on demand in its own tab, pinned first, and coordinates the main agents and the other towers. You don't need to talk to it.
28
+ - **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
29
+ - **Broadcast** to a tab, a workspace, every tower, or every session.
12
30
 
13
31
  ## Install
14
32
 
@@ -16,69 +34,30 @@ Outside Herdr the extension does nothing.
16
34
  pi install npm:pi-straw-boss
17
35
  ```
18
36
 
19
- While developing: `pi install ~/projects/pi-straw-boss` loads the working tree, and `/reload` picks up edits.
20
-
21
- ## How it works
22
-
23
- Joining is a choice, never forced.
24
-
25
- 1. A session that is not in the net gets one short reminder at the start of its first task, asking whether its current work competes with other work in the workspace for resources (ports, databases, files, branches) or needs to collaborate with it.
26
- 2. If yes, it calls `boss_join` (or the human types `/boss`). It can also join later, whenever it discovers the need. The reminder is not repeated, and a session that never joins is left alone: no prompt, no listing, no broadcasts (except scope `all`).
27
- 3. The first session of a tab to join is the tab's **main agent**; later joiners are **members**. `/boss` in a member takes the role over.
28
- 4. Subagents launched by pi-herdr-agents (recognised by `PI_SUBAGENT_ID`) never join. Their parent represents them.
37
+ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer. [pi-herdr-agents](https://github.com/giuseppecrj/pi-herdr-agents) is optional: with it, subagents show up under the session that launched them.
29
38
 
30
- | Role | Who | Gets |
31
- |---|---|---|
32
- | main agent | first joiner of a tab, or the `/boss` taker-over | a short prompt: split work by app, brief workers, check results, ask the boss before merging or deploying |
33
- | member | later joiners of a tab | a short prompt naming the main agent and the tower |
34
- | tower | one per workspace, in its own tab labelled `tower` | a prompt for coordinating agents and repairing coordination friction; it never asks the user |
39
+ ## Quick start
35
40
 
36
- The tower is opened on demand by `call_tower` (or `/boss tower`), without taking focus, and answers through intercom. If two towers ever race in one workspace, the live token holder wins, otherwise the lower tab id, and the other closes its own tab. Towers of different workspaces reach each other with `call_tower`; the **head tower** is the tower of the first workspace in `herdr workspace list` order, so there is no election.
41
+ 1. Open pi in a Herdr tab and give it work. When the work needs to share or coordinate, the agent joins the net. You can also type `/boss`.
42
+ 2. Type `/boss status` to see each tab's main agent and the towers.
43
+ 3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
37
44
 
38
- ## Tools
45
+ ## Tools and commands
39
46
 
40
- | Tool | Available to | What it does |
41
- |---|---|---|
42
- | `boss_join` | sessions not yet in the net | Join this tab's net; the first joiner is main. |
43
- | `boss_status` | joined sessions and towers | The main agent of each tab, this workspace's tower, the head tower, and the other towers. |
44
- | `call_tower` | joined sessions and towers | Send a message to this workspace's tower, or another workspace's by id or exact label. Opens the tower tab when none runs. |
45
- | `boss_broadcast` | joined sessions and towers | One message to every other session in a scope. `tab`, `workspace` and `towers` reach only sessions that joined the net; `all` reaches every pi session on the machine, joined or not (for notices such as going to sleep). Default `workspace`. |
46
-
47
- | Command | Effect |
47
+ | Name | What it does |
48
48
  |---|---|
49
- | `/boss` | Join if needed, then become this tab's main agent. |
50
- | `/boss status` | Show `boss_status`. |
51
- | `/boss tower` | Open this workspace's tower if none runs, then focus its tab. |
52
-
53
- ## What it writes
54
-
55
- - Herdr pane token `sb_role` (`main`, `member` or `tower`) and workspace token `tower` (the tower's intercom session id), both refreshed every 4 minutes with a 10 minute TTL and cleared at shutdown.
56
- - A pi session entry `straw-boss` (`asked`, `joined`, `tower`) so a resumed session keeps its place without a second reminder.
57
- - One intercom extension channel per tab, `straw-boss/tab/<hex of the tab id>`, registered only by joined sessions and towers. Tab ids over 24 characters are refused.
58
-
59
- The tower tab is labelled `tower` and its Herdr agent is named `tower-<workspace id>` in lowercase (Herdr agent names are unique per server).
49
+ | `boss_join` | Join this tab's net. |
50
+ | `boss_status` | Main agents, this workspace's tower, and the other towers. |
51
+ | `call_tower` | Message this workspace's tower, or another workspace's. |
52
+ | `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
53
+ | `/boss` | Join and become this tab's main agent. |
54
+ | `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
60
55
 
61
- ## From Claude Code or Codex
56
+ Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
62
57
 
63
- Read-only discovery works from any shell in a Herdr pane:
64
-
65
- ```bash
66
- herdr workspace get "$HERDR_WORKSPACE_ID" | jq -r '.result.workspace.tokens.tower'
67
- herdr pane list --workspace "$HERDR_WORKSPACE_ID" | jq -r '.result.panes[] | select(.tokens.sb_role) | [.pane_id, .tokens.sb_role] | @tsv'
68
- node ~/.pi/agent/npm/node_modules/pi-intercom/cli.mjs ask --to "$TOWER_SESSION_ID" --text "<message>"
69
- ```
70
-
71
- Opening a missing tower needs a pi session.
72
-
73
- ## Development
74
-
75
- ```bash
76
- npm install
77
- npm run check # tsc --noEmit
78
- npm test # node --test, against a fake Herdr and a fake intercom broker
79
- ```
58
+ ## Learn more
80
59
 
81
- Prompts are plain markdown in `prompts/`, read at session start, so an edit applies after `/reload`.
60
+ [How it works](docs/how-it-works.md) covers roles, the tower, the dashboard, what the extension writes, and how Claude Code or Codex agents can find a tower.
82
61
 
83
62
  ## License
84
63
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.1.0",
4
- "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand, and boss_broadcast reaches the rest",
3
+ "version": "0.2.0",
4
+ "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "wayne930242",
@@ -36,10 +36,12 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*",
39
40
  "typebox": "*"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@earendil-works/pi-coding-agent": "1.3.0",
44
+ "@earendil-works/pi-tui": "1.3.0",
43
45
  "@types/node": "26.6.5",
44
46
  "typebox": "1.3.27",
45
47
  "typescript": "7.0.2"
@@ -0,0 +1,178 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
6
+
7
+ // The tower's monitoring screen: a widget above the editor, never focused, redrawn only while something is working.
8
+ //
9
+ // Data sources, all read-only:
10
+ // - pi-intercom listSessions(): every live session, its status (idle, thinking, tool:<name>), and its Herdr location.
11
+ // presence_update, session_joined, and session_left events on the tower's channel only trigger an early re-read.
12
+ // - `herdr pane list`: per pane the `sb_role` token (main, member), the pi session file, and Herdr's agent_status
13
+ // (blocked means the agent waits on a question); `herdr workspace list`: workspace status, `tower` token, worktree.
14
+ // - pi-herdr-agents subagents: a child's activity file lives at <sessions dir>/artifacts/<parent session id>/
15
+ // subagent-activity/<child id>.json, and its pane's session file name starts with `<timestamp>_<child id>`.
16
+ // The parent pane's own session file names the artifacts directory, so children group under the session that launched them.
17
+
18
+ export type Mark = "working" | "waiting" | "idle" | "done" | "error" | "unknown";
19
+ /** One line of the tree. `net` rows are tab (0) > session (1) > subagent (2); `towers` rows are flat. */
20
+ export interface Row { group: "net" | "towers"; depth: number; mark: Mark; text: string; kind?: string; note?: string; workspace?: string }
21
+ export interface Snapshot { title: string; mark: Mark; head: boolean; rows: Row[] }
22
+ export type Tone = "accent" | "warning" | "muted" | "success" | "dim" | "error" | "border" | "title";
23
+ export type Paint = (tone: Tone, text: string) => string;
24
+ export interface Sources { run: HerdrRun; sessions(): Promise<Session[]>; workspaceId: string; now?: number }
25
+
26
+ const KEY = "straw-boss-dashboard";
27
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
+ // One glyph and one theme color per state; a working row shows the animated spinner instead of a fixed glyph.
29
+ const GLYPH: Record<Exclude<Mark, "working">, string> = { waiting: "?", idle: "○", done: "✓", error: "✗", unknown: "·" };
30
+ const TONE: Record<Mark, Tone> = { working: "accent", waiting: "warning", idle: "dim", done: "success", error: "error", unknown: "muted" };
31
+ const HERDR_MARK: Record<string, Mark> = { working: "working", blocked: "waiting", idle: "idle", done: "done" };
32
+ const RECENT_MS = 10 * 60_000;
33
+ const MAX_ROWS = 18;
34
+ const MAX_WIDTH = 76;
35
+ const MAX_CHILDREN = 8;
36
+
37
+ const markOf = (status?: string): Mark => HERDR_MARK[status ?? ""] ?? "unknown";
38
+ const pad = (text: string, width: number) => text + " ".repeat(Math.max(0, width - visibleWidth(text)));
39
+
40
+ /** The pure half: a snapshot in, a framed tree out. One shared spinner frame animates every working row. */
41
+ export function render({ title, mark, head, rows }: Snapshot, frame: number, width: number, paint: Paint): string[] {
42
+ const total = Math.min(width, MAX_WIDTH), inner = Math.max(8, total - 4);
43
+ const glyph = (m: Mark) => paint(TONE[m], m === "working" ? SPINNER[frame % SPINNER.length]! : GLYPH[m as Exclude<Mark, "working">]);
44
+ const shown = rows.slice(0, MAX_ROWS);
45
+ // A row is the last child when the next row at its depth or above is shallower (or there is none).
46
+ const last = (i: number) => i < 0 || shown.slice(i + 1).find((r) => r.group === shown[i]!.group && r.depth <= shown[i]!.depth)?.depth !== shown[i]!.depth;
47
+ const ancestor = (i: number, depth: number) => shown.findLastIndex((r, k) => k < i && r.group === shown[i]!.group && r.depth === depth);
48
+ const prefix = (i: number) => (shown[i]!.group === "net" ? Array.from({ length: shown[i]!.depth }, (_, d) => (last(ancestor(i, d)) ? " " : "│ ")).join("") + (last(i) ? "└─ " : "├─ ") : "");
49
+ const cells = shown.map((r, i) => paint("border", prefix(i)) + (r.mark === "unknown" && !r.kind ? "" : `${glyph(r.mark)} `) + r.text);
50
+ const [w1, w2] = [Math.max(...cells.map(visibleWidth), 0), Math.max(...shown.map((r) => r.kind?.length ?? 0))];
51
+ const line = (i: number) => `${pad(cells[i]!, w1)} ${paint("muted", (shown[i]!.kind ?? "").padEnd(w2))} ${paint("dim", shown[i]!.note ?? "")}`.trimEnd();
52
+ const section = (group: Row["group"], name: string, empty?: string) => {
53
+ const at = shown.flatMap((r, i) => (r.group === group ? [i] : []));
54
+ return at.length || empty ? [paint("muted", name), ...(at.length ? at.map(line) : [paint("dim", ` ${empty}`)])] : [];
55
+ };
56
+ const body = [...section("net", "this net", "nothing has joined yet"), ...section("towers", "other towers"), ...(rows.length > MAX_ROWS ? [paint("muted", `… ${rows.length - MAX_ROWS} more`)] : [])];
57
+ const [name, tag] = [` ${glyph(mark)} ${paint("title", `tower · ${title}`)} `, head ? ` ${paint("accent", "head tower")} ` : ""];
58
+ const dashes = "─".repeat(Math.max(1, total - 4 - visibleWidth(name) - visibleWidth(tag)));
59
+ const frameLine = (text: string) => paint("border", "│ ") + pad(truncateToWidth(text, inner), inner) + paint("border", " │");
60
+ return [paint("border", "╭─") + name + paint("border", dashes) + tag + paint("border", "─╮"), ...body.map(frameLine), paint("border", `╰${"─".repeat(total - 2)}╯`)].map((l) => truncateToWidth(l, width));
61
+ }
62
+
63
+ function readJson(path: string): Record<string, unknown> {
64
+ try {
65
+ return JSON.parse(readFileSync(path, "utf8"));
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ /** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. */
72
+ function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
73
+ const file = parent?.agent_session?.value;
74
+ if (!file) return [];
75
+ const dir = join(dirname(file), "artifacts", basename(file, ".jsonl").split("_").pop()!);
76
+ let files: string[] = [];
77
+ try {
78
+ files = readdirSync(join(dir, "subagent-activity"));
79
+ } catch {} // no subagents were ever launched from this session
80
+ return files
81
+ .map((name) => {
82
+ const id = name.replace(/\.json$/, "");
83
+ const path = join(dir, "subagent-activity", name);
84
+ return { id, path, pane: all.find((p) => p.agent_session?.value.includes(`_${id}`)), age: now - statSync(path).mtimeMs };
85
+ })
86
+ .filter((c) => c.pane || c.age < RECENT_MS)
87
+ .map(({ id, path, pane }) => {
88
+ const activity = readJson(path);
89
+ // A child whose pane is gone, or back at a shell prompt, has finished.
90
+ const mark: Mark = pane?.agent === "pi" ? markOf(pane.agent_status) : "done";
91
+ let script: string | undefined;
92
+ try {
93
+ script = readdirSync(join(dir, "subagent-scripts")).find((f) => f.endsWith(`-${id}.sh`));
94
+ } catch {} // the launch script is only a fallback source for the name
95
+ const name = pane?.label ?? script?.slice(0, -`-${id}.sh`.length) ?? id.slice(0, 8);
96
+ const doing = activity.toolActive ? String(activity.toolName) : String(activity.phase ?? "");
97
+ return { group: "net" as const, depth: 2, mark, text: name, kind: "subagent", workspace: pane?.workspace_id, note: { working: doing, waiting: "waiting on a question", done: "finished" }[mark as "working"] };
98
+ })
99
+ .sort((a, b) => Number(a.mark === "done") - Number(b.mark === "done") || a.text.localeCompare(b.text))
100
+ .slice(0, MAX_CHILDREN);
101
+ }
102
+
103
+ /**
104
+ * One reading of the world as rows: this tower's own net in detail (each tab's joined sessions with their subagents, then its
105
+ * sub workspaces), and one summary line for every other tower. Other workspaces' sessions, and sessions that did not join, are not listed.
106
+ */
107
+ export async function collect({ run, sessions, workspaceId, now = Date.now() }: Sources): Promise<Snapshot> {
108
+ const [live, ps, all] = [await sessions(), await panes(run), await workspaces(run)];
109
+ const head = headOf(all);
110
+ const mine = all.find((w) => w.workspace_id === workspaceId);
111
+ const sessionRow = (s: Session, role: string): Row => {
112
+ const pane = ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
113
+ const busy = s.status ? s.status !== "idle" : undefined;
114
+ const mark: Mark = pane?.agent_status === "blocked" ? "waiting" : busy === undefined ? markOf(pane?.agent_status) : busy ? "working" : "idle";
115
+ return { group: "net", depth: 1, mark, text: s.name ?? s.id, kind: role, note: { working: s.status, waiting: "waiting on a question" }[mark as "working"] };
116
+ };
117
+ const joined = live.filter((s) => s.herdrLocation?.workspace?.id === workspaceId).flatMap((s) => {
118
+ const pane = ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
119
+ const role = pane?.tokens?.sb_role;
120
+ return role === "main" || role === "member" ? [{ s, pane, role, tab: s.herdrLocation!.tab! }] : [];
121
+ });
122
+ const rows: Row[] = [];
123
+ for (const group of Object.values(Object.groupBy(joined, (j) => j.tab.id))) {
124
+ rows.push({ group: "net", depth: 0, mark: "unknown", text: `tab ${group![0]!.tab.label}` });
125
+ for (const { s, pane, role } of group!.sort((a, b) => Number(b.role === "main") - Number(a.role === "main"))) rows.push(sessionRow(s, role), ...subagents(pane, ps, now));
126
+ }
127
+ // Sub workspaces are linked worktrees: they hang under this tower like subagents, and their sessions never join. One that
128
+ // already shows as a launched subagent (pi-herdr-agents runs worktree children there) is not listed a second time.
129
+ const claimed = new Set(rows.map((r) => r.workspace));
130
+ for (const sub of all.filter((x) => isSub(x) && mine && parentOf(all, x) === mine && !claimed.has(x.workspace_id))) rows.push({ group: "net", depth: 0, mark: markOf(sub.agent_status), text: sub.label, kind: "sub workspace" });
131
+ for (const w of all.filter((x) => x !== mine && !isSub(x) && live.some((s) => s.id === x.tokens?.tower))) rows.push({ group: "towers", depth: 0, mark: markOf(w.agent_status), text: w.label, kind: w === head ? "head tower" : "tower" });
132
+ // The title shows what needs attention most: a waiting row, then a working one, else the workspace's own status.
133
+ const attention = (["waiting", "working"] as const).find((m) => rows.some((r) => r.mark === m));
134
+ return { title: mine?.label ?? workspaceId, mark: attention ?? markOf(mine?.agent_status), head: Boolean(mine) && mine === head, rows };
135
+ }
136
+
137
+ export interface Dashboard { poke(): void; stop(): void }
138
+ export interface Timing { frameMs?: number; pollMs?: number; minGapMs?: number }
139
+
140
+ /**
141
+ * Shows the dashboard in a tower's TUI. The spinner timer (8 fps) runs only while a row is working; the data poll (3 s)
142
+ * runs while the widget exists. stop() clears both timers and the widget, and pi disposing the widget does the same.
143
+ */
144
+ export function startDashboard({ ui, collect: read, frameMs = 125, pollMs = 3000, minGapMs = 500 }: { ui: ExtensionContext["ui"]; collect(): Promise<Snapshot> } & Timing): Dashboard {
145
+ let live: Dashboard | undefined;
146
+ ui.setWidget(KEY, (tui, theme) => {
147
+ let snap: Snapshot = { title: "", mark: "unknown", head: false, rows: [] }, frame = 0, busy = false, stopped = false, last = 0;
148
+ let spinner: ReturnType<typeof setInterval> | undefined;
149
+ const refresh = async () => {
150
+ if (busy || stopped) return;
151
+ busy = true;
152
+ try {
153
+ snap = await read();
154
+ } catch (error) {
155
+ snap = { ...snap, mark: "error", rows: [{ group: "net", depth: 0, mark: "error", text: `dashboard unavailable: ${(error as Error).message}` }] };
156
+ }
157
+ busy = false;
158
+ last = Date.now();
159
+ if (stopped) return;
160
+ const working = snap.mark === "working" || snap.rows.some((r) => r.mark === "working");
161
+ if (working && !spinner) spinner = setInterval(() => (frame++, tui.requestRender()), frameMs).unref();
162
+ if (!working && spinner) spinner = void clearInterval(spinner);
163
+ tui.requestRender();
164
+ };
165
+ const poll = setInterval(() => void refresh(), pollMs).unref();
166
+ const stop = () => {
167
+ stopped = true;
168
+ clearInterval(poll);
169
+ clearInterval(spinner);
170
+ spinner = undefined;
171
+ };
172
+ live = { poke: () => void (Date.now() - last >= minGapMs && refresh()), stop };
173
+ void refresh();
174
+ const paint: Paint = (tone, text) => (tone === "title" ? theme.bold(theme.fg("accent", text)) : theme.fg(tone, text));
175
+ return { render: (width) => render(snap, frame, width, paint), invalidate() {}, dispose: stop };
176
+ });
177
+ return { poke: () => live?.poke(), stop: () => (ui.setWidget(KEY, undefined), live?.stop(), (live = undefined), undefined) };
178
+ }
package/src/herdr.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createConnection } from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
 
3
6
  export type HerdrRun = (args: string[], timeoutMs?: number) => Promise<string>;
7
+ /** One request on Herdr's socket API; rejects with the error Herdr answered. */
8
+ export type HerdrApi = (method: string, params: object) => Promise<void>;
4
9
  export type Sleep = (ms: number) => Promise<void>;
5
10
  export interface Place { paneId: string; tabId: string; workspaceId: string }
6
- export interface Workspace { workspace_id: string; label: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string } }
7
- export interface Pane { pane_id: string; tab_id: string; agent?: string; tokens?: Record<string, string> }
8
- export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean> }
11
+ export interface Workspace { workspace_id: string; label: string; agent_status?: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string; is_linked_worktree?: boolean; repo_key?: string } }
12
+ export interface Pane { pane_id: string; tab_id: string; workspace_id?: string; agent?: string; agent_status?: string; agent_session?: { value: string }; label?: string; tokens?: Record<string, string> }
13
+ export interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
14
+ /** A live pi-intercom session; `status` is idle, thinking, or tool:<name>. */
15
+ export interface Session { id: string; name?: string; status?: string; herdrLocation?: Location }
16
+ export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean>; moveFirst(tabId: string): Promise<void> }
9
17
 
10
18
  const TTL_MS = 10 * 60_000;
11
19
  const START_MS = 30_000;
@@ -36,12 +44,34 @@ export async function place(run: HerdrRun): Promise<Place> {
36
44
  return { paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id };
37
45
  }
38
46
 
47
+ /** A sub workspace is a linked git worktree that Herdr opened as its own workspace: it is treated like a subagent of its parent. */
48
+ export const isSub = (w: Workspace) => w.worktree?.is_linked_worktree === true;
49
+ export const parentOf = (all: Workspace[], w: Workspace) => all.find((p) => !isSub(p) && p.worktree?.repo_key !== undefined && p.worktree.repo_key === w.worktree?.repo_key);
50
+ /** The head tower's workspace: the first one in list order that is not a sub workspace. */
51
+ export const headOf = (all: Workspace[]) => all.find((w) => !isSub(w));
52
+
39
53
  export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
40
54
 
41
55
  export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
42
56
  return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
43
57
  }
44
58
 
59
+ export const herdrApi = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock")): HerdrApi => (method, params) =>
60
+ new Promise((resolve, reject) => {
61
+ const socket = createConnection(socketPath).setTimeout(5000);
62
+ let reply = "";
63
+ const end = (error?: Error) => (socket.destroy(), error ? reject(error) : resolve());
64
+ socket.on("connect", () => socket.write(`${JSON.stringify({ id: "straw-boss", method, params })}\n`));
65
+ socket.on("data", (chunk) => {
66
+ reply += chunk;
67
+ if (!reply.includes("\n")) return;
68
+ const { error } = JSON.parse(reply.split("\n")[0]!) as { error?: { code?: string; message?: string } };
69
+ end(error && new Error(`herdr ${method} failed: ${error.code ?? ""} ${error.message ?? ""}`.trim()));
70
+ });
71
+ socket.on("timeout", () => end(new Error(`herdr ${method} timed out`)));
72
+ socket.on("error", (error) => end(new Error(`herdr ${method} failed: ${error.message}`)));
73
+ });
74
+
45
75
  /** Writes display-and-discovery tokens (null clears one). Herdr takes the positional id before the options. */
46
76
  export function report(run: HerdrRun, kind: "pane" | "workspace", id: string, tokens: Record<string, string | null>) {
47
77
  const flags = Object.entries(tokens).flatMap(([name, value]) => (value === null ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
@@ -67,7 +97,7 @@ export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
67
97
  return pending;
68
98
  }
69
99
 
70
- async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: string) {
100
+ async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Workspace, cwd: string) {
71
101
  const ws = workspace.workspace_id;
72
102
  const held = async () => {
73
103
  const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
@@ -77,18 +107,22 @@ async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: s
77
107
  if (current) return { id: current, opened: false };
78
108
 
79
109
  // A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
110
+ // The tab is told apart by the `sb_role=tower` pane token that only Straw Boss writes; a tab merely labelled "tower"
111
+ // (a pi-herdr-agents subagent can be named so) is someone else's.
80
112
  let paneId: string;
81
113
  let running = false;
82
- const tab = (await result(run, ["tab", "list", "--workspace", ws])).tabs.find((t: { label: string }) => t.label === "tower");
83
- if (tab) {
84
- const root = (await panes(run, ws)).find((p) => p.tab_id === tab.tab_id);
85
- if (!root) throw new Error(`Tower tab ${tab.tab_id} has no pane`);
86
- paneId = root.pane_id;
87
- running = root.agent === "pi";
114
+ const marked = (await panes(run, ws)).find((p) => p.tokens?.sb_role === "tower");
115
+ if (marked) {
116
+ paneId = marked.pane_id;
117
+ running = marked.agent === "pi";
118
+ await moveFirst(marked.tab_id);
88
119
  } else {
89
120
  const dir = workspace.worktree?.checkout_path ?? cwd;
90
121
  const created = await result(run, ["tab", "create", "--workspace", ws, "--label", "tower", "--cwd", dir, "--no-focus", "--env", "STRAW_BOSS_ROLE=tower"]);
91
122
  paneId = created.root_pane.pane_id;
123
+ // Marked at once, so a tab left without pi is recognised (while the marker lasts) and the label stays display only.
124
+ await report(run, "pane", paneId, { sb_role: "tower" });
125
+ await moveFirst(created.tab.tab_id);
92
126
  }
93
127
  if (!running) {
94
128
  // Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Type, type Static, type TSchema } from "typebox";
6
- import { ensureTower, herdrRun, type HerdrRun, type Pane, panes, place, type Place, report, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
6
+ import { collect, startDashboard } from "./dashboard.ts";
7
+ import { ensureTower, headOf, herdrApi, type HerdrApi, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
7
8
 
8
9
  // pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
9
10
  const REGISTER = "intercom:extension-register";
@@ -18,8 +19,6 @@ const CONNECT_MS = 15_000;
18
19
  const SEND_MS = 30_000;
19
20
  const SETTLE_MS = 2000;
20
21
 
21
- interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
22
- interface Session { id: string; name?: string; herdrLocation?: Location }
23
22
  interface Channel {
24
23
  snapshot(): { connected: boolean; supported: boolean; owner?: { sessionId: string }; state?: { payload: unknown } };
25
24
  publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void;
@@ -31,7 +30,7 @@ interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
31
30
  type Role = "tower" | "main" | "member";
32
31
  type Scope = "tab" | "workspace" | "towers" | "all";
33
32
  type Member = ReturnType<typeof netMembers>[number];
34
- export interface Deps { run?: HerdrRun; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
33
+ export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
35
34
 
36
35
  /** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
37
36
  function netMembers(sessions: Session[], ps: Pane[]) {
@@ -43,12 +42,14 @@ function netMembers(sessions: Session[], ps: Pane[]) {
43
42
  }
44
43
  const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
45
44
 
46
- export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)) }: Deps = {}) {
45
+ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)) }: Deps = {}) {
47
46
  return function strawBoss(pi: ExtensionAPI): void {
48
47
  // Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
49
48
  if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
50
49
  let isTower = env.STRAW_BOSS_ROLE === "tower";
51
50
  let asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
51
+ // Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
52
+ let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
52
53
  let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
53
54
  let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
54
55
  let queue: Promise<void> = Promise.resolve();
@@ -93,6 +94,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
93
94
  const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
94
95
  if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
95
96
  else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
97
+ if (["presence_update", "session_joined", "session_left"].includes(event.type)) dashboard?.poke();
96
98
  };
97
99
  const refresh = async () => {
98
100
  if (!joined) return;
@@ -124,6 +126,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
124
126
  };
125
127
  const joinNet = async (restored = false) => {
126
128
  if (joined) return;
129
+ if (barred) throw new Error(barred);
127
130
  at = await place(run);
128
131
  connect(true);
129
132
  await connected();
@@ -158,13 +161,17 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
158
161
  const openTower = async (query?: string) => {
159
162
  const all = await workspaces(run);
160
163
  const found = all.filter((w) => (query ? w.workspace_id === query || w.label === query : w.workspace_id === at.workspaceId));
161
- const [target] = found;
162
164
  if (found.length !== 1) {
163
165
  throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${query}". Workspaces: ${all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
164
166
  }
167
+ // A sub workspace never has a tower: its parent workspace's tower serves it.
168
+ const asked = found[0]!;
169
+ const target = isSub(asked) ? parentOf(all, asked) : asked;
170
+ if (!target) throw new Error(`${asked.label} is a sub workspace with no open parent workspace, so it has no tower; open the parent workspace first`);
165
171
  if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
166
172
  const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
167
- return { target, ...(await ensureTower({ run, sleep, live }, target, process.cwd())) };
173
+ const moveFirst = (tabId: string) => api("tab.move", { tab_id: tabId, insert_index: 0 }).catch((error: Error) => tell(`Straw Boss: could not move the tower tab first: ${error.message}`, "warning"));
174
+ return { target, routed: target !== asked ? asked : undefined, ...(await ensureTower({ run, sleep, live, moveFirst }, target, process.cwd())) };
168
175
  };
169
176
  const status = async () => {
170
177
  const sessions = await c.listSessions();
@@ -172,7 +179,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
172
179
  const mine = all.find((w) => w.workspace_id === at.workspaceId);
173
180
  const towerOf = (w?: Workspace) => {
174
181
  const s = sessions.find((x) => x.id === w?.tokens?.tower);
175
- return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === all[0] };
182
+ return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === headOf(all) };
176
183
  };
177
184
  const own = netMembers(sessions, ps).filter((m) => m.workspace.id === at.workspaceId && m.role !== "tower");
178
185
  const tabs = Object.values(Object.groupBy(own, (m) => m.tab.id)).map((group = []) => ({
@@ -184,7 +191,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
184
191
  return {
185
192
  me: { id: me(), role: role() },
186
193
  workspace: { id: at.workspaceId, label: mine?.label },
187
- head: all[0]?.workspace_id,
194
+ head: headOf(all)?.workspace_id,
188
195
  tower: towerOf(mine) ?? null,
189
196
  tabs,
190
197
  towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
@@ -214,7 +221,8 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
214
221
  const tower = await openTower(workspace);
215
222
  const result = await send(tower.id, message);
216
223
  if (result.status !== "sent") throw new Error(`The tower of ${tower.target.label} did not take the message: ${result.status} ${result.code ?? ""} ${result.detail ?? ""}`.trim());
217
- const text = `Sent to the tower of ${tower.target.label}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
224
+ const via = tower.routed ? ` (${tower.routed.label} is a sub workspace)` : "";
225
+ const text = `Sent to the tower of ${tower.target.label}${via}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
218
226
  return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
219
227
  });
220
228
  tool("boss_broadcast", "Send one message to every other session in a scope. tab, workspace, and towers reach only sessions that joined the net; all reaches every pi session on this machine (for notices such as the boss going to sleep).",
@@ -243,14 +251,29 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
243
251
  return;
244
252
  }
245
253
  holdsTower = true;
246
- info = { workspace: ws?.label ?? at.workspaceId, head: all[0]?.label ?? "" };
254
+ info = { workspace: ws?.label ?? at.workspaceId, head: headOf(all)?.label ?? "" };
247
255
  addNetTools();
248
256
  keepFresh();
249
257
  await publish();
258
+ // The dashboard lives exactly as long as this session holds the tower role.
259
+ if (hasUI) dashboard = startDashboard({ ui: ui!, collect: () => collect({ run, sessions: () => c.listSessions(), workspaceId: at.workspaceId }) });
250
260
  };
251
261
 
252
- pi.on("session_start", (_event, ctx) => {
262
+ // A session in a sub workspace (a linked git worktree) acts like a subagent: it never joins. Failing to tell bars it too.
263
+ const barReason = async () => {
264
+ try {
265
+ const { workspaceId } = await place(run);
266
+ const all = await workspaces(run);
267
+ const sub = all.find((w) => w.workspace_id === workspaceId);
268
+ return sub && isSub(sub) ? { text: `${sub.label} is a sub workspace, so this session cannot join the net; the main agent of its parent workspace represents it`, level: "info" as const } : undefined;
269
+ } catch (error) {
270
+ return { text: `Straw Boss could not tell whether this is a sub workspace: ${(error as Error).message}`, level: "error" as const };
271
+ }
272
+ };
273
+
274
+ pi.on("session_start", async (_event, ctx) => {
253
275
  ui = ctx.ui;
276
+ hasUI = ctx.hasUI;
254
277
  prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
255
278
  saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
256
279
  asked = Boolean(saved.asked);
@@ -260,12 +283,18 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
260
283
  if (isTower) {
261
284
  starting = startTower();
262
285
  } else {
263
- tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
264
- await joinNet();
265
- const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
266
- return { text, details: { role: role() } };
267
- });
268
- if (saved.joined) starting = joinNet(true);
286
+ const bar = await barReason();
287
+ barred = bar?.text;
288
+ if (bar) {
289
+ tell(bar.text, bar.level);
290
+ } else {
291
+ tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
292
+ await joinNet();
293
+ const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
294
+ return { text, details: { role: role() } };
295
+ });
296
+ if (saved.joined) starting = joinNet(true);
297
+ }
269
298
  }
270
299
  starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
271
300
  });
@@ -273,7 +302,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
273
302
  pi.on("before_agent_start", (event) => {
274
303
  const current = role();
275
304
  // A session outside the net is asked once, at its first run, whether it should join.
276
- const first = !current && !asked;
305
+ const first = !current && !asked && !barred;
277
306
  if (first) {
278
307
  asked = true;
279
308
  pi.appendEntry(ENTRY, { asked: true });
@@ -286,6 +315,8 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
286
315
  });
287
316
 
288
317
  pi.on("session_shutdown", async () => {
318
+ dashboard?.stop();
319
+ dashboard = undefined;
289
320
  clearInterval(timer);
290
321
  timer = undefined;
291
322
  const held = holdsTower;