pi-straw-boss 0.9.0 → 0.9.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/package.json +1 -1
- package/src/herdr.ts +21 -7
- package/src/index.ts +27 -4
- package/src/server-watch.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.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",
|
package/src/herdr.ts
CHANGED
|
@@ -164,14 +164,24 @@ async function open({ run, sleep, live, moveFirst, model }: TowerEnv, workspace:
|
|
|
164
164
|
|
|
165
165
|
// A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
|
|
166
166
|
// The tab is told apart by the `sb_role=tower` pane token that only Straw Boss writes; a tab merely labelled "tower"
|
|
167
|
-
// (a pi-herdr-agents subagent can be named so) is someone else's.
|
|
167
|
+
// (a pi-herdr-agents subagent can be named so) is someone else's. A tab whose token was cleared (its tower quit) is still ours when it has
|
|
168
|
+
// exactly the tower label (the glyph), one pane, and no agent in it.
|
|
168
169
|
let paneId: string;
|
|
169
170
|
let running = false;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
const here = await panes(run, ws);
|
|
172
|
+
const marked = here.find((p) => p.tokens?.sb_role === "tower");
|
|
173
|
+
const bare = marked ? [] : (await tabs(run, ws)).filter((t) => t.label === TOWER_LABEL).flatMap((t) => {
|
|
174
|
+
const inside = here.filter((p) => p.tab_id === t.tab_id);
|
|
175
|
+
return inside.length === 1 && !inside[0]!.agent && !inside[0]!.tokens?.sb_role ? [inside[0]!] : [];
|
|
176
|
+
});
|
|
177
|
+
const reused = marked ?? bare[0];
|
|
178
|
+
if (reused) {
|
|
179
|
+
paneId = reused.pane_id;
|
|
180
|
+
running = reused.agent === "pi";
|
|
181
|
+
if (!marked) await report(run, "pane", paneId, { sb_role: "tower" });
|
|
182
|
+
await moveFirst(reused.tab_id);
|
|
183
|
+
// Other shell-only leftovers are closed; failing to close one leaves a harmless tab, so it does not stop the tower.
|
|
184
|
+
for (const extra of bare.slice(1)) await run(["tab", "close", extra.tab_id]).catch(() => {});
|
|
175
185
|
} else {
|
|
176
186
|
const dir = workspace.worktree?.checkout_path ?? cwd;
|
|
177
187
|
const created = await createTab(run, ws, dir, ["STRAW_BOSS_ROLE=tower"], TOWER_LABEL);
|
|
@@ -182,7 +192,11 @@ async function open({ run, sleep, live, moveFirst, model }: TowerEnv, workspace:
|
|
|
182
192
|
}
|
|
183
193
|
if (!running) {
|
|
184
194
|
// Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
|
|
185
|
-
|
|
195
|
+
// An adopted shell that turns out busy (the user runs something there) gives the token back, so the next open does not count it as the tower.
|
|
196
|
+
await startPi(run, sleep, `tower-${ws.toLowerCase()}`, paneId, ["--name", `tower · ${workspace.label}`, ...(model ? ["--model", model] : [])]).catch(async (error: unknown) => {
|
|
197
|
+
if (reused && !marked) await report(run, "pane", paneId, { sb_role: null }).catch(() => {});
|
|
198
|
+
throw error;
|
|
199
|
+
});
|
|
186
200
|
}
|
|
187
201
|
const id = await waitFor(sleep, held, TOWER_WAIT_MS, 1000);
|
|
188
202
|
if (!id) throw new Error(`The tower of ${workspace.label} did not publish its token within ${TOWER_WAIT_MS / 1000} s (pane ${paneId})`);
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { opener } from "./opener.ts";
|
|
|
15
15
|
import { orphans } from "./orphan.ts";
|
|
16
16
|
import { copyText, pairingKeys, showLines } from "./pairing.ts";
|
|
17
17
|
import { type Run, switchRemote, tailscale } from "./remote.ts";
|
|
18
|
+
import { type HerdrWatch, herdrWatch } from "./server-watch.ts";
|
|
18
19
|
import { ensureTower, headOf, towerModel, 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";
|
|
19
20
|
|
|
20
21
|
// pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
|
|
@@ -43,7 +44,7 @@ interface Saved { tower?: boolean }
|
|
|
43
44
|
type Role = "tower" | "main" | "member";
|
|
44
45
|
type Scope = "tab" | "workspace" | "towers" | "all";
|
|
45
46
|
type Member = ReturnType<typeof netMembers>[number];
|
|
46
|
-
export interface Deps { run?: HerdrRun; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string; autoTower?: boolean; nudgeMs?: number }
|
|
47
|
+
export interface Deps { run?: HerdrRun; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; watch?: HerdrWatch; stateDir?: string; autoTower?: boolean; nudgeMs?: number }
|
|
47
48
|
|
|
48
49
|
/** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
|
|
49
50
|
function netMembers(sessions: Session[], ps: Pane[]) {
|
|
@@ -55,7 +56,7 @@ function netMembers(sessions: Session[], ps: Pane[]) {
|
|
|
55
56
|
}
|
|
56
57
|
const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
|
|
57
58
|
|
|
58
|
-
export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir, autoTower = true, nudgeMs = NUDGE_MS }: Deps = {}) {
|
|
59
|
+
export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, watch = herdrWatch(), stateDir, autoTower = true, nudgeMs = NUDGE_MS }: Deps = {}) {
|
|
59
60
|
return function strawBoss(pi: ExtensionAPI): void {
|
|
60
61
|
// Outside Herdr there is no tab to coordinate. Every session in Herdr relays its dialogs to the GUI (bridge.ts), subagents too: they ask
|
|
61
62
|
// questions as well. A pi-herdr-agents worker never joins: its parent represents it.
|
|
@@ -76,7 +77,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
76
77
|
let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
|
|
77
78
|
let gui: Gui | undefined, viewer: Channel | undefined, opening: Promise<string> | undefined;
|
|
78
79
|
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
79
|
-
let queue: Promise<void> = Promise.resolve();
|
|
80
|
+
let queue: Promise<void> = Promise.resolve(), unwatch: (() => void) | undefined;
|
|
80
81
|
// This session's intercom name, which the subagents it launched show as their launcher, and the subagent panes it has marked.
|
|
81
82
|
let ownName = "", openedBy: string | undefined, kids = new Set<string>();
|
|
82
83
|
// Watches for subagents this session launched whose result can no longer reach it (orphan.ts).
|
|
@@ -125,6 +126,16 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
125
126
|
if (holdsTower && !stopped) await report(run, "workspace", at.workspaceId, { tower: me() }).catch(() => {});
|
|
126
127
|
};
|
|
127
128
|
const publish = (known?: Session[]) => (publishing = publishing.then(() => publishOnce(known)).catch(() => {}));
|
|
129
|
+
// A new Herdr server (an update handoff, or a stop and start) starts without the tokens of the one before. Writing them waits for no refresh:
|
|
130
|
+
// the tower's marks go first, because they are how another session finds the tower, and the rest follows. Nothing is written by a session that has not joined.
|
|
131
|
+
const restore = () => {
|
|
132
|
+
if (stopped || !at || !(joined || holdsTower)) return;
|
|
133
|
+
publishing = publishing.then(async () => {
|
|
134
|
+
if (stopped) return;
|
|
135
|
+
if (holdsTower) await Promise.all([report(run, "workspace", at.workspaceId, { tower: me() }), report(run, "pane", at.paneId, { sb_role: "tower" })]).catch(() => {});
|
|
136
|
+
await publishOnce();
|
|
137
|
+
}).catch(() => {});
|
|
138
|
+
};
|
|
128
139
|
const keepFresh = () => {
|
|
129
140
|
timer ??= setInterval(() => void publish(), REFRESH_MS);
|
|
130
141
|
timer.unref();
|
|
@@ -397,11 +408,14 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
397
408
|
const log = ctx.sessionManager;
|
|
398
409
|
clearInterval(nudgeTimer);
|
|
399
410
|
nudges?.stop();
|
|
411
|
+
unwatch?.();
|
|
412
|
+
unwatch = undefined;
|
|
400
413
|
nudges = orphans({ run, file: () => log.getSessionFile(), entries: () => log.getEntries(), sessions, send, type: ENTRY, remember: (key) => pi.appendEntry(ENTRY, { nudged: key }), me, tell });
|
|
401
414
|
nudgeTimer = setInterval(() => void nudges?.poll().catch(() => {}), nudgeMs).unref();
|
|
402
415
|
// Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
|
|
403
416
|
let starting: Promise<void> | undefined;
|
|
404
417
|
if (isTower) {
|
|
418
|
+
unwatch = watch(restore);
|
|
405
419
|
starting = startTower();
|
|
406
420
|
} else {
|
|
407
421
|
const bar = await barReason();
|
|
@@ -409,6 +423,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
409
423
|
if (bar) {
|
|
410
424
|
tell(bar.text, bar.level);
|
|
411
425
|
} else {
|
|
426
|
+
if (!stopped) unwatch = watch(restore); // a shutdown that came while Herdr was asked about the workspace has stopped nothing yet
|
|
412
427
|
starting = joinNet().then(async () => {
|
|
413
428
|
const taken = (await claimOpened()) && (await promote(false));
|
|
414
429
|
if (taken && !taken.promoted) tell(taken.text, "warning");
|
|
@@ -441,7 +456,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
441
456
|
if (["subagent", "subagent_resume"].includes(event.toolName) && !isTower && !barred && role()) for (const ms of KID_MARK_MS) setTimeout(() => void publish(), ms).unref();
|
|
442
457
|
});
|
|
443
458
|
|
|
444
|
-
pi.on("session_shutdown", async () => {
|
|
459
|
+
pi.on("session_shutdown", async (event) => {
|
|
445
460
|
if (gui) await switchRemote(gui, false, guiDir, ts).catch(() => {});
|
|
446
461
|
await gui?.stop();
|
|
447
462
|
gui = undefined;
|
|
@@ -451,6 +466,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
451
466
|
timer = undefined;
|
|
452
467
|
clearInterval(nudgeTimer);
|
|
453
468
|
nudges?.stop();
|
|
469
|
+
unwatch?.();
|
|
470
|
+
unwatch = undefined;
|
|
454
471
|
stopped = true; // a refresh still in flight must not write tokens after they are cleared
|
|
455
472
|
await publishing;
|
|
456
473
|
const held = holdsTower;
|
|
@@ -459,6 +476,12 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
459
476
|
await report(run, "pane", at.paneId, { sb_role: null, sb_opened_by: null, sb_order: null, sb_mark: null, sb_indent: null, sb_tab: null }).catch(() => {});
|
|
460
477
|
await clearOrder(run, [...kids]);
|
|
461
478
|
if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
|
|
479
|
+
// A tower that quits leaves its tab as a plain shell: close the tab, but only when this pane is all it holds. A reload, /new, resume or fork keeps the tab.
|
|
480
|
+
if (isTower && event.reason === "quit") {
|
|
481
|
+
const all = await panes(run, at.workspaceId).catch(() => []);
|
|
482
|
+
const tab = all.find((p) => p.pane_id === at.paneId)?.tab_id;
|
|
483
|
+
if (tab && all.every((p) => p.tab_id !== tab || p.pane_id === at.paneId)) await run(["tab", "close", tab]).catch(() => {});
|
|
484
|
+
}
|
|
462
485
|
});
|
|
463
486
|
|
|
464
487
|
// /boss gui is a read-only viewer: any session may open it, and it joins nothing and writes no token. A session outside the net
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createConnection, type Socket } from "node:net";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
|
|
6
|
+
/** Starts watching the Herdr server; `onTakeover` runs each time a server answers again after the one watched went away. Returns the stop function. */
|
|
7
|
+
export type HerdrWatch = (onTakeover: () => void) => () => void;
|
|
8
|
+
|
|
9
|
+
const RETRY_MS = [250, 500, 1000, 2000];
|
|
10
|
+
const ACK_MS = 5000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One `events.subscribe` connection kept open on Herdr's socket, for what its closing says: the server it was open to has stopped, and the
|
|
14
|
+
* tokens it held in memory are gone. The subscription asks for `workspace.renamed`, which almost never happens, so the open connection carries
|
|
15
|
+
* no traffic. When it closes (or the first attempt finds no server) the watch reconnects after 0.25 s, 0.5 s, 1 s, then every 2 s, and when a server
|
|
16
|
+
* acknowledges the subscription it calls `onTakeover` once. The first connection of a watch, which finds the server the session started in, does not.
|
|
17
|
+
* The socket and the retry timer never keep pi alive; `stop` ends both.
|
|
18
|
+
*/
|
|
19
|
+
export const herdrWatch = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock"), retryMs = RETRY_MS): HerdrWatch => (onTakeover) => {
|
|
20
|
+
let socket: Socket | undefined, timer: ReturnType<typeof setTimeout> | undefined, stopped = false, initial = true, retries = 0;
|
|
21
|
+
const attempt = () => {
|
|
22
|
+
const first = initial, text = new StringDecoder("utf8");
|
|
23
|
+
let acked = false, ended = false, head = "";
|
|
24
|
+
initial = false;
|
|
25
|
+
const end = () => {
|
|
26
|
+
if (ended) return;
|
|
27
|
+
ended = true;
|
|
28
|
+
current.destroy();
|
|
29
|
+
if (!stopped) timer = setTimeout(attempt, retryMs[Math.min(retries++, retryMs.length - 1)]).unref();
|
|
30
|
+
};
|
|
31
|
+
const current = (socket = createConnection(socketPath).setTimeout(ACK_MS));
|
|
32
|
+
current.unref();
|
|
33
|
+
current.on("connect", () => current.write(`${JSON.stringify({ id: `straw-boss-watch-${Date.now()}`, method: "events.subscribe", params: { subscriptions: [{ type: "workspace.renamed" }] } })}\n`));
|
|
34
|
+
current.on("data", (chunk) => {
|
|
35
|
+
if (acked || ended) return; // events after the acknowledgement are not wanted: read them off the socket and drop them
|
|
36
|
+
head += text.write(chunk);
|
|
37
|
+
if (!head.includes("\n")) return;
|
|
38
|
+
try {
|
|
39
|
+
if ((JSON.parse(head.split("\n")[0]!) as { result?: { type?: string } }).result?.type !== "subscription_started") return end();
|
|
40
|
+
} catch {
|
|
41
|
+
return end();
|
|
42
|
+
}
|
|
43
|
+
acked = true;
|
|
44
|
+
retries = 0;
|
|
45
|
+
current.setTimeout(0);
|
|
46
|
+
if (!first) onTakeover();
|
|
47
|
+
});
|
|
48
|
+
current.on("timeout", end);
|
|
49
|
+
current.on("error", end);
|
|
50
|
+
current.on("close", end);
|
|
51
|
+
};
|
|
52
|
+
attempt();
|
|
53
|
+
return () => {
|
|
54
|
+
stopped = true;
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
socket?.destroy();
|
|
57
|
+
};
|
|
58
|
+
};
|