pi-straw-boss 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wayne930242
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # pi-straw-boss
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Straw Boss coordination for [pi](https://pi.dev) sessions that run in [Herdr](https://herdr.dev): a session **joins** a small net when its work competes or collaborates with other work in the workspace, the first to join a tab becomes its **main agent**, one **tower** per workspace opens on demand, and `boss_broadcast` reaches the rest. No tracking, no scheduler, no new service: the extension is glue over pi-intercom and Herdr metadata.
4
+
5
+ ## Requirements
6
+
7
+ - pi, running inside a Herdr pane
8
+ - [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer
9
+ - Herdr 0.9.4 or newer
10
+
11
+ Outside Herdr the extension does nothing.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:pi-straw-boss
17
+ ```
18
+
19
+ While developing: `pi install ~/projects/pi-straw-boss` loads the working tree, and `/reload` picks up edits.
20
+
21
+ ## How it works
22
+
23
+ Joining is a choice, never forced.
24
+
25
+ 1. A session that is not in the net gets one short reminder at the start of its first task, asking whether its current work competes with other work in the workspace for resources (ports, databases, files, branches) or needs to collaborate with it.
26
+ 2. If yes, it calls `boss_join` (or the human types `/boss`). It can also join later, whenever it discovers the need. The reminder is not repeated, and a session that never joins is left alone: no prompt, no listing, no broadcasts (except scope `all`).
27
+ 3. The first session of a tab to join is the tab's **main agent**; later joiners are **members**. `/boss` in a member takes the role over.
28
+ 4. Subagents launched by pi-herdr-agents (recognised by `PI_SUBAGENT_ID`) never join. Their parent represents them.
29
+
30
+ | Role | Who | Gets |
31
+ |---|---|---|
32
+ | main agent | first joiner of a tab, or the `/boss` taker-over | a short prompt: split work by app, brief workers, check results, ask the boss before merging or deploying |
33
+ | member | later joiners of a tab | a short prompt naming the main agent and the tower |
34
+ | tower | one per workspace, in its own tab labelled `tower` | a prompt for coordinating agents and repairing coordination friction; it never asks the user |
35
+
36
+ The tower is opened on demand by `call_tower` (or `/boss tower`), without taking focus, and answers through intercom. If two towers ever race in one workspace, the live token holder wins, otherwise the lower tab id, and the other closes its own tab. Towers of different workspaces reach each other with `call_tower`; the **head tower** is the tower of the first workspace in `herdr workspace list` order, so there is no election.
37
+
38
+ ## Tools
39
+
40
+ | Tool | Available to | What it does |
41
+ |---|---|---|
42
+ | `boss_join` | sessions not yet in the net | Join this tab's net; the first joiner is main. |
43
+ | `boss_status` | joined sessions and towers | The main agent of each tab, this workspace's tower, the head tower, and the other towers. |
44
+ | `call_tower` | joined sessions and towers | Send a message to this workspace's tower, or another workspace's by id or exact label. Opens the tower tab when none runs. |
45
+ | `boss_broadcast` | joined sessions and towers | One message to every other session in a scope. `tab`, `workspace` and `towers` reach only sessions that joined the net; `all` reaches every pi session on the machine, joined or not (for notices such as going to sleep). Default `workspace`. |
46
+
47
+ | Command | Effect |
48
+ |---|---|
49
+ | `/boss` | Join if needed, then become this tab's main agent. |
50
+ | `/boss status` | Show `boss_status`. |
51
+ | `/boss tower` | Open this workspace's tower if none runs, then focus its tab. |
52
+
53
+ ## What it writes
54
+
55
+ - Herdr pane token `sb_role` (`main`, `member` or `tower`) and workspace token `tower` (the tower's intercom session id), both refreshed every 4 minutes with a 10 minute TTL and cleared at shutdown.
56
+ - A pi session entry `straw-boss` (`asked`, `joined`, `tower`) so a resumed session keeps its place without a second reminder.
57
+ - One intercom extension channel per tab, `straw-boss/tab/<hex of the tab id>`, registered only by joined sessions and towers. Tab ids over 24 characters are refused.
58
+
59
+ The tower tab is labelled `tower` and its Herdr agent is named `tower-<workspace id>` in lowercase (Herdr agent names are unique per server).
60
+
61
+ ## From Claude Code or Codex
62
+
63
+ Read-only discovery works from any shell in a Herdr pane:
64
+
65
+ ```bash
66
+ herdr workspace get "$HERDR_WORKSPACE_ID" | jq -r '.result.workspace.tokens.tower'
67
+ herdr pane list --workspace "$HERDR_WORKSPACE_ID" | jq -r '.result.panes[] | select(.tokens.sb_role) | [.pane_id, .tokens.sb_role] | @tsv'
68
+ node ~/.pi/agent/npm/node_modules/pi-intercom/cli.mjs ask --to "$TOWER_SESSION_ID" --text "<message>"
69
+ ```
70
+
71
+ Opening a missing tower needs a pi session.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ npm install
77
+ npm run check # tsc --noEmit
78
+ npm test # node --test, against a fake Herdr and a fake intercom broker
79
+ ```
80
+
81
+ Prompts are plain markdown in `prompts/`, read at session start, so an edit applies after `/reload`.
82
+
83
+ ## License
84
+
85
+ MIT
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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, and boss_broadcast reaches the rest",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "wayne930242",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wayne930242/pi-straw-boss.git"
11
+ },
12
+ "homepage": "https://github.com/wayne930242/pi-straw-boss#readme",
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "pi-coding-agent",
17
+ "extension",
18
+ "herdr",
19
+ "intercom",
20
+ "orchestration"
21
+ ],
22
+ "files": [
23
+ "src",
24
+ "prompts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "check": "tsc --noEmit",
35
+ "test": "node --test tests/*.test.ts"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "typebox": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "1.3.0",
43
+ "@types/node": "26.6.5",
44
+ "typebox": "1.3.27",
45
+ "typescript": "7.0.2"
46
+ }
47
+ }
@@ -0,0 +1,16 @@
1
+ # Apps configuration
2
+
3
+ The project's managed apps live in `.straw-boss/apps.json` at the git root, or in `.claude/straw-boss/apps.json` when the first is absent. Without either file, a repository holding one codebase is its own single app; with several plausible apps, ask which one the request targets.
4
+
5
+ ```json
6
+ {"apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"]}]}
7
+ ```
8
+
9
+ | Field | Meaning |
10
+ |---|---|
11
+ | `name`, `dir`, `match` | Required: the app's name, its checkout relative to the git root, and phrases that name it in a request. |
12
+ | `redirectTo`, `note` | A retired app: route new work to the named app and tell the user the `note`. |
13
+ | `forbidDirectCommit` | `true`: changes reach the base branch only through a worktree branch and MR. |
14
+ | `gitWorkflowSkill` | The app's own skill for commit, MR, and release steps; the worker follows it. |
15
+ | `localFiles` | Gitignored files a new worktree needs, each with `path` relative to `dir`; copy them before launch. A `sensitive: true` file is copied only under the user's authorization, and an entry without `optional: true` that is missing blocks the launch. |
16
+ | `crossAppSkills` | `withApp` and `skill`: an existing skill that handles this app depending on `withApp`; name it in the brief. |
@@ -0,0 +1,7 @@
1
+ You are the main agent of Herdr tab {{tab}}: the boss (the user) works with you here. Your workspace tower is {{tower}}.
2
+
3
+ - Split work by owning app ({{dir}}/apps-config.md). A task is one worker's end-to-end work in one app; parallel writers in an app each get a worktree; dependent tasks wait.
4
+ - Brief each worker in English: required behavior, the Reality anchor, the boss's authorizations quoted; leave cause and location to the worker. End with: "When you need a decision these authorizations do not cover, call `caller_ping` and stop." Answer pings through the boss, then `subagent_resume` the worker.
5
+ - Check each result against its brief. Merging to a shared branch or deploying needs the boss's authorization.
6
+ - boss_status finds coordinators, call_tower reaches the tower (report coordination friction there), boss_broadcast broadcasts to the net.
7
+ - Done when every task has landed or is reported blocked and its worktree is removed.
@@ -0,0 +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.
@@ -0,0 +1 @@
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.
@@ -0,0 +1,8 @@
1
+ You are the tower of Herdr workspace {{workspace}}; agents talk to you, the boss does not.
2
+
3
+ Main agents, members, and other towers message you through intercom: answer the sender with intercom send (reply works only for asks).
4
+
5
+ - Coordinate: route each request to the main agent that owns it, relaying with boss_status, call_tower, and boss_broadcast. Towers coordinate through the head tower, the tower of workspace {{head}}.
6
+ - Boss assistant: a friction report gives expected versus actual behavior, the blocked step, and evidence. Find the owner and the broken relationship (registration, endpoint, dependency, shared resource, checkpoint, cleanup); have the owner repair it, read the state back, and have the reporter re-run its step. Return a Straw Boss source defect as a finding with evidence.
7
+ - Never ask the boss (no ask_user, robot_hand, focus changes); decisions for the boss go back to the owning main agent.
8
+ - Do no app work yourself.
package/src/herdr.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export type HerdrRun = (args: string[], timeoutMs?: number) => Promise<string>;
4
+ export type Sleep = (ms: number) => Promise<void>;
5
+ export interface Place { paneId: string; tabId: string; workspaceId: string }
6
+ export interface Workspace { workspace_id: string; label: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string } }
7
+ export interface Pane { pane_id: string; tab_id: string; agent?: string; tokens?: Record<string, string> }
8
+ export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean> }
9
+
10
+ const TTL_MS = 10 * 60_000;
11
+ const START_MS = 30_000;
12
+ const TOWER_WAIT_MS = 60_000;
13
+ const START_ATTEMPTS = 20;
14
+ const opening = new Map<string, Promise<{ id: string; opened: boolean }>>();
15
+
16
+ export const herdrRun: HerdrRun = (args, timeoutMs = 5000) =>
17
+ new Promise((resolve, reject) => {
18
+ execFile(process.env.HERDR_BIN_PATH || "herdr", args, { timeout: timeoutMs }, (error, stdout, stderr) => {
19
+ if (error) reject(new Error(`herdr ${args.join(" ")} failed: ${stderr.trim() || error.message}`));
20
+ else resolve(stdout);
21
+ });
22
+ });
23
+
24
+ // Every herdr reply wraps its payload in `result`.
25
+ async function result(run: HerdrRun, args: string[], timeoutMs?: number) {
26
+ const output = await run(args, timeoutMs);
27
+ try {
28
+ return JSON.parse(output).result;
29
+ } catch {
30
+ throw new Error(`herdr ${args.join(" ")} returned invalid JSON: ${output.slice(0, 200)}`);
31
+ }
32
+ }
33
+
34
+ export async function place(run: HerdrRun): Promise<Place> {
35
+ const pane = (await result(run, ["pane", "current"])).pane;
36
+ return { paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id };
37
+ }
38
+
39
+ export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
40
+
41
+ export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
42
+ return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
43
+ }
44
+
45
+ /** Writes display-and-discovery tokens (null clears one). Herdr takes the positional id before the options. */
46
+ export function report(run: HerdrRun, kind: "pane" | "workspace", id: string, tokens: Record<string, string | null>) {
47
+ const flags = Object.entries(tokens).flatMap(([name, value]) => (value === null ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
48
+ const ttl = flags.includes("--token") ? ["--ttl-ms", String(TTL_MS)] : [];
49
+ return run([kind, "report-metadata", id, "--source", "straw-boss", ...ttl, ...flags]);
50
+ }
51
+
52
+ /** Polls until `check` returns something truthy, or `ms` have passed. */
53
+ export async function waitFor<T>(sleep: Sleep, check: () => T | Promise<T>, ms: number, step = 100): Promise<Awaited<T> | undefined> {
54
+ for (let waited = 0; waited <= ms; waited += step) {
55
+ const value = await check();
56
+ if (value) return value;
57
+ await sleep(step);
58
+ }
59
+ }
60
+
61
+ /** The live tower of a workspace, opening its `tower` tab when there is none. One opening at a time per workspace. */
62
+ export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
63
+ const existing = opening.get(workspace.workspace_id);
64
+ if (existing) return existing;
65
+ const pending = open(env, workspace, cwd).finally(() => opening.delete(workspace.workspace_id));
66
+ opening.set(workspace.workspace_id, pending);
67
+ return pending;
68
+ }
69
+
70
+ async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: string) {
71
+ const ws = workspace.workspace_id;
72
+ const held = async () => {
73
+ const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
74
+ return id && (await live(id)) ? id : undefined;
75
+ };
76
+ const current = await held();
77
+ if (current) return { id: current, opened: false };
78
+
79
+ // A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
80
+ let paneId: string;
81
+ let running = false;
82
+ const tab = (await result(run, ["tab", "list", "--workspace", ws])).tabs.find((t: { label: string }) => t.label === "tower");
83
+ if (tab) {
84
+ const root = (await panes(run, ws)).find((p) => p.tab_id === tab.tab_id);
85
+ if (!root) throw new Error(`Tower tab ${tab.tab_id} has no pane`);
86
+ paneId = root.pane_id;
87
+ running = root.agent === "pi";
88
+ } else {
89
+ const dir = workspace.worktree?.checkout_path ?? cwd;
90
+ const created = await result(run, ["tab", "create", "--workspace", ws, "--label", "tower", "--cwd", dir, "--no-focus", "--env", "STRAW_BOSS_ROLE=tower"]);
91
+ paneId = created.root_pane.pane_id;
92
+ }
93
+ if (!running) {
94
+ // Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
95
+ const start = ["agent", "start", `tower-${ws.toLowerCase()}`, "--kind", "pi", "--pane", paneId, "--timeout", String(START_MS), "--", "--name", `tower · ${workspace.label}`];
96
+ // A new tab's shell may not be at its prompt yet: herdr answers agent_pane_busy until it is, and any other failure is final.
97
+ for (let attempt = 1; ; attempt++) {
98
+ try {
99
+ await run(start, START_MS + 5000);
100
+ break;
101
+ } catch (error) {
102
+ if (attempt === START_ATTEMPTS || !(error as Error).message.includes("agent_pane_busy")) throw error;
103
+ await sleep(500);
104
+ }
105
+ }
106
+ }
107
+ const id = await waitFor(sleep, held, TOWER_WAIT_MS, 1000);
108
+ if (!id) throw new Error(`The tower of ${workspace.label} did not publish its token within ${TOWER_WAIT_MS / 1000} s (pane ${paneId})`);
109
+ return { id, opened: true };
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Type, type Static, type TSchema } from "typebox";
6
+ import { ensureTower, herdrRun, type HerdrRun, type Pane, panes, place, type Place, report, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
7
+
8
+ // pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
9
+ const REGISTER = "intercom:extension-register";
10
+ const OUTBOX = "intercom:outbox-request";
11
+ const OUTBOX_RESULT = "intercom:outbox-result";
12
+ const NAMESPACE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
13
+ const ENTRY = "straw-boss";
14
+ const PROMPTS = ["main", "tower", "member", "reminder"];
15
+ const REFRESH_MS = 4 * 60_000;
16
+ const CLAIM_MS = 5000;
17
+ const CONNECT_MS = 15_000;
18
+ const SEND_MS = 30_000;
19
+ const SETTLE_MS = 2000;
20
+
21
+ interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
22
+ interface Session { id: string; name?: string; herdrLocation?: Location }
23
+ interface Channel {
24
+ snapshot(): { connected: boolean; supported: boolean; owner?: { sessionId: string }; state?: { payload: unknown } };
25
+ publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void;
26
+ commitState(payload: unknown, expectedRevision?: number): void;
27
+ listSessions(): Promise<Session[]>;
28
+ }
29
+ interface SendResult { requestId?: string; status: string; code?: string; detail?: string }
30
+ interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
31
+ type Role = "tower" | "main" | "member";
32
+ type Scope = "tab" | "workspace" | "towers" | "all";
33
+ type Member = ReturnType<typeof netMembers>[number];
34
+ export interface Deps { run?: HerdrRun; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
35
+
36
+ /** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
37
+ function netMembers(sessions: Session[], ps: Pane[]) {
38
+ return sessions.flatMap((s) => {
39
+ const { workspace, tab, paneId } = s.herdrLocation ?? {};
40
+ const role = ps.find((p) => p.pane_id === paneId)?.tokens?.sb_role;
41
+ return workspace && tab && role ? [{ s, role, tab, workspace }] : [];
42
+ });
43
+ }
44
+ const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
45
+
46
+ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)) }: Deps = {}) {
47
+ return function strawBoss(pi: ExtensionAPI): void {
48
+ // Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
49
+ if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
50
+ let isTower = env.STRAW_BOSS_ROLE === "tower";
51
+ let asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
52
+ let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
53
+ let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
54
+ let queue: Promise<void> = Promise.resolve();
55
+ const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
56
+ const role = (): Role | undefined => {
57
+ if (isTower) return "tower";
58
+ if (!joined) return undefined;
59
+ return isMain ? "main" : "member";
60
+ };
61
+ const tell = (text: string, level: "info" | "warning" | "error" = "info") => ui?.notify(text, level);
62
+ const render = (text = "") => text.replace(/\{\{(\w+)\}\}/g, (_, key: string) => ({ dir: promptDir, ...info })[key] || "none yet");
63
+ const tool = <P extends TSchema>(name: string, description: string, parameters: P, body: (params: Static<P>) => Promise<{ text: string; details?: unknown }>) =>
64
+ pi.registerTool({
65
+ name, label: name, description, parameters,
66
+ execute: async (_id, params) => {
67
+ const { text, details } = await body(params);
68
+ return { content: [{ type: "text", text }], details };
69
+ },
70
+ });
71
+ const connect = (eligible: boolean) => {
72
+ const namespace = `straw-boss/tab/${Buffer.from(at.tabId).toString("hex")}`;
73
+ if (!NAMESPACE.test(namespace)) throw new Error(`Tab id ${at.tabId} is too long for an intercom namespace (limit 24 characters)`);
74
+ pi.events.emit(REGISTER, { namespace, ownerEligible: eligible, onReady: (channel: Channel) => (c = channel), onEvent });
75
+ if (!c) throw new Error("pi-intercom is not loaded (Straw Boss needs pi-intercom >= 0.17.1)");
76
+ };
77
+
78
+ // Tokens are display and discovery only: a failed write waits for the next refresh, liveness comes from intercom.
79
+ const publish = async () => {
80
+ const sbRole = role();
81
+ if (sbRole) await report(run, "pane", at.paneId, { sb_role: sbRole }).catch(() => {});
82
+ if (holdsTower) await report(run, "workspace", at.workspaceId, { tower: me() }).catch(() => {});
83
+ };
84
+ const keepFresh = () => {
85
+ timer ??= setInterval(() => void publish(), REFRESH_MS);
86
+ timer.unref();
87
+ };
88
+
89
+ // The owner records who is main. The first joiner pins itself, so a later owner change never swaps the main agent.
90
+ const pin = (main?: string) => {
91
+ if (main && c.snapshot().owner?.sessionId === me()) c.commitState({ v: 1, main, since: Date.now() });
92
+ };
93
+ const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
94
+ if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
95
+ else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
96
+ };
97
+ const refresh = async () => {
98
+ if (!joined) return;
99
+ try {
100
+ const snap = c.snapshot();
101
+ const sessions = await c.listSessions();
102
+ let main = (snap.state?.payload as { main?: string } | undefined)?.main;
103
+ if (!sessions.some((s) => s.id === main)) {
104
+ // The pinned main is gone: the owner is main until it pins itself.
105
+ main = snap.owner?.sessionId;
106
+ pin(main);
107
+ }
108
+ const ws = (await workspaces(run)).find((w) => w.workspace_id === at.workspaceId);
109
+ const tab = sessions.find((s) => s.id === me())?.herdrLocation?.tab?.label ?? at.tabId;
110
+ 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)) };
111
+ const wasMain = isMain;
112
+ isMain = main === me();
113
+ if (isMain !== wasMain) tell(`This session is ${isMain ? "now" : "no longer"} the main agent of ${info.tab}`);
114
+ await publish();
115
+ } catch (error) {
116
+ tell(`Straw Boss: ${(error as Error).message}`, "warning");
117
+ }
118
+ };
119
+ const sync = () => (queue = queue.then(refresh));
120
+ // A session that has just started connects to the broker a moment after session_start.
121
+ const connected = async () => {
122
+ if (await waitFor(sleep, () => c.snapshot().connected && c.snapshot().supported, CONNECT_MS)) return;
123
+ throw new Error(c.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
124
+ };
125
+ const joinNet = async (restored = false) => {
126
+ if (joined) return;
127
+ at = await place(run);
128
+ connect(true);
129
+ await connected();
130
+ if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
131
+ joined = true;
132
+ if (!restored) pi.appendEntry(ENTRY, { asked: true, joined: true });
133
+ addNetTools();
134
+ keepFresh();
135
+ await sync();
136
+ };
137
+ const takeover = async () => {
138
+ await joinNet();
139
+ if (!isMain) {
140
+ if (c.snapshot().owner?.sessionId === me()) pin(me());
141
+ else c.publish({ type: "claim" }, { audience: "owner" });
142
+ if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) throw new Error("The owner did not confirm the takeover within 5 s");
143
+ }
144
+ return `This session is the main agent of tab ${info.tab}`;
145
+ };
146
+ const send = (to: string, message: string) =>
147
+ new Promise<SendResult>((resolve) => {
148
+ const requestId = `straw-boss-${me()}-${++requests}`;
149
+ const done = (result: SendResult) => {
150
+ off();
151
+ clearTimeout(timeout);
152
+ resolve(result);
153
+ };
154
+ const off = pi.events.on(OUTBOX_RESULT, (data) => (data as SendResult).requestId === requestId && done(data as SendResult));
155
+ const timeout = setTimeout(() => done({ status: "failed", detail: `no result within ${SEND_MS / 1000} s` }), SEND_MS);
156
+ pi.events.emit(OUTBOX, { version: 1, requestId, extensionId: "pi-straw-boss", extensionName: "Pi Straw Boss", to, message });
157
+ });
158
+ const openTower = async (query?: string) => {
159
+ const all = await workspaces(run);
160
+ const found = all.filter((w) => (query ? w.workspace_id === query || w.label === query : w.workspace_id === at.workspaceId));
161
+ const [target] = found;
162
+ if (found.length !== 1) {
163
+ throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${query}". Workspaces: ${all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
164
+ }
165
+ if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
166
+ const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
167
+ return { target, ...(await ensureTower({ run, sleep, live }, target, process.cwd())) };
168
+ };
169
+ const status = async () => {
170
+ const sessions = await c.listSessions();
171
+ const [ps, all] = [await panes(run), await workspaces(run)];
172
+ const mine = all.find((w) => w.workspace_id === at.workspaceId);
173
+ const towerOf = (w?: Workspace) => {
174
+ const s = sessions.find((x) => x.id === w?.tokens?.tower);
175
+ return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === all[0] };
176
+ };
177
+ const own = netMembers(sessions, ps).filter((m) => m.workspace.id === at.workspaceId && m.role !== "tower");
178
+ const tabs = Object.values(Object.groupBy(own, (m) => m.tab.id)).map((group = []) => ({
179
+ id: group[0]?.tab.id,
180
+ label: group[0]?.tab.label,
181
+ main: group.find((m) => m.role === "main")?.s.id ?? null,
182
+ sessions: group.length,
183
+ }));
184
+ return {
185
+ me: { id: me(), role: role() },
186
+ workspace: { id: at.workspaceId, label: mine?.label },
187
+ head: all[0]?.workspace_id,
188
+ tower: towerOf(mine) ?? null,
189
+ tabs,
190
+ towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
191
+ };
192
+ };
193
+ const broadcast = async (message: string, scope: Scope) => {
194
+ const others = (await c.listSessions()).filter((s) => s.id !== me());
195
+ const inScope = (m: Member) => ({ tab: m.tab.id === at.tabId, workspace: m.workspace.id === at.workspaceId, towers: m.role === "tower" })[scope as Exclude<Scope, "all">];
196
+ // tab, workspace, and towers reach the net only; all reaches every other pi session on the machine.
197
+ const targets = scope === "all" ? others : netMembers(others, await panes(run)).filter(inScope).map((m) => m.s);
198
+ const results = await Promise.all(targets.map(async (s) => ({ id: s.id, name: nameOf(s), ...(await send(s.id, message)) })));
199
+ const summary = results.map((r) => `${r.name}: ${r.status}${r.code ? ` (${r.code})` : ""}`).join("\n") || "no recipients";
200
+ if (results.some((r) => r.status !== "sent")) throw new Error(`Not every recipient received it:\n${summary}`);
201
+ return { text: `Broadcast to ${results.length} session(s):\n${summary}`, details: { scope, results } };
202
+ };
203
+ function addNetTools() {
204
+ tool("boss_status", "Show this workspace's Straw Boss coordinators: each tab's main agent, this workspace's tower, the head tower, and the other towers.", Type.Object({}), async () => {
205
+ const result = await status();
206
+ return { text: JSON.stringify(result, null, 2), details: result };
207
+ });
208
+ tool("call_tower", "Send a message to the tower of your workspace, or of another workspace by id or exact label. Opens the tower tab when none is running; the tower replies through intercom.",
209
+ Type.Object({
210
+ message: Type.String({ description: "What the tower needs to know or do: at most two sentences, details by reference." }),
211
+ workspace: Type.Optional(Type.String({ description: "Another workspace's id or exact label. Omit for your own." })),
212
+ }),
213
+ async ({ message, workspace }) => {
214
+ const tower = await openTower(workspace);
215
+ const result = await send(tower.id, message);
216
+ if (result.status !== "sent") throw new Error(`The tower of ${tower.target.label} did not take the message: ${result.status} ${result.code ?? ""} ${result.detail ?? ""}`.trim());
217
+ const text = `Sent to the tower of ${tower.target.label}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
218
+ return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
219
+ });
220
+ tool("boss_broadcast", "Send one message to every other session in a scope. tab, workspace, and towers reach only sessions that joined the net; all reaches every pi session on this machine (for notices such as the boss going to sleep).",
221
+ Type.Object({
222
+ message: Type.String({ description: "The message, once; receivers do not re-broadcast it." }),
223
+ scope: Type.Optional(Type.Union([Type.Literal("tab"), Type.Literal("workspace"), Type.Literal("towers"), Type.Literal("all")], { description: "Default workspace." })),
224
+ }),
225
+ ({ message, scope }) => broadcast(message, scope ?? "workspace"));
226
+ }
227
+ const startTower = async () => {
228
+ at = await place(run);
229
+ connect(false);
230
+ await connected();
231
+ if (!saved.tower) pi.appendEntry(ENTRY, { tower: true, workspaceId: at.workspaceId });
232
+ await report(run, "pane", at.paneId, { sb_role: "tower" });
233
+ await sleep(SETTLE_MS);
234
+ const all = await workspaces(run);
235
+ const [ps, sessions] = [await panes(run, at.workspaceId), await c.listSessions()];
236
+ const ws = all.find((w) => w.workspace_id === at.workspaceId);
237
+ const holder = ws?.tokens?.tower;
238
+ const rival = ps.find((p) => p.pane_id !== at.paneId && p.agent === "pi" && p.tokens?.sb_role === "tower");
239
+ // A second tower in one workspace yields before it publishes: the live token holder wins, otherwise the lower tab id.
240
+ if ((holder && holder !== me() && sessions.some((s) => s.id === holder)) || (rival && rival.tab_id < at.tabId)) {
241
+ await report(run, "pane", at.paneId, { sb_role: null });
242
+ await run(["tab", "close", at.tabId]);
243
+ return;
244
+ }
245
+ holdsTower = true;
246
+ info = { workspace: ws?.label ?? at.workspaceId, head: all[0]?.label ?? "" };
247
+ addNetTools();
248
+ keepFresh();
249
+ await publish();
250
+ };
251
+
252
+ pi.on("session_start", (_event, ctx) => {
253
+ ui = ctx.ui;
254
+ prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
255
+ saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
256
+ asked = Boolean(saved.asked);
257
+ isTower ||= Boolean(saved.tower);
258
+ // Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
259
+ let starting: Promise<void> | undefined;
260
+ if (isTower) {
261
+ starting = startTower();
262
+ } else {
263
+ 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 () => {
264
+ await joinNet();
265
+ 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}.`;
266
+ return { text, details: { role: role() } };
267
+ });
268
+ if (saved.joined) starting = joinNet(true);
269
+ }
270
+ starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
271
+ });
272
+
273
+ pi.on("before_agent_start", (event) => {
274
+ const current = role();
275
+ // A session outside the net is asked once, at its first run, whether it should join.
276
+ const first = !current && !asked;
277
+ if (first) {
278
+ asked = true;
279
+ pi.appendEntry(ENTRY, { asked: true });
280
+ }
281
+ let text: string | undefined;
282
+ if (current) text = render(prompts[current]);
283
+ else if (first) text = render(prompts.reminder);
284
+ if (text) event.systemPromptOptions.sections[ENTRY] = text;
285
+ else delete event.systemPromptOptions.sections[ENTRY];
286
+ });
287
+
288
+ pi.on("session_shutdown", async () => {
289
+ clearInterval(timer);
290
+ timer = undefined;
291
+ const held = holdsTower;
292
+ holdsTower = false;
293
+ if (!role() || !at) return;
294
+ await report(run, "pane", at.paneId, { sb_role: null }).catch(() => {});
295
+ if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
296
+ });
297
+
298
+ pi.registerCommand("boss", {
299
+ 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.",
300
+ handler: async (args, ctx) => {
301
+ ui = ctx.ui;
302
+ try {
303
+ if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net. /boss joins it.");
304
+ if (isTower) throw new Error("This session is the tower, not a main agent");
305
+ if (args.trim() !== "tower") return tell(await takeover());
306
+ await joinNet();
307
+ const tower = await openTower();
308
+ const where = (await c.listSessions()).find((s) => s.id === tower.id)?.herdrLocation;
309
+ if (where?.tab) await run(["tab", "focus", where.tab.id]);
310
+ } catch (error) {
311
+ tell((error as Error).message, "error");
312
+ }
313
+ },
314
+ });
315
+ };
316
+ }
317
+
318
+ export default createStrawBoss();