pi-straw-boss 0.9.1 → 0.9.3

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
@@ -60,7 +60,7 @@ Subagents and sessions in Herdr worktree workspaces never join: the session that
60
60
  An optional Herdr plugin in [`herdr-plugin/`](herdr-plugin) turns the sidebar's Agents list into a net view.
61
61
  It lists the head tower's workspace first, in each workspace the tower, then each tab's main agent and members, and every subagent right under the session that launched it, even in a worktree workspace.
62
62
  The view is on whenever the Herdr server starts, and one key switches it off and on again.
63
- pi-straw-boss writes the sort key (`sb_order`), a role label (`sb_mark`) and, on subagents, a tree branch (`sb_indent`) the view and your sidebar rows can use.
63
+ pi-straw-boss writes the sort key (`sb_order`), a role label (`sb_mark`) and, on subagents, a tree branch (`sb_indent`) and the subagent's name (`sb_name`) the view and your sidebar rows can use.
64
64
  It needs Herdr 0.9.3 or newer and node.
65
65
 
66
66
  <p align="center"><img src="docs/sidebar-net-view.png" width="308" alt="Herdr's sidebar in the net view: each workspace's tower with the radio tower glyph first, each main agent on two rows with a star and its tab's label, and every subagent on a tree branch under the session that launched it"></p>
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.3",
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/dashboard.ts CHANGED
@@ -110,7 +110,7 @@ export function subagents(parent: Pane | undefined, all: Pane[], now: number, li
110
110
  script = readdirSync(join(dir, "subagent-scripts")).find((f) => f.endsWith(`-${id}.sh`));
111
111
  file ??= readFileSync(join(dir, "subagent-scripts", script!), "utf8").match(/PI_SUBAGENT_SESSION='([^']+)'/)?.[1];
112
112
  } catch {} // the launch script is only a fallback source for the name and the session file
113
- const name = pane?.label ?? script?.slice(0, -`-${id}.sh`.length) ?? id.slice(0, 8);
113
+ const name = pane?.label || (script?.slice(0, -`-${id}.sh`.length) ?? id.slice(0, 8));
114
114
  const doing = activity.toolActive ? String(activity.toolName) : String(activity.phase ?? "");
115
115
  return { group: "net" as const, depth: 2, mark, text: name, kind: "subagent", workspace: pane?.workspace_id, id, file, pane: pane?.agent === "pi" ? pane.pane_id : undefined, note: { working: doing, waiting: "waiting on a question", done: "finished" }[mark as "working"] };
116
116
  })
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,12 +466,14 @@ 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;
457
474
  holdsTower = false;
458
475
  if (!role() || !at) return;
459
- 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(() => {});
476
+ await report(run, "pane", at.paneId, { sb_role: null, sb_opened_by: null, sb_order: null, sb_mark: null, sb_indent: null, sb_name: null, sb_tab: null }).catch(() => {});
460
477
  await clearOrder(run, [...kids]);
461
478
  if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
462
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.
package/src/order.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { subagents } from "./dashboard.ts";
2
2
  import { ARROW, type HerdrRun, type HerdrTab, isSub, MARK, type Pane, panes, type Place, report, tabs, workspaces, type Workspace } from "./herdr.ts";
3
3
 
4
- // Four display tokens for the sidebar plugin and for Herdr's own Agents rows, written next to `sb_role`:
4
+ // Five display tokens for the sidebar plugin and for Herdr's own Agents rows, written next to `sb_role`:
5
5
  // - `sb_order`: a sort key that lays the net out as the head tower's workspace first, then the other workspaces in Herdr's order, in each the tower
6
6
  // and then the tabs in tab order, in each tab the main agent and then the members, and every subagent right after the session that launched it,
7
7
  // its own subagents after it in turn. Segments are fixed-width and `.`-separated, so plain string order is the numeric order of every segment,
@@ -11,6 +11,7 @@ import { ARROW, type HerdrRun, type HerdrTab, isSub, MARK, type Pane, panes, typ
11
11
  // - `sb_tab`: a member's tab label, which the first row shows in place of Herdr's tab item; a main agent's is in its mark instead.
12
12
  // - `sb_indent`: a tree branch on a subagent alone, like Herdr's Spaces panel draws a worktree: `├─` while a later subagent of the same launcher is marked, `└─` on the last. A row put before the state icon shows it as the
13
13
  // indent under the launcher. A tower, a main agent and a member carry none.
14
+ // - `sb_name`: a subagent's own name, as pi-herdr-agents shows it. Herdr's pane label held it until a new server dropped the label; a row can show the token instead. A subagent alone carries one.
14
15
  // A session writes them for itself and for the subagents it launched, which never join and write nothing, on the same 4 minute refresh as `sb_role`.
15
16
 
16
17
  /** What `sb_indent` holds on a subagent: the branch to it from its launcher, and `last` for the youngest marked sibling. */
@@ -44,16 +45,16 @@ export function descendants(parent: Pane | undefined, key: string, name: string,
44
45
  if (!row.pane || seen.has(row.pane)) return [];
45
46
  seen.add(row.pane);
46
47
  const order = `${key}.${part(i)}`, indent = rows.slice(i + 1).some((r) => r.pane && !seen.has(r.pane)) ? INDENT.more : INDENT.last;
47
- return [{ pane: row.pane, order, mark: `${ARROW} ${short(name)}`, indent }, ...descendants(ps.find((p) => p.pane_id === row.pane), order, row.text, ps, now, seen)];
48
+ return [{ pane: row.pane, order, mark: `${ARROW} ${short(name)}`, indent, name: short(row.text) }, ...descendants(ps.find((p) => p.pane_id === row.pane), order, row.text, ps, now, seen)];
48
49
  });
49
50
  }
50
51
 
51
- /** The subagent panes a session marks: each gets `sb_order`, `sb_mark` and `sb_indent`. */
52
- export interface Kid { pane: string; order: string; mark: string; indent: string }
52
+ /** The subagent panes a session marks: each gets `sb_order`, `sb_mark`, `sb_indent` and `sb_name`. */
53
+ export interface Kid { pane: string; order: string; mark: string; indent: string; name: string }
53
54
 
54
55
  /**
55
56
  * What a session publishes besides its role: its own tokens (null clears them when Herdr cannot place the session, a tower has no mark, and none
56
- * has an indent, which clears the one a pane keeps from when it ran a subagent) and those of the subagents it launched. Reads Herdr only.
57
+ * has an indent or a name, which clears the ones a pane keeps from when it ran a subagent) and those of the subagents it launched. Reads Herdr only.
57
58
  */
58
59
  export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "main" | "member", name: string, now = Date.now()): Promise<{ own: Record<string, string | null>; kids: Kid[]; joined: Set<string> }> {
59
60
  const [ps, all, tabList] = [await panes(run), await workspaces(run), await tabs(run, place.workspaceId)];
@@ -62,7 +63,7 @@ export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "ma
62
63
  // A main agent's tab label goes in its mark, for a row of its own; a member's in `sb_tab`, which stands for the tab item in the first row.
63
64
  const tab = tabList.find((t) => t.tab_id === pane?.tab_id)?.label || null;
64
65
  const mark = role === "tower" ? null : role === "main" ? (tab ? `${MARK.main} ${tab}` : MARK.main) : MARK[role];
65
- return { own: { sb_order: key ?? null, sb_mark: mark, sb_indent: null, sb_tab: role === "member" ? tab : null }, kids: key ? descendants(pane, key, name, ps, now) : [], joined };
66
+ return { own: { sb_order: key ?? null, sb_mark: mark, sb_indent: null, sb_name: null, sb_tab: role === "member" ? tab : null }, kids: key ? descendants(pane, key, name, ps, now) : [], joined };
66
67
  }
67
68
 
68
69
  /**
@@ -70,11 +71,11 @@ export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "ma
70
71
  * (a shell where a subagent ran can start a pi that writes its own). Returns the panes now marked. A failed write waits for the next refresh.
71
72
  */
72
73
  export async function markKids(run: HerdrRun, kids: Kid[], before: Set<string>, joined = new Set<string>()): Promise<Set<string>> {
73
- for (const kid of kids) await report(run, "pane", kid.pane, { sb_order: kid.order, sb_mark: kid.mark, sb_indent: kid.indent }).catch(() => {});
74
+ for (const kid of kids) await report(run, "pane", kid.pane, { sb_order: kid.order, sb_mark: kid.mark, sb_indent: kid.indent, sb_name: kid.name }).catch(() => {});
74
75
  const marked = new Set(kids.map((k) => k.pane));
75
76
  await clearOrder(run, [...before].filter((id) => !marked.has(id) && !joined.has(id)));
76
77
  return marked;
77
78
  }
78
79
 
79
- /** Removes the three tokens from panes, ignoring a pane that has already closed. */
80
- export const clearOrder = (run: HerdrRun, paneIds: string[]) => Promise.all(paneIds.map((id) => report(run, "pane", id, { sb_order: null, sb_mark: null, sb_indent: null }).catch(() => {})));
80
+ /** Removes the four tokens from panes, ignoring a pane that has already closed. */
81
+ export const clearOrder = (run: HerdrRun, paneIds: string[]) => Promise.all(paneIds.map((id) => report(run, "pane", id, { sb_order: null, sb_mark: null, sb_indent: null, sb_name: null }).catch(() => {})));
@@ -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
+ };