pi-straw-boss 0.4.0 → 0.4.1

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
@@ -12,9 +12,9 @@
12
12
 
13
13
  Run a few agents on the same project and they start stepping on each other: two dev servers on one port, two branches editing the same migration, and no agent knows the others exist. pi-straw-boss lets them find each other and coordinate, without a scheduler or a new service.
14
14
 
15
- - **Opt-in net.** Each session is asked once whether its work shares resources or needs to collaborate. Only those that say yes join.
16
- - **A main agent per tab.** The first to join a tab coordinates it; `/boss` takes over.
17
- - **A tower per workspace.** It opens on demand in its own tab, pinned first, and coordinates the main agents and the other towers. You don't need to talk to it.
15
+ - **Everyone joins.** Each pi in Herdr joins its tab's net at start as a member; nothing to ask or remember.
16
+ - **A main agent per tab, by promotion.** An agent whose work grows calls `boss_promote`, or is promoted when it first dispatches a subagent. A tab has one main, and nobody takes it over except you with `/boss`.
17
+ - **A tower per workspace.** It opens with the workspace's first main in its own tab, pinned first, and coordinates the main agents and the other towers. You don't need to talk to it.
18
18
  - **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
19
19
  - **A browser view** of the whole net, `/boss gui`: every workspace as a base with its tower, agents, and subagents, animated and read-only. Click a base to zoom in, click a unit to read its chat as a live comms log; units that wait on you queue up as alerts.
20
20
  - **Broadcast** to a tab, a workspace, every tower, or every session.
@@ -30,7 +30,7 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
30
30
 
31
31
  ## Quick start
32
32
 
33
- 1. Open pi in a Herdr tab and give it work. When the work needs to share or coordinate, the agent joins the net. You can also type `/boss`.
33
+ 1. Open pi in a Herdr tab and give it work. It joins the net by itself; when its work grows it promotes itself to the tab's main agent, which also opens the workspace tower. You can take over with `/boss`.
34
34
  2. Type `/boss status` to see each tab's main agent and the towers.
35
35
  3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
36
36
  4. Type `/boss gui` from any session to see the whole net in your browser.
@@ -39,13 +39,13 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
39
39
 
40
40
  | Name | What it does |
41
41
  |---|---|
42
- | `boss_join` | Join this tab's net. |
42
+ | `boss_promote` | Become this tab's main agent (refused while it has one). |
43
43
  | `boss_status` | Main agents, this workspace's tower, and the other towers. |
44
44
  | `call_tower` | Message this workspace's tower, or another workspace's. |
45
45
  | `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
46
- | `/boss` | Join and become this tab's main agent. |
46
+ | `/boss` | Take over as this tab's main agent. |
47
47
  | `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
48
- | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server. |
48
+ | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session); stop its server. |
49
49
 
50
50
  Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
51
51
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.4.0",
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",
3
+ "version": "0.4.1",
4
+ "description": "Straw Boss coordination for pi in Herdr: every pi in Herdr joins its tab's net, a session promotes itself to main agent when its work grows, the workspace tower opens with the first main and has a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "wayne930242",
@@ -0,0 +1 @@
1
+ Straw Boss: you are a member of tab {{tab}}, which has no main agent yet. When your work grows, meaning several steps, dispatching workers, or touching more than one app, call `boss_promote` to become the tab's main agent; dispatching a subagent promotes you automatically. Report coordination friction, such as an agent, endpoint, or shared resource you cannot reach, with call_tower.
package/prompts/member.md CHANGED
@@ -1 +1 @@
1
- Straw Boss: you joined the net of tab {{tab}}, whose main agent is {{main}}. The tower of workspace {{workspace}} is {{tower}}. Take shared-resource and collaboration questions to the main agent through intercom. Report coordination friction, such as an agent, endpoint, or shared resource you cannot reach, with call_tower.
1
+ Straw Boss: you are a member of tab {{tab}}, whose main agent is {{main}}. The tower of workspace {{workspace}} is {{tower}}. Before you dispatch a subagent, tell {{main}} through intercom what you will do and which resources (ports, databases, files, branches) it uses; take other shared-resource questions there too. Report coordination friction, such as an agent, endpoint, or shared resource you cannot reach, with call_tower.
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ const OUTBOX = "intercom:outbox-request";
15
15
  const OUTBOX_RESULT = "intercom:outbox-result";
16
16
  const NAMESPACE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
17
17
  const ENTRY = "straw-boss";
18
- const PROMPTS = ["main", "tower", "member", "reminder"];
18
+ const PROMPTS = ["main", "tower", "member", "member-no-main"];
19
19
  const REFRESH_MS = 4 * 60_000;
20
20
  const CLAIM_MS = 5000;
21
21
  const CONNECT_MS = 15_000;
@@ -29,11 +29,11 @@ interface Channel {
29
29
  listSessions(): Promise<Session[]>;
30
30
  }
31
31
  interface SendResult { requestId?: string; status: string; code?: string; detail?: string }
32
- interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
32
+ interface Saved { tower?: boolean }
33
33
  type Role = "tower" | "main" | "member";
34
34
  type Scope = "tab" | "workspace" | "towers" | "all";
35
35
  type Member = ReturnType<typeof netMembers>[number];
36
- export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string }
36
+ export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string; autoTower?: boolean }
37
37
 
38
38
  /** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
39
39
  function netMembers(sessions: Session[], ps: Pane[]) {
@@ -45,12 +45,14 @@ function netMembers(sessions: Session[], ps: Pane[]) {
45
45
  }
46
46
  const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
47
47
 
48
- export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir }: Deps = {}) {
48
+ export function createStrawBoss({ run = herdrRun, api = herdrApi(), 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 }: Deps = {}) {
49
49
  return function strawBoss(pi: ExtensionAPI): void {
50
50
  // Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
51
51
  if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
52
52
  let isTower = env.STRAW_BOSS_ROLE === "tower";
53
- let asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
53
+ let joined = false, isMain = false, holdsTower = false, requests = 0;
54
+ // The tab's live main agent as of the last refresh, and the main this session pinned as owner before the broker echoes it back.
55
+ let mainId: string | undefined, pinned: string | undefined, joining: Promise<void> | undefined;
54
56
  // Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
55
57
  let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
56
58
  let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
@@ -75,6 +77,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
75
77
  },
76
78
  });
77
79
  const connect = (eligible: boolean) => {
80
+ if (c) return; // a join that failed after registering retries on the channel it has
78
81
  const namespace = `straw-boss/tab/${Buffer.from(at.tabId).toString("hex")}`;
79
82
  if (!NAMESPACE.test(namespace)) throw new Error(`Tab id ${at.tabId} is too long for an intercom namespace (limit 24 characters)`);
80
83
  pi.events.emit(REGISTER, { namespace, ownerEligible: eligible, onReady: (channel: Channel) => (c = channel), onEvent });
@@ -92,33 +95,53 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
92
95
  timer.unref();
93
96
  };
94
97
 
95
- // The owner records who is main. The first joiner pins itself, so a later owner change never swaps the main agent.
96
- const pin = (main?: string) => {
97
- if (main && c.snapshot().owner?.sessionId === me()) c.commitState({ v: 1, main, since: Date.now() });
98
+ // The owner records who is main. Promotion is first come, first served, and the main stays until it leaves: a later owner change never swaps it.
99
+ const pin = (main: string) => {
100
+ if (c.snapshot().owner?.sessionId !== me()) return;
101
+ c.commitState({ v: 1, main, since: Date.now() });
102
+ pinned = main;
98
103
  };
99
- const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
100
- if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
101
- else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
104
+ // The owner decides a claim: a tab has one main, so a claim is dropped while a live main exists, unless the user forced it with /boss.
105
+ const claim = async (from: string, force: boolean) => {
106
+ try {
107
+ if (c.snapshot().owner?.sessionId !== me()) return;
108
+ const current = pinned ?? (c.snapshot().state?.payload as { main?: string } | undefined)?.main;
109
+ if (!force && current && current !== from && (await c.listSessions()).some((s) => s.id === current)) return;
110
+ pin(from);
111
+ } catch (error) {
112
+ tell(`Straw Boss: ${(error as Error).message}`, "warning");
113
+ }
114
+ };
115
+ const onEvent = (event: { type: string; fromSessionId?: string; owner?: { sessionId: string }; payload?: { type?: string; force?: boolean } }) => {
116
+ // Ownership moved away: what this session pinned as owner is no longer its to defend.
117
+ if (event.type === "owner" && event.owner?.sessionId !== me()) pinned = undefined;
118
+ // A join that failed at start (intercom not connected yet, no owner yet) is retried when the channel changes.
119
+ if (!joined && !barred && !isTower && c && ["connection", "owner"].includes(event.type)) void joinNet().catch(() => {});
120
+ if (event.type === "message" && event.payload?.type === "claim" && event.fromSessionId) {
121
+ const [from, force] = [event.fromSessionId, Boolean(event.payload.force)];
122
+ queue = queue.then(() => claim(from, force));
123
+ } else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
102
124
  if (["presence_update", "session_joined", "session_left"].includes(event.type)) dashboard?.poke();
103
125
  };
104
126
  const refresh = async () => {
105
127
  if (!joined) return;
106
128
  try {
107
129
  const snap = c.snapshot();
130
+ if (snap.owner?.sessionId !== me()) pinned = undefined;
108
131
  const sessions = await c.listSessions();
109
- let main = (snap.state?.payload as { main?: string } | undefined)?.main;
110
- if (!sessions.some((s) => s.id === main)) {
111
- // The pinned main is gone: the owner is main until it pins itself.
112
- main = snap.owner?.sessionId;
113
- pin(main);
114
- }
132
+ // A main that has left leaves the tab without one until a member promotes.
133
+ const recorded = (snap.state?.payload as { main?: string } | undefined)?.main;
134
+ if (pinned === recorded) pinned = undefined; // the broker has echoed it
135
+ mainId = sessions.find((s) => s.id === (pinned ?? recorded))?.id;
115
136
  const ws = (await workspaces(run)).find((w) => w.workspace_id === at.workspaceId);
116
137
  const tab = sessions.find((s) => s.id === me())?.herdrLocation?.tab?.label ?? at.tabId;
117
- info = { tab, workspace: ws?.label ?? at.workspaceId, main: nameOf(sessions.find((s) => s.id === main)), tower: nameOf(sessions.find((s) => s.id === ws?.tokens?.tower)) };
138
+ info = { tab, workspace: ws?.label ?? at.workspaceId, main: nameOf(sessions.find((s) => s.id === mainId)), tower: nameOf(sessions.find((s) => s.id === ws?.tokens?.tower)) };
118
139
  const wasMain = isMain;
119
- isMain = main === me();
140
+ isMain = mainId === me();
120
141
  if (isMain !== wasMain) tell(`This session is ${isMain ? "now" : "no longer"} the main agent of ${info.tab}`);
121
142
  await publish();
143
+ // The workspace's first main opens the tower at once; later mains find it open.
144
+ if (isMain && !wasMain && autoTower) void openTower().catch((error: Error) => tell(`Straw Boss: could not open the tower: ${error.message}`, "warning"));
122
145
  } catch (error) {
123
146
  tell(`Straw Boss: ${(error as Error).message}`, "warning");
124
147
  }
@@ -129,27 +152,40 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
129
152
  if (await waitFor(sleep, () => channel.snapshot().connected && channel.snapshot().supported, CONNECT_MS)) return;
130
153
  throw new Error(channel.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
131
154
  };
132
- const joinNet = async (restored = false) => {
133
- if (joined) return;
134
- if (barred) throw new Error(barred);
135
- at = await place(run);
136
- connect(true);
137
- await connected();
138
- if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
139
- joined = true;
140
- if (!restored) pi.appendEntry(ENTRY, { asked: true, joined: true });
141
- addNetTools();
142
- keepFresh();
143
- await sync();
144
- };
145
- const takeover = async () => {
155
+ // Every joinable session joins at start as a member (owner-eligible, so the tab channel always has an owner). One join at a time.
156
+ const joinNet = () =>
157
+ (joining ??= (async () => {
158
+ if (joined) return;
159
+ if (barred) throw new Error(barred);
160
+ at = await place(run);
161
+ connect(true);
162
+ await connected();
163
+ if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
164
+ joined = true;
165
+ addNetTools();
166
+ addPromote();
167
+ keepFresh();
168
+ await sync();
169
+ })().catch((error) => {
170
+ joining = undefined;
171
+ throw error;
172
+ }));
173
+ // Becoming the tab's main agent. Without `force` a tab that already has a live main refuses; /boss forces.
174
+ const promote = async (force: boolean): Promise<{ promoted: boolean; text: string }> => {
146
175
  await joinNet();
147
- if (!isMain) {
148
- if (c.snapshot().owner?.sessionId === me()) pin(me());
149
- else c.publish({ type: "claim" }, { audience: "owner" });
150
- if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) throw new Error("The owner did not confirm the takeover within 5 s");
176
+ await sync();
177
+ if (isMain) return { promoted: true, text: `You are already the main agent of tab ${info.tab}.` };
178
+ const refused = () => ({ promoted: false, text: `Tab ${info.tab} already has a main agent, ${info.main}. Stay a member: you may dispatch your own subagents, but tell ${info.main} through intercom first what you will do and which resources you will use.` });
179
+ if (!force && mainId) return refused();
180
+ if (c.snapshot().owner?.sessionId === me()) await (queue = queue.then(() => claim(me(), force)));
181
+ else c.publish({ type: "claim", force }, { audience: "owner" });
182
+ if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) {
183
+ await sync();
184
+ if (isMain) return { promoted: true, text: `You are now the main agent of tab ${info.tab}.` };
185
+ if (!force && mainId && mainId !== me()) return refused();
186
+ throw new Error("The owner did not confirm the promotion within 5 s");
151
187
  }
152
- return `This session is the main agent of tab ${info.tab}`;
188
+ return { promoted: true, text: `You are now the main agent of tab ${info.tab}.` };
153
189
  };
154
190
  const send = (to: string, message: string) =>
155
191
  new Promise<SendResult>((resolve) => {
@@ -237,6 +273,13 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
237
273
  }),
238
274
  ({ message, scope }) => broadcast(message, scope ?? "workspace"));
239
275
  }
276
+ function addPromote() {
277
+ tool("boss_promote", "Become the main agent of this Herdr tab when your work grows: several steps, dispatching workers, or touching more than one app. Refused when the tab already has a main agent.", Type.Object({}), async () => {
278
+ const { promoted, text } = await promote(false);
279
+ if (!promoted) throw new Error(text);
280
+ return { text, details: { role: role() } };
281
+ });
282
+ }
240
283
  const startTower = async () => {
241
284
  at = await place(run);
242
285
  connect(false);
@@ -281,7 +324,6 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
281
324
  hasUI = ctx.hasUI;
282
325
  prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
283
326
  saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
284
- asked = Boolean(saved.asked);
285
327
  isTower ||= Boolean(saved.tower);
286
328
  // Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
287
329
  let starting: Promise<void> | undefined;
@@ -293,12 +335,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
293
335
  if (bar) {
294
336
  tell(bar.text, bar.level);
295
337
  } else {
296
- tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
297
- await joinNet();
298
- const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
299
- return { text, details: { role: role() } };
300
- });
301
- if (saved.joined) starting = joinNet(true);
338
+ starting = joinNet();
302
339
  }
303
340
  }
304
341
  starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
@@ -306,19 +343,22 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
306
343
 
307
344
  pi.on("before_agent_start", (event) => {
308
345
  const current = role();
309
- // A session outside the net is asked once, at its first run, whether it should join.
310
- const first = !current && !asked && !barred;
311
- if (first) {
312
- asked = true;
313
- pi.appendEntry(ENTRY, { asked: true });
314
- }
315
- let text: string | undefined;
316
- if (current) text = render(prompts[current]);
317
- else if (first) text = render(prompts.reminder);
318
- if (text) event.systemPromptOptions.sections[ENTRY] = text;
346
+ const name = current === "member" && !mainId ? "member-no-main" : current;
347
+ if (name) event.systemPromptOptions.sections[ENTRY] = render(prompts[name]);
319
348
  else delete event.systemPromptOptions.sections[ENTRY];
320
349
  });
321
350
 
351
+ // A member that dispatches a subagent without promoting is promoted here, before the subagent starts, unless the tab already has a main.
352
+ pi.on("tool_call", async (event) => {
353
+ if (event.toolName !== "subagent" || isTower || barred) return;
354
+ try {
355
+ await joinNet();
356
+ if (!isMain) await promote(false);
357
+ } catch (error) {
358
+ tell(`Straw Boss: ${(error as Error).message}`, "warning");
359
+ }
360
+ });
361
+
322
362
  pi.on("session_shutdown", async () => {
323
363
  await gui?.stop();
324
364
  gui = undefined;
@@ -367,14 +407,14 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
367
407
  };
368
408
 
369
409
  pi.registerCommand("boss", {
370
- description: "Join this tab's Straw Boss net and become its main agent. /boss status shows the coordinators; /boss tower opens and focuses the workspace tower; /boss gui opens the net in the browser (/boss gui stop closes it).",
410
+ description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower opens and focuses the workspace tower; /boss gui opens the net in the browser (/boss gui stop closes it).",
371
411
  handler: async (args, ctx) => {
372
412
  ui = ctx.ui;
373
413
  try {
374
414
  if (/^gui( stop)?$/.test(args.trim())) return await guiCommand(args.trim() === "gui stop");
375
- if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net. /boss joins it.");
415
+ if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net: still joining, or this session cannot join.");
376
416
  if (isTower) throw new Error("This session is the tower, not a main agent");
377
- if (args.trim() !== "tower") return tell(await takeover());
417
+ if (args.trim() !== "tower") return tell((await promote(true)).text);
378
418
  await joinNet();
379
419
  const tower = await openTower();
380
420
  const where = (await c.listSessions()).find((s) => s.id === tower.id)?.herdrLocation;
@@ -1 +0,0 @@
1
- Straw Boss: before you start, decide whether your current work competes with other work in this Herdr workspace for resources (ports, databases, files, branches) or needs to collaborate with it. If so, call `boss_join` now; you become the coordinator of this tab, or a member if another session already is. If not, ignore this and do not mention it. You can still call `boss_join` later if the need appears.