pi-straw-boss 0.6.3 → 0.6.5
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 +27 -0
- package/package.json +1 -1
- package/src/dashboard.ts +3 -3
- package/src/herdr.ts +6 -2
- package/src/index.ts +22 -6
- package/src/order.ts +75 -0
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Run a few agents on the same project and they start stepping on each other: two
|
|
|
19
19
|
- **A browser view** of the whole net, `/boss gui`: every workspace as a base with its tower, agents, and subagents, animated and read-only. Click a base to zoom in, click a unit to read its chat as a live comms log; units that wait on you queue up as alerts.
|
|
20
20
|
- **Broadcast** to a tab, a workspace, every tower, or every session.
|
|
21
21
|
- **Tidy tabs.** When a pi starts in a tab or quits out of it, the tab's panes are made equal again, so workers split off one by one don't leave a chain of halves.
|
|
22
|
+
- **A net view in Herdr's sidebar.** An optional plugin turns the Agents list into the net, towers first and each subagent under its launcher, on one key.
|
|
22
23
|
|
|
23
24
|
## Install
|
|
24
25
|
|
|
@@ -50,6 +51,32 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
|
|
|
50
51
|
|
|
51
52
|
Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
|
|
52
53
|
|
|
54
|
+
## Net view in the sidebar
|
|
55
|
+
|
|
56
|
+
An optional Herdr plugin in [`herdr-plugin/`](herdr-plugin) turns the sidebar's Agents list into a net view.
|
|
57
|
+
It lists the head tower's workspace first, in each workspace the tower, then each tab's main agent and members, and every subagent right under the session that launched it, even in a worktree workspace.
|
|
58
|
+
One key switches to it, and the same key switches back to Herdr's own view.
|
|
59
|
+
pi-straw-boss writes the sort key (`sb_order`) and a role label (`sb_mark`) the view and your sidebar rows can use.
|
|
60
|
+
It needs Herdr 0.9.3 or newer and node.
|
|
61
|
+
|
|
62
|
+
Link it from a checkout, then bind a key in Herdr's `config.toml`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
herdr plugin link /path/to/pi-straw-boss/herdr-plugin
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
[[keys.command]]
|
|
70
|
+
key = "prefix+alt+n"
|
|
71
|
+
type = "plugin_action"
|
|
72
|
+
command = "wayne930242.straw-boss.net"
|
|
73
|
+
description = "toggle the straw net view"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run `herdr server reload-config` to load the key, and `herdr plugin unlink wayne930242.straw-boss` to remove the plugin.
|
|
77
|
+
`herdr plugin install wayne930242/pi-straw-boss/herdr-plugin` installs it from GitHub instead of a checkout.
|
|
78
|
+
[How it works](docs/how-it-works.md#sidebar-net-view) has the tokens and a sidebar rows snippet that shows each member's role in Herdr's own view too.
|
|
79
|
+
|
|
53
80
|
## Learn more
|
|
54
81
|
|
|
55
82
|
[How it works](docs/how-it-works.md) covers roles, the tower, the dashboard, the browser view, what the extension writes, and how Claude Code or Codex agents can find a tower.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
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",
|
package/src/dashboard.ts
CHANGED
|
@@ -78,8 +78,8 @@ function readJson(path: string): Record<string, unknown> {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. */
|
|
82
|
-
export function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
|
|
81
|
+
/** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. The dashboard shows the first `limit`. */
|
|
82
|
+
export function subagents(parent: Pane | undefined, all: Pane[], now: number, limit = MAX_CHILDREN): Row[] {
|
|
83
83
|
const file = parent?.agent_session?.value;
|
|
84
84
|
if (!file) return [];
|
|
85
85
|
const dir = join(dirname(file), "artifacts", basename(file, ".jsonl").split("_").pop()!);
|
|
@@ -109,7 +109,7 @@ export function subagents(parent: Pane | undefined, all: Pane[], now: number): R
|
|
|
109
109
|
return { group: "net" as const, depth: 2, mark, text: name, kind: "subagent", workspace: pane?.workspace_id, id, file, pane: pane?.agent === "pi" ? pane.pane_id : undefined, note: { working: doing, waiting: "waiting on a question", done: "finished" }[mark as "working"] };
|
|
110
110
|
})
|
|
111
111
|
.sort((a, b) => Number(a.mark === "done") - Number(b.mark === "done") || a.text.localeCompare(b.text))
|
|
112
|
-
.slice(0,
|
|
112
|
+
.slice(0, limit);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
package/src/herdr.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type Sleep = (ms: number) => Promise<void>;
|
|
|
20
20
|
export interface Place { paneId: string; tabId: string; workspaceId: string }
|
|
21
21
|
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 } }
|
|
22
22
|
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> }
|
|
23
|
+
export interface HerdrTab { tab_id: string; workspace_id: string; label?: string }
|
|
23
24
|
export interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
|
|
24
25
|
/** A live pi-intercom session; `status` is idle, thinking, or tool:<name>. */
|
|
25
26
|
export interface Session { id: string; name?: string; status?: string; herdrLocation?: Location }
|
|
@@ -49,8 +50,8 @@ async function result(run: HerdrRun, args: string[], timeoutMs?: number) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
// The tower tab is labelled with the Nerd Font
|
|
53
|
-
export const TOWER_LABEL = "\u{
|
|
53
|
+
// The tower tab is labelled with the Nerd Font radio tower glyph (md-radio_tower, U+F043B) alone.
|
|
54
|
+
export const TOWER_LABEL = "\u{f043b}";
|
|
54
55
|
|
|
55
56
|
export async function place(run: HerdrRun): Promise<Place> {
|
|
56
57
|
const pane = (await result(run, ["pane", "current"])).pane;
|
|
@@ -65,6 +66,9 @@ export const headOf = (all: Workspace[]) => all.find((w) => !isSub(w));
|
|
|
65
66
|
|
|
66
67
|
export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
|
|
67
68
|
|
|
69
|
+
/** The tabs of one workspace in Herdr's tab order. */
|
|
70
|
+
export const tabs = async (run: HerdrRun, workspace: string): Promise<HerdrTab[]> => (await result(run, ["tab", "list", "--workspace", workspace])).tabs;
|
|
71
|
+
|
|
68
72
|
export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
|
|
69
73
|
return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
|
|
70
74
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { collect, startDashboard } from "./dashboard.ts";
|
|
|
9
9
|
import { createHub, type Hub } from "./dialogs.ts";
|
|
10
10
|
import { answers, type Gui, openBrowser, readRecord, sharedGui, startGui, urlOf } from "./gui.ts";
|
|
11
11
|
import { netSource } from "./net.ts";
|
|
12
|
+
import { clearOrder, markKids, netTokens } from "./order.ts";
|
|
12
13
|
import { copyText, pairingKeys, showLines } from "./pairing.ts";
|
|
13
14
|
import { type Run, switchRemote, tailscale } from "./remote.ts";
|
|
14
15
|
import { ensureTower, headOf, herdrApi, type HerdrApi, herdrCall, type HerdrCall, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
|
|
@@ -68,6 +69,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
68
69
|
let gui: Gui | undefined, viewer: Channel | undefined, opening: Promise<string> | undefined;
|
|
69
70
|
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
70
71
|
let queue: Promise<void> = Promise.resolve();
|
|
72
|
+
// This session's intercom name, which the subagents it launched show as their launcher, and the subagent panes it has marked.
|
|
73
|
+
let ownName = "", kids = new Set<string>();
|
|
71
74
|
const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
|
|
72
75
|
const role = (): Role | undefined => {
|
|
73
76
|
if (isTower) return "tower";
|
|
@@ -98,11 +101,20 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
98
101
|
};
|
|
99
102
|
|
|
100
103
|
// Tokens are display and discovery only: a failed write waits for the next refresh, liveness comes from intercom.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// One publish at a time, and shutdown waits for the one in flight: a write that lands after the clear would leave tokens behind.
|
|
105
|
+
let publishing: Promise<void> = Promise.resolve(), stopped = false;
|
|
106
|
+
const publishOnce = async (known?: Session[]) => {
|
|
107
|
+
const sbRole = stopped ? undefined : role();
|
|
108
|
+
if (sbRole) {
|
|
109
|
+
ownName = nameOf((known ?? (await c.listSessions().catch(() => []))).find((s) => s.id === me())) || ownName;
|
|
110
|
+
// A failed read of Herdr leaves the subagents' tokens as they are; their TTL still runs.
|
|
111
|
+
const net = await netTokens(run, at, sbRole, ownName || me().slice(0, 8)).catch(() => undefined);
|
|
112
|
+
await report(run, "pane", at.paneId, { sb_role: sbRole, ...net?.own }).catch(() => {});
|
|
113
|
+
if (net) kids = await markKids(run, net.kids, kids, net.joined);
|
|
114
|
+
}
|
|
115
|
+
if (holdsTower && !stopped) await report(run, "workspace", at.workspaceId, { tower: me() }).catch(() => {});
|
|
105
116
|
};
|
|
117
|
+
const publish = (known?: Session[]) => (publishing = publishing.then(() => publishOnce(known)).catch(() => {}));
|
|
106
118
|
const keepFresh = () => {
|
|
107
119
|
timer ??= setInterval(() => void publish(), REFRESH_MS);
|
|
108
120
|
timer.unref();
|
|
@@ -157,7 +169,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
157
169
|
const wasMain = isMain;
|
|
158
170
|
isMain = mainId === me();
|
|
159
171
|
if (isMain !== wasMain) tell(`This session is ${isMain ? "now" : "no longer"} the main agent of ${info.tab}`);
|
|
160
|
-
await publish();
|
|
172
|
+
await publish(sessions);
|
|
161
173
|
// The workspace's first main opens the tower at once; later mains find it open.
|
|
162
174
|
if (isMain && !wasMain && autoTower) void openTower().catch((error: Error) => tell(`Straw Boss: could not open the tower: ${error.message}`, "warning"));
|
|
163
175
|
} catch (error) {
|
|
@@ -338,6 +350,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
338
350
|
};
|
|
339
351
|
|
|
340
352
|
pi.on("session_start", async (_event, ctx) => {
|
|
353
|
+
stopped = false;
|
|
341
354
|
ui = ctx.ui;
|
|
342
355
|
hasUI = ctx.hasUI;
|
|
343
356
|
prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
|
|
@@ -385,10 +398,13 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
385
398
|
dashboard = undefined;
|
|
386
399
|
clearInterval(timer);
|
|
387
400
|
timer = undefined;
|
|
401
|
+
stopped = true; // a refresh still in flight must not write tokens after they are cleared
|
|
402
|
+
await publishing;
|
|
388
403
|
const held = holdsTower;
|
|
389
404
|
holdsTower = false;
|
|
390
405
|
if (!role() || !at) return;
|
|
391
|
-
await report(run, "pane", at.paneId, { sb_role: null }).catch(() => {});
|
|
406
|
+
await report(run, "pane", at.paneId, { sb_role: null, sb_order: null, sb_mark: null }).catch(() => {});
|
|
407
|
+
await clearOrder(run, [...kids]);
|
|
392
408
|
if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
|
|
393
409
|
});
|
|
394
410
|
|
package/src/order.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { subagents } from "./dashboard.ts";
|
|
2
|
+
import { type HerdrRun, type HerdrTab, isSub, type Pane, panes, type Place, report, tabs, workspaces, type Workspace } from "./herdr.ts";
|
|
3
|
+
|
|
4
|
+
// Two display tokens for the sidebar plugin and for Herdr's own Agents rows, written next to `sb_role`:
|
|
5
|
+
// - `sb_order`: a sort key that lays the net out as the head tower's workspace first, then the other workspaces in Herdr's order, in each the tower
|
|
6
|
+
// and then the tabs in tab order, in each tab the main agent and then the members, and every subagent right after the session that launched it,
|
|
7
|
+
// its own subagents after it in turn. Segments are fixed-width and `.`-separated, so plain string order is the numeric order of every segment,
|
|
8
|
+
// and a subagent's key is its parent's key plus one more segment, which sorts after the parent and before the parent's next sibling.
|
|
9
|
+
// - `sb_mark`: a short label that says what a net member is, a star for a main agent, a dot for a member, and an arrow and the launcher's name for a subagent.
|
|
10
|
+
// A tower has none: its tab already carries the radio tower glyph.
|
|
11
|
+
// A session writes them for itself and for the subagents it launched, which never join and write nothing, on the same 4 minute refresh as `sb_role`.
|
|
12
|
+
|
|
13
|
+
/** The glyphs sit in Hack Nerd Font and MesloLGS NF: Font Awesome's star (U+F005), the middle dot, and the down-right arrow. */
|
|
14
|
+
export const MARK = { main: "\uf005 main", member: "\u00b7 member" } as const;
|
|
15
|
+
export const ARROW = "\u21b3";
|
|
16
|
+
// The dashboard shows eight subagents per session, but every live one is keyed: one left out would sort outside its launcher's span. Three digits count them.
|
|
17
|
+
const MAX_KIDS = 999;
|
|
18
|
+
const NAME_MAX = 24;
|
|
19
|
+
const part = (n: number, width = 3) => String(n).padStart(width, "0");
|
|
20
|
+
const short = (name: string) => (name.length > NAME_MAX ? `${name.slice(0, NAME_MAX - 1)}…` : name);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The key of a session that is in the net, or undefined when Herdr cannot place it. Workspaces count among the base ones (a sub workspace is a
|
|
24
|
+
* linked worktree whose sessions hang under their launcher), the head tower's workspace being the first of them; a pane's slot is its place among
|
|
25
|
+
* its tab's panes, so every member has a key of its own.
|
|
26
|
+
*/
|
|
27
|
+
export function orderKey(role: "tower" | "main" | "member", pane: Pane, ps: Pane[], all: Workspace[], tabList: HerdrTab[]): string | undefined {
|
|
28
|
+
const base = all.filter((w) => !isSub(w)).findIndex((w) => w.workspace_id === pane.workspace_id);
|
|
29
|
+
if (base < 0) return undefined;
|
|
30
|
+
if (role === "tower") return `${part(base)}.0`;
|
|
31
|
+
const tab = tabList.findIndex((t) => t.tab_id === pane.tab_id), slot = ps.filter((p) => p.tab_id === pane.tab_id).findIndex((p) => p.pane_id === pane.pane_id);
|
|
32
|
+
return tab < 0 || slot < 0 ? undefined : `${part(base)}.1.${part(tab)}.${role === "main" ? 0 : 1}.${part(slot)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The tokens of the live subagents below a session, each keyed after its launcher, at any depth. Subagents that finished have no pane to mark;
|
|
37
|
+
* a pane already marked on the way down (a cycle in the launch records) is not marked again, which also ends the descent.
|
|
38
|
+
*/
|
|
39
|
+
export function descendants(parent: Pane | undefined, key: string, name: string, ps: Pane[], now: number, seen = new Set<string>(parent ? [parent.pane_id] : [])): Kid[] {
|
|
40
|
+
if (!parent) return [];
|
|
41
|
+
return subagents(parent, ps, now, MAX_KIDS).flatMap((row, i) => {
|
|
42
|
+
if (!row.pane || seen.has(row.pane)) return [];
|
|
43
|
+
seen.add(row.pane);
|
|
44
|
+
const order = `${key}.${part(i)}`;
|
|
45
|
+
return [{ pane: row.pane, order, mark: `${ARROW} ${short(name)}` }, ...descendants(ps.find((p) => p.pane_id === row.pane), order, row.text, ps, now, seen)];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The subagent panes a session marks: each gets `sb_order` and `sb_mark`. */
|
|
50
|
+
export interface Kid { pane: string; order: string; mark: string }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a session publishes besides its role: its own two tokens (null clears them when Herdr cannot place the session, and a tower has no
|
|
54
|
+
* mark) and those of the subagents it launched. Reads Herdr only.
|
|
55
|
+
*/
|
|
56
|
+
export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "main" | "member", name: string, now = Date.now()): Promise<{ own: Record<string, string | null>; kids: Kid[]; joined: Set<string> }> {
|
|
57
|
+
const [ps, all, tabList] = [await panes(run), await workspaces(run), await tabs(run, place.workspaceId)];
|
|
58
|
+
const pane = ps.find((p) => p.pane_id === place.paneId), key = pane && orderKey(role, pane, ps, all, tabList);
|
|
59
|
+
const joined = new Set(ps.filter((p) => p.tokens?.sb_role).map((p) => p.pane_id));
|
|
60
|
+
return { own: { sb_order: key ?? null, sb_mark: role === "tower" ? null : MARK[role] }, kids: key ? descendants(pane, key, name, ps, now) : [], joined };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Writes the subagents' tokens and clears those of subagent panes marked before and gone now, except a pane that has joined the net since
|
|
65
|
+
* (a shell where a subagent ran can start a pi that writes its own). Returns the panes now marked. A failed write waits for the next refresh.
|
|
66
|
+
*/
|
|
67
|
+
export async function markKids(run: HerdrRun, kids: Kid[], before: Set<string>, joined = new Set<string>()): Promise<Set<string>> {
|
|
68
|
+
for (const kid of kids) await report(run, "pane", kid.pane, { sb_order: kid.order, sb_mark: kid.mark }).catch(() => {});
|
|
69
|
+
const marked = new Set(kids.map((k) => k.pane));
|
|
70
|
+
await clearOrder(run, [...before].filter((id) => !marked.has(id) && !joined.has(id)));
|
|
71
|
+
return marked;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Removes both tokens from panes, ignoring a pane that has already closed. */
|
|
75
|
+
export const clearOrder = (run: HerdrRun, paneIds: string[]) => Promise.all(paneIds.map((id) => report(run, "pane", id, { sb_order: null, sb_mark: null }).catch(() => {})));
|