pi-straw-boss 0.9.1 → 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/index.ts +20 -3
- 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/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");
|
|
@@ -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;
|
|
@@ -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
|
+
};
|