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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.9.0",
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 marked = (await panes(run, ws)).find((p) => p.tokens?.sb_role === "tower");
171
- if (marked) {
172
- paneId = marked.pane_id;
173
- running = marked.agent === "pi";
174
- await moveFirst(marked.tab_id);
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
- await startPi(run, sleep, `tower-${ws.toLowerCase()}`, paneId, ["--name", `tower · ${workspace.label}`, ...(model ? ["--model", model] : [])]);
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
+ };