pi-straw-boss 0.3.1 → 0.3.2

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
@@ -18,6 +18,7 @@ Run a few agents on the same project and they start stepping on each other: two
18
18
  - **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
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.
20
20
  - **Broadcast** to a tab, a workspace, every tower, or every session.
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.
21
22
 
22
23
  ## Install
23
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "pi": {
30
30
  "extensions": [
31
- "./src/index.ts"
31
+ "./src/index.ts",
32
+ "./src/balance.ts"
32
33
  ]
33
34
  },
34
35
  "scripts": {
@@ -0,0 +1,11 @@
1
+ export interface Rect { x: number; y: number; width: number; height: number }
2
+ export interface Split { id: string; direction: "right" | "down"; ratio: number; rect: Rect }
3
+ export interface Pane { pane_id: string; rect: Rect }
4
+ export interface Layout { panes: Pane[]; splits: Split[]; zoomed?: boolean }
5
+ export interface Resize { pane: string; direction: "left" | "right" | "up" | "down"; amount: number }
6
+ export type Run = (args: string[]) => Promise<string>;
7
+
8
+ export function planBalance(layout: Layout): Resize[];
9
+ export const herdr: Run;
10
+ export function balanceTab(paneId: string, options?: { run?: Run; lockRoot?: string }): Promise<void>;
11
+ export function balanceAfterClose(paneId: string, tabId: string | undefined, options?: { run?: Run; lockRoot?: string; pollMs?: number; waitMs?: number }): Promise<void>;
@@ -0,0 +1,178 @@
1
+ // Equalises the panes of one Herdr tab. The extension (balance.ts) runs it detached: `balance.mjs now <pane>` when its pi
2
+ // starts, and `balance.mjs after-close <pane> <tab>` when its pi quits, because the pane closes only after the pi process is gone.
3
+ // Plain JavaScript on purpose: Node will not strip types from a .ts file inside node_modules, where this package is installed.
4
+ import { execFile } from "node:child_process";
5
+ import { mkdir, rmdir, stat } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ /** @typedef {{ x: number, y: number, width: number, height: number }} Rect */
11
+ /** @typedef {{ id: string, direction: "right" | "down", ratio: number, rect: Rect }} Split */
12
+ /** @typedef {{ pane_id: string, rect: Rect }} Pane */
13
+ /** @typedef {{ panes: Pane[], splits: Split[], zoomed?: boolean }} Layout */
14
+ /** @typedef {{ pane: string, direction: "left" | "right" | "up" | "down", amount: number }} Resize */
15
+ /** @typedef {(args: string[]) => Promise<string>} Run */
16
+
17
+ const TOLERANCE = 0.01;
18
+ // Each pass applies one resize and rereads the layout; a tab never needs more passes than this.
19
+ const MAX_PASSES = 32;
20
+ const LOCK_ROOT = join(tmpdir(), "pi-straw-boss-pane-balance");
21
+ const LOCK_WAIT_MS = 15_000;
22
+ const LOCK_STALE_MS = 30_000;
23
+ // pi-herdr-agents closes a worker's pane once its lead has taken the result.
24
+ const CLOSE_WAIT_MS = 30_000;
25
+ const POLL_MS = 250;
26
+
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+
29
+ // Herdr reports its split tree flat: `split_<depth>_<path>`, where the path spells the
30
+ // first (0) or second (1) child taken from the root, and the root's path is `root`.
31
+ const splitPath = (id) => {
32
+ const path = id.split("_").slice(2).join("_");
33
+ return path === "root" ? "" : path;
34
+ };
35
+
36
+ /**
37
+ * The resizes that give every leaf an equal share along each split's axis, outermost first.
38
+ * @param {Layout} layout
39
+ * @returns {Resize[]}
40
+ */
41
+ export function planBalance(layout) {
42
+ const splits = new Map(layout.splits.map((split) => [splitPath(split.id), split]));
43
+ /** @type {Map<string, Pane[]>} */
44
+ const members = new Map([["", layout.panes]]);
45
+ const sides = (path) => {
46
+ const split = splits.get(path);
47
+ const horizontal = split.direction === "right";
48
+ const boundary = horizontal
49
+ ? split.rect.x + split.rect.width * split.ratio
50
+ : split.rect.y + split.rect.height * split.ratio;
51
+ const first = [];
52
+ const second = [];
53
+ for (const pane of members.get(path) ?? []) {
54
+ const centre = horizontal ? pane.rect.x + pane.rect.width / 2 : pane.rect.y + pane.rect.height / 2;
55
+ (centre < boundary ? first : second).push(pane);
56
+ }
57
+ return [first, second];
58
+ };
59
+ for (const path of [...splits.keys()].sort((a, b) => a.length - b.length)) {
60
+ const [first, second] = sides(path);
61
+ members.set(`${path}0`, first);
62
+ members.set(`${path}1`, second);
63
+ }
64
+ const weight = (path, axis) => {
65
+ const split = splits.get(path);
66
+ if (!split) return 1;
67
+ const a = weight(`${path}0`, axis);
68
+ const b = weight(`${path}1`, axis);
69
+ return split.direction === axis ? a + b : Math.max(a, b);
70
+ };
71
+ /** @type {Resize[]} */
72
+ const resizes = [];
73
+ for (const [path, split] of [...splits].sort(([a], [b]) => a.length - b.length)) {
74
+ const first = weight(`${path}0`, split.direction);
75
+ const target = first / (first + weight(`${path}1`, split.direction));
76
+ const delta = target - split.ratio;
77
+ if (Math.abs(delta) < TOLERANCE) continue;
78
+ const horizontal = split.direction === "right";
79
+ const edge = (pane) => (horizontal ? pane.rect.x + pane.rect.width : pane.rect.y + pane.rect.height);
80
+ const start = (pane) => (horizontal ? pane.rect.x : pane.rect.y);
81
+ // Herdr moves the named edge of the pane: grow the first side from its far edge,
82
+ // or grow the second side from its near edge.
83
+ const amount = Number(Math.abs(delta).toFixed(4));
84
+ if (delta > 0) {
85
+ const pane = members.get(`${path}0`).reduce((best, pane) => (edge(pane) > edge(best) ? pane : best));
86
+ resizes.push({ pane: pane.pane_id, direction: horizontal ? "right" : "down", amount });
87
+ } else {
88
+ const pane = members.get(`${path}1`).reduce((best, pane) => (start(pane) < start(best) ? pane : best));
89
+ resizes.push({ pane: pane.pane_id, direction: horizontal ? "left" : "up", amount });
90
+ }
91
+ }
92
+ return resizes;
93
+ }
94
+
95
+ /** @type {Run} */
96
+ export const herdr = (args) =>
97
+ new Promise((resolve, reject) => {
98
+ execFile(process.env.HERDR_BIN_PATH || "herdr", args, { encoding: "utf8" }, (error, stdout, stderr) =>
99
+ error ? reject(Object.assign(error, { stderr })) : resolve(stdout));
100
+ });
101
+
102
+ const readLayout = async (paneId, run) =>
103
+ /** @type {Layout & { tab_id: string }} */ (JSON.parse(await run(["pane", "layout", "--pane", paneId])).result.layout);
104
+
105
+ /** Holds a per-tab directory lock so concurrent Pi sessions take turns resizing one tab. */
106
+ async function withTabLock(tabId, lockRoot, work) {
107
+ const lock = join(lockRoot, tabId.replace(/[^\w.-]/g, "_"));
108
+ await mkdir(lockRoot, { recursive: true });
109
+ const deadline = Date.now() + LOCK_WAIT_MS;
110
+ for (;;) {
111
+ try {
112
+ await mkdir(lock);
113
+ break;
114
+ } catch (error) {
115
+ if (error.code !== "EEXIST") throw error;
116
+ const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
117
+ if (age > LOCK_STALE_MS) {
118
+ await rmdir(lock).catch(() => {});
119
+ continue;
120
+ }
121
+ if (Date.now() > deadline) return;
122
+ await sleep(100);
123
+ }
124
+ }
125
+ try {
126
+ await work();
127
+ } finally {
128
+ await rmdir(lock).catch(() => {});
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Equalises the tab holding `paneId`. Every resize is planned from a freshly read layout,
134
+ * so a pane that opens or closes mid-way is accounted for instead of overshot.
135
+ * @param {string} paneId
136
+ * @param {{ run?: Run, lockRoot?: string }} [options]
137
+ */
138
+ export async function balanceTab(paneId, { run = herdr, lockRoot = LOCK_ROOT } = {}) {
139
+ const { tab_id: tabId } = await readLayout(paneId, run);
140
+ await withTabLock(tabId, lockRoot, async () => {
141
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
142
+ const layout = await readLayout(paneId, run);
143
+ if (layout.zoomed) return;
144
+ const [resize] = planBalance(layout);
145
+ if (!resize) return;
146
+ await run(["pane", "resize", "--pane", resize.pane, "--direction", resize.direction, "--amount", String(resize.amount)]);
147
+ }
148
+ });
149
+ }
150
+
151
+ /** Whether Herdr reports the pane gone; it says so on stderr. Any other failure counts as open. */
152
+ const paneGone = (paneId, run) =>
153
+ run(["pane", "get", paneId]).then(() => false, (error) => String(error?.stderr ?? "").includes("pane_not_found"));
154
+
155
+ /**
156
+ * Waits for `paneId` to close, then equalises `tabId`, the tab the quitting Pi was in.
157
+ * A pane that outlives its Pi, such as a shell the user keeps, changes no layout and is left alone.
158
+ * @param {string} paneId
159
+ * @param {string | undefined} tabId
160
+ * @param {{ run?: Run, lockRoot?: string, pollMs?: number, waitMs?: number }} [options]
161
+ */
162
+ export async function balanceAfterClose(paneId, tabId, { run = herdr, lockRoot = LOCK_ROOT, pollMs = POLL_MS, waitMs = CLOSE_WAIT_MS } = {}) {
163
+ if (!tabId) return;
164
+ const deadline = Date.now() + waitMs;
165
+ while (!(await paneGone(paneId, run))) {
166
+ if (Date.now() > deadline) return;
167
+ await sleep(pollMs);
168
+ }
169
+ const panes = /** @type {{ pane_id: string, tab_id: string }[]} */ (JSON.parse(await run(["pane", "list"])).result.panes);
170
+ const anchor = panes.find((pane) => pane.tab_id === tabId);
171
+ if (anchor) await balanceTab(anchor.pane_id, { run, lockRoot });
172
+ }
173
+
174
+ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[3]) {
175
+ const [, , mode, pane, tab] = process.argv;
176
+ if (mode === "now") await balanceTab(pane).catch(() => {});
177
+ if (mode === "after-close") await balanceAfterClose(pane, tab).catch(() => {});
178
+ }
package/src/balance.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ // Pane balance: when a pi starts in a Herdr tab, or quits out of one, the tab's panes are made equal again, so a tab that
6
+ // pi-herdr-agents keeps splitting for workers does not end up as a chain of halves. Every pi does this, joined or not.
7
+ // The work runs in balance.mjs as a detached process: a quitting pi exits before Herdr closes its pane, and a fresh process
8
+ // always runs the installed version, where an imported .mjs would stay cached across /reload.
9
+
10
+ const HELPER = fileURLToPath(new URL("./balance.mjs", import.meta.url));
11
+
12
+ /** Runs the helper outside this process: `now <pane>` or `after-close <pane> <tab>`. */
13
+ export function runHelper(args: string[], launch = spawn): void {
14
+ const child = launch(process.execPath, [HELPER, ...args], { detached: true, stdio: "ignore" });
15
+ child.on("error", () => {});
16
+ child.unref();
17
+ }
18
+
19
+ // Survives /reload, which loads extensions again in the same process.
20
+ const STARTED = Symbol.for("pi-straw-boss.pane-balance.started");
21
+
22
+ /** Equalises a Herdr tab only when a pi starts in it or quits out of it. */
23
+ export default function paneBalance(pi: ExtensionAPI, helper = runHelper, env = process.env): void {
24
+ const paneId = env.HERDR_PANE_ID;
25
+ if (!paneId) return;
26
+ // Balance on load: session_start waits for every other extension's handler, about ten seconds later.
27
+ const host = globalThis as { [STARTED]?: boolean };
28
+ if (!host[STARTED]) {
29
+ host[STARTED] = true;
30
+ helper(["now", paneId]);
31
+ }
32
+ pi.on("session_shutdown", (event) => {
33
+ if (event.reason === "quit") helper(["after-close", paneId, env.HERDR_TAB_ID ?? ""]);
34
+ });
35
+ }