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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.9.1",
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
+ };