pi-straw-boss 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +63 -2
- package/package.json +47 -4
- package/prompts/apps-config.md +16 -0
- package/prompts/main.md +7 -0
- package/prompts/member.md +1 -0
- package/prompts/reminder.md +1 -0
- package/prompts/tower.md +8 -0
- package/src/dashboard.ts +178 -0
- package/src/herdr.ts +144 -0
- package/src/index.ts +349 -0
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,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-straw-boss
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Many pi agents, one project, no collisions.** pi-straw-boss gives the agents you run across [Herdr](https://herdr.dev) tabs a light chain of command: a main agent per tab, a tower per workspace, and a live view of who is doing what.
|
|
4
|
+
|
|
5
|
+
<!-- /boss gui screenshot goes here -->
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
╭─ ⠸ tower · Alpha ─────────────────────────────────────────── head tower ─╮
|
|
9
|
+
│ this net │
|
|
10
|
+
│ ├─ tab api-main │
|
|
11
|
+
│ │ ├─ ○ api-main main │
|
|
12
|
+
│ │ │ └─ ⠏ psb-sleeper subagent bash │
|
|
13
|
+
│ │ └─ ○ docs-member member │
|
|
14
|
+
│ └─ tab other │
|
|
15
|
+
│ └─ ? ops-main main waiting on a question │
|
|
16
|
+
│ other towers │
|
|
17
|
+
│ ✓ Beta tower │
|
|
18
|
+
╰──────────────────────────────────────────────────────────────────────────╯
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
- **Opt-in net.** Each session is asked once whether its work shares resources or needs to collaborate. Only those that say yes join.
|
|
26
|
+
- **A main agent per tab.** The first to join a tab coordinates it; `/boss` takes over.
|
|
27
|
+
- **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.
|
|
28
|
+
- **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
|
|
29
|
+
- **Broadcast** to a tab, a workspace, every tower, or every session.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:pi-straw-boss
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer. [pi-herdr-agents](https://github.com/giuseppecrj/pi-herdr-agents) is optional: with it, subagents show up under the session that launched them.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
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`.
|
|
42
|
+
2. Type `/boss status` to see each tab's main agent and the towers.
|
|
43
|
+
3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
|
|
44
|
+
|
|
45
|
+
## Tools and commands
|
|
46
|
+
|
|
47
|
+
| Name | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `boss_join` | Join this tab's net. |
|
|
50
|
+
| `boss_status` | Main agents, this workspace's tower, and the other towers. |
|
|
51
|
+
| `call_tower` | Message this workspace's tower, or another workspace's. |
|
|
52
|
+
| `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
|
|
53
|
+
| `/boss` | Join and become this tab's main agent. |
|
|
54
|
+
| `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
|
|
55
|
+
|
|
56
|
+
Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
|
|
57
|
+
|
|
58
|
+
## Learn more
|
|
59
|
+
|
|
60
|
+
[How it works](docs/how-it-works.md) covers roles, the tower, the dashboard, what the extension writes, and how Claude Code or Codex agents can find a tower.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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",
|
|
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
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
44
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
45
|
+
"@types/node": "26.6.5",
|
|
46
|
+
"typebox": "1.3.27",
|
|
47
|
+
"typescript": "7.0.2"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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. |
|
package/prompts/main.md
ADDED
|
@@ -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.
|
package/prompts/tower.md
ADDED
|
@@ -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/dashboard.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
|
|
6
|
+
|
|
7
|
+
// The tower's monitoring screen: a widget above the editor, never focused, redrawn only while something is working.
|
|
8
|
+
//
|
|
9
|
+
// Data sources, all read-only:
|
|
10
|
+
// - pi-intercom listSessions(): every live session, its status (idle, thinking, tool:<name>), and its Herdr location.
|
|
11
|
+
// presence_update, session_joined, and session_left events on the tower's channel only trigger an early re-read.
|
|
12
|
+
// - `herdr pane list`: per pane the `sb_role` token (main, member), the pi session file, and Herdr's agent_status
|
|
13
|
+
// (blocked means the agent waits on a question); `herdr workspace list`: workspace status, `tower` token, worktree.
|
|
14
|
+
// - pi-herdr-agents subagents: a child's activity file lives at <sessions dir>/artifacts/<parent session id>/
|
|
15
|
+
// subagent-activity/<child id>.json, and its pane's session file name starts with `<timestamp>_<child id>`.
|
|
16
|
+
// The parent pane's own session file names the artifacts directory, so children group under the session that launched them.
|
|
17
|
+
|
|
18
|
+
export type Mark = "working" | "waiting" | "idle" | "done" | "error" | "unknown";
|
|
19
|
+
/** One line of the tree. `net` rows are tab (0) > session (1) > subagent (2); `towers` rows are flat. */
|
|
20
|
+
export interface Row { group: "net" | "towers"; depth: number; mark: Mark; text: string; kind?: string; note?: string; workspace?: string }
|
|
21
|
+
export interface Snapshot { title: string; mark: Mark; head: boolean; rows: Row[] }
|
|
22
|
+
export type Tone = "accent" | "warning" | "muted" | "success" | "dim" | "error" | "border" | "title";
|
|
23
|
+
export type Paint = (tone: Tone, text: string) => string;
|
|
24
|
+
export interface Sources { run: HerdrRun; sessions(): Promise<Session[]>; workspaceId: string; now?: number }
|
|
25
|
+
|
|
26
|
+
const KEY = "straw-boss-dashboard";
|
|
27
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
28
|
+
// One glyph and one theme color per state; a working row shows the animated spinner instead of a fixed glyph.
|
|
29
|
+
const GLYPH: Record<Exclude<Mark, "working">, string> = { waiting: "?", idle: "○", done: "✓", error: "✗", unknown: "·" };
|
|
30
|
+
const TONE: Record<Mark, Tone> = { working: "accent", waiting: "warning", idle: "dim", done: "success", error: "error", unknown: "muted" };
|
|
31
|
+
const HERDR_MARK: Record<string, Mark> = { working: "working", blocked: "waiting", idle: "idle", done: "done" };
|
|
32
|
+
const RECENT_MS = 10 * 60_000;
|
|
33
|
+
const MAX_ROWS = 18;
|
|
34
|
+
const MAX_WIDTH = 76;
|
|
35
|
+
const MAX_CHILDREN = 8;
|
|
36
|
+
|
|
37
|
+
const markOf = (status?: string): Mark => HERDR_MARK[status ?? ""] ?? "unknown";
|
|
38
|
+
const pad = (text: string, width: number) => text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
39
|
+
|
|
40
|
+
/** The pure half: a snapshot in, a framed tree out. One shared spinner frame animates every working row. */
|
|
41
|
+
export function render({ title, mark, head, rows }: Snapshot, frame: number, width: number, paint: Paint): string[] {
|
|
42
|
+
const total = Math.min(width, MAX_WIDTH), inner = Math.max(8, total - 4);
|
|
43
|
+
const glyph = (m: Mark) => paint(TONE[m], m === "working" ? SPINNER[frame % SPINNER.length]! : GLYPH[m as Exclude<Mark, "working">]);
|
|
44
|
+
const shown = rows.slice(0, MAX_ROWS);
|
|
45
|
+
// A row is the last child when the next row at its depth or above is shallower (or there is none).
|
|
46
|
+
const last = (i: number) => i < 0 || shown.slice(i + 1).find((r) => r.group === shown[i]!.group && r.depth <= shown[i]!.depth)?.depth !== shown[i]!.depth;
|
|
47
|
+
const ancestor = (i: number, depth: number) => shown.findLastIndex((r, k) => k < i && r.group === shown[i]!.group && r.depth === depth);
|
|
48
|
+
const prefix = (i: number) => (shown[i]!.group === "net" ? Array.from({ length: shown[i]!.depth }, (_, d) => (last(ancestor(i, d)) ? " " : "│ ")).join("") + (last(i) ? "└─ " : "├─ ") : "");
|
|
49
|
+
const cells = shown.map((r, i) => paint("border", prefix(i)) + (r.mark === "unknown" && !r.kind ? "" : `${glyph(r.mark)} `) + r.text);
|
|
50
|
+
const [w1, w2] = [Math.max(...cells.map(visibleWidth), 0), Math.max(...shown.map((r) => r.kind?.length ?? 0))];
|
|
51
|
+
const line = (i: number) => `${pad(cells[i]!, w1)} ${paint("muted", (shown[i]!.kind ?? "").padEnd(w2))} ${paint("dim", shown[i]!.note ?? "")}`.trimEnd();
|
|
52
|
+
const section = (group: Row["group"], name: string, empty?: string) => {
|
|
53
|
+
const at = shown.flatMap((r, i) => (r.group === group ? [i] : []));
|
|
54
|
+
return at.length || empty ? [paint("muted", name), ...(at.length ? at.map(line) : [paint("dim", ` ${empty}`)])] : [];
|
|
55
|
+
};
|
|
56
|
+
const body = [...section("net", "this net", "nothing has joined yet"), ...section("towers", "other towers"), ...(rows.length > MAX_ROWS ? [paint("muted", `… ${rows.length - MAX_ROWS} more`)] : [])];
|
|
57
|
+
const [name, tag] = [` ${glyph(mark)} ${paint("title", `tower · ${title}`)} `, head ? ` ${paint("accent", "head tower")} ` : ""];
|
|
58
|
+
const dashes = "─".repeat(Math.max(1, total - 4 - visibleWidth(name) - visibleWidth(tag)));
|
|
59
|
+
const frameLine = (text: string) => paint("border", "│ ") + pad(truncateToWidth(text, inner), inner) + paint("border", " │");
|
|
60
|
+
return [paint("border", "╭─") + name + paint("border", dashes) + tag + paint("border", "─╮"), ...body.map(frameLine), paint("border", `╰${"─".repeat(total - 2)}╯`)].map((l) => truncateToWidth(l, width));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readJson(path: string): Record<string, unknown> {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. */
|
|
72
|
+
function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
|
|
73
|
+
const file = parent?.agent_session?.value;
|
|
74
|
+
if (!file) return [];
|
|
75
|
+
const dir = join(dirname(file), "artifacts", basename(file, ".jsonl").split("_").pop()!);
|
|
76
|
+
let files: string[] = [];
|
|
77
|
+
try {
|
|
78
|
+
files = readdirSync(join(dir, "subagent-activity"));
|
|
79
|
+
} catch {} // no subagents were ever launched from this session
|
|
80
|
+
return files
|
|
81
|
+
.map((name) => {
|
|
82
|
+
const id = name.replace(/\.json$/, "");
|
|
83
|
+
const path = join(dir, "subagent-activity", name);
|
|
84
|
+
return { id, path, pane: all.find((p) => p.agent_session?.value.includes(`_${id}`)), age: now - statSync(path).mtimeMs };
|
|
85
|
+
})
|
|
86
|
+
.filter((c) => c.pane || c.age < RECENT_MS)
|
|
87
|
+
.map(({ id, path, pane }) => {
|
|
88
|
+
const activity = readJson(path);
|
|
89
|
+
// A child whose pane is gone, or back at a shell prompt, has finished.
|
|
90
|
+
const mark: Mark = pane?.agent === "pi" ? markOf(pane.agent_status) : "done";
|
|
91
|
+
let script: string | undefined;
|
|
92
|
+
try {
|
|
93
|
+
script = readdirSync(join(dir, "subagent-scripts")).find((f) => f.endsWith(`-${id}.sh`));
|
|
94
|
+
} catch {} // the launch script is only a fallback source for the name
|
|
95
|
+
const name = pane?.label ?? script?.slice(0, -`-${id}.sh`.length) ?? id.slice(0, 8);
|
|
96
|
+
const doing = activity.toolActive ? String(activity.toolName) : String(activity.phase ?? "");
|
|
97
|
+
return { group: "net" as const, depth: 2, mark, text: name, kind: "subagent", workspace: pane?.workspace_id, note: { working: doing, waiting: "waiting on a question", done: "finished" }[mark as "working"] };
|
|
98
|
+
})
|
|
99
|
+
.sort((a, b) => Number(a.mark === "done") - Number(b.mark === "done") || a.text.localeCompare(b.text))
|
|
100
|
+
.slice(0, MAX_CHILDREN);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One reading of the world as rows: this tower's own net in detail (each tab's joined sessions with their subagents, then its
|
|
105
|
+
* sub workspaces), and one summary line for every other tower. Other workspaces' sessions, and sessions that did not join, are not listed.
|
|
106
|
+
*/
|
|
107
|
+
export async function collect({ run, sessions, workspaceId, now = Date.now() }: Sources): Promise<Snapshot> {
|
|
108
|
+
const [live, ps, all] = [await sessions(), await panes(run), await workspaces(run)];
|
|
109
|
+
const head = headOf(all);
|
|
110
|
+
const mine = all.find((w) => w.workspace_id === workspaceId);
|
|
111
|
+
const sessionRow = (s: Session, role: string): Row => {
|
|
112
|
+
const pane = ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
|
|
113
|
+
const busy = s.status ? s.status !== "idle" : undefined;
|
|
114
|
+
const mark: Mark = pane?.agent_status === "blocked" ? "waiting" : busy === undefined ? markOf(pane?.agent_status) : busy ? "working" : "idle";
|
|
115
|
+
return { group: "net", depth: 1, mark, text: s.name ?? s.id, kind: role, note: { working: s.status, waiting: "waiting on a question" }[mark as "working"] };
|
|
116
|
+
};
|
|
117
|
+
const joined = live.filter((s) => s.herdrLocation?.workspace?.id === workspaceId).flatMap((s) => {
|
|
118
|
+
const pane = ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
|
|
119
|
+
const role = pane?.tokens?.sb_role;
|
|
120
|
+
return role === "main" || role === "member" ? [{ s, pane, role, tab: s.herdrLocation!.tab! }] : [];
|
|
121
|
+
});
|
|
122
|
+
const rows: Row[] = [];
|
|
123
|
+
for (const group of Object.values(Object.groupBy(joined, (j) => j.tab.id))) {
|
|
124
|
+
rows.push({ group: "net", depth: 0, mark: "unknown", text: `tab ${group![0]!.tab.label}` });
|
|
125
|
+
for (const { s, pane, role } of group!.sort((a, b) => Number(b.role === "main") - Number(a.role === "main"))) rows.push(sessionRow(s, role), ...subagents(pane, ps, now));
|
|
126
|
+
}
|
|
127
|
+
// Sub workspaces are linked worktrees: they hang under this tower like subagents, and their sessions never join. One that
|
|
128
|
+
// already shows as a launched subagent (pi-herdr-agents runs worktree children there) is not listed a second time.
|
|
129
|
+
const claimed = new Set(rows.map((r) => r.workspace));
|
|
130
|
+
for (const sub of all.filter((x) => isSub(x) && mine && parentOf(all, x) === mine && !claimed.has(x.workspace_id))) rows.push({ group: "net", depth: 0, mark: markOf(sub.agent_status), text: sub.label, kind: "sub workspace" });
|
|
131
|
+
for (const w of all.filter((x) => x !== mine && !isSub(x) && live.some((s) => s.id === x.tokens?.tower))) rows.push({ group: "towers", depth: 0, mark: markOf(w.agent_status), text: w.label, kind: w === head ? "head tower" : "tower" });
|
|
132
|
+
// The title shows what needs attention most: a waiting row, then a working one, else the workspace's own status.
|
|
133
|
+
const attention = (["waiting", "working"] as const).find((m) => rows.some((r) => r.mark === m));
|
|
134
|
+
return { title: mine?.label ?? workspaceId, mark: attention ?? markOf(mine?.agent_status), head: Boolean(mine) && mine === head, rows };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Dashboard { poke(): void; stop(): void }
|
|
138
|
+
export interface Timing { frameMs?: number; pollMs?: number; minGapMs?: number }
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Shows the dashboard in a tower's TUI. The spinner timer (8 fps) runs only while a row is working; the data poll (3 s)
|
|
142
|
+
* runs while the widget exists. stop() clears both timers and the widget, and pi disposing the widget does the same.
|
|
143
|
+
*/
|
|
144
|
+
export function startDashboard({ ui, collect: read, frameMs = 125, pollMs = 3000, minGapMs = 500 }: { ui: ExtensionContext["ui"]; collect(): Promise<Snapshot> } & Timing): Dashboard {
|
|
145
|
+
let live: Dashboard | undefined;
|
|
146
|
+
ui.setWidget(KEY, (tui, theme) => {
|
|
147
|
+
let snap: Snapshot = { title: "", mark: "unknown", head: false, rows: [] }, frame = 0, busy = false, stopped = false, last = 0;
|
|
148
|
+
let spinner: ReturnType<typeof setInterval> | undefined;
|
|
149
|
+
const refresh = async () => {
|
|
150
|
+
if (busy || stopped) return;
|
|
151
|
+
busy = true;
|
|
152
|
+
try {
|
|
153
|
+
snap = await read();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
snap = { ...snap, mark: "error", rows: [{ group: "net", depth: 0, mark: "error", text: `dashboard unavailable: ${(error as Error).message}` }] };
|
|
156
|
+
}
|
|
157
|
+
busy = false;
|
|
158
|
+
last = Date.now();
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
const working = snap.mark === "working" || snap.rows.some((r) => r.mark === "working");
|
|
161
|
+
if (working && !spinner) spinner = setInterval(() => (frame++, tui.requestRender()), frameMs).unref();
|
|
162
|
+
if (!working && spinner) spinner = void clearInterval(spinner);
|
|
163
|
+
tui.requestRender();
|
|
164
|
+
};
|
|
165
|
+
const poll = setInterval(() => void refresh(), pollMs).unref();
|
|
166
|
+
const stop = () => {
|
|
167
|
+
stopped = true;
|
|
168
|
+
clearInterval(poll);
|
|
169
|
+
clearInterval(spinner);
|
|
170
|
+
spinner = undefined;
|
|
171
|
+
};
|
|
172
|
+
live = { poke: () => void (Date.now() - last >= minGapMs && refresh()), stop };
|
|
173
|
+
void refresh();
|
|
174
|
+
const paint: Paint = (tone, text) => (tone === "title" ? theme.bold(theme.fg("accent", text)) : theme.fg(tone, text));
|
|
175
|
+
return { render: (width) => render(snap, frame, width, paint), invalidate() {}, dispose: stop };
|
|
176
|
+
});
|
|
177
|
+
return { poke: () => live?.poke(), stop: () => (ui.setWidget(KEY, undefined), live?.stop(), (live = undefined), undefined) };
|
|
178
|
+
}
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type HerdrRun = (args: string[], timeoutMs?: number) => Promise<string>;
|
|
7
|
+
/** One request on Herdr's socket API; rejects with the error Herdr answered. */
|
|
8
|
+
export type HerdrApi = (method: string, params: object) => Promise<void>;
|
|
9
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
10
|
+
export interface Place { paneId: string; tabId: string; workspaceId: string }
|
|
11
|
+
export interface Workspace { workspace_id: string; label: string; agent_status?: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string; is_linked_worktree?: boolean; repo_key?: string } }
|
|
12
|
+
export interface Pane { pane_id: string; tab_id: string; workspace_id?: string; agent?: string; agent_status?: string; agent_session?: { value: string }; label?: string; tokens?: Record<string, string> }
|
|
13
|
+
export interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
|
|
14
|
+
/** A live pi-intercom session; `status` is idle, thinking, or tool:<name>. */
|
|
15
|
+
export interface Session { id: string; name?: string; status?: string; herdrLocation?: Location }
|
|
16
|
+
export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean>; moveFirst(tabId: string): Promise<void> }
|
|
17
|
+
|
|
18
|
+
const TTL_MS = 10 * 60_000;
|
|
19
|
+
const START_MS = 30_000;
|
|
20
|
+
const TOWER_WAIT_MS = 60_000;
|
|
21
|
+
const START_ATTEMPTS = 20;
|
|
22
|
+
const opening = new Map<string, Promise<{ id: string; opened: boolean }>>();
|
|
23
|
+
|
|
24
|
+
export const herdrRun: HerdrRun = (args, timeoutMs = 5000) =>
|
|
25
|
+
new Promise((resolve, reject) => {
|
|
26
|
+
execFile(process.env.HERDR_BIN_PATH || "herdr", args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
27
|
+
if (error) reject(new Error(`herdr ${args.join(" ")} failed: ${stderr.trim() || error.message}`));
|
|
28
|
+
else resolve(stdout);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Every herdr reply wraps its payload in `result`.
|
|
33
|
+
async function result(run: HerdrRun, args: string[], timeoutMs?: number) {
|
|
34
|
+
const output = await run(args, timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(output).result;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`herdr ${args.join(" ")} returned invalid JSON: ${output.slice(0, 200)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function place(run: HerdrRun): Promise<Place> {
|
|
43
|
+
const pane = (await result(run, ["pane", "current"])).pane;
|
|
44
|
+
return { paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A sub workspace is a linked git worktree that Herdr opened as its own workspace: it is treated like a subagent of its parent. */
|
|
48
|
+
export const isSub = (w: Workspace) => w.worktree?.is_linked_worktree === true;
|
|
49
|
+
export const parentOf = (all: Workspace[], w: Workspace) => all.find((p) => !isSub(p) && p.worktree?.repo_key !== undefined && p.worktree.repo_key === w.worktree?.repo_key);
|
|
50
|
+
/** The head tower's workspace: the first one in list order that is not a sub workspace. */
|
|
51
|
+
export const headOf = (all: Workspace[]) => all.find((w) => !isSub(w));
|
|
52
|
+
|
|
53
|
+
export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
|
|
54
|
+
|
|
55
|
+
export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
|
|
56
|
+
return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const herdrApi = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock")): HerdrApi => (method, params) =>
|
|
60
|
+
new Promise((resolve, reject) => {
|
|
61
|
+
const socket = createConnection(socketPath).setTimeout(5000);
|
|
62
|
+
let reply = "";
|
|
63
|
+
const end = (error?: Error) => (socket.destroy(), error ? reject(error) : resolve());
|
|
64
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ id: "straw-boss", method, params })}\n`));
|
|
65
|
+
socket.on("data", (chunk) => {
|
|
66
|
+
reply += chunk;
|
|
67
|
+
if (!reply.includes("\n")) return;
|
|
68
|
+
const { error } = JSON.parse(reply.split("\n")[0]!) as { error?: { code?: string; message?: string } };
|
|
69
|
+
end(error && new Error(`herdr ${method} failed: ${error.code ?? ""} ${error.message ?? ""}`.trim()));
|
|
70
|
+
});
|
|
71
|
+
socket.on("timeout", () => end(new Error(`herdr ${method} timed out`)));
|
|
72
|
+
socket.on("error", (error) => end(new Error(`herdr ${method} failed: ${error.message}`)));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Writes display-and-discovery tokens (null clears one). Herdr takes the positional id before the options. */
|
|
76
|
+
export function report(run: HerdrRun, kind: "pane" | "workspace", id: string, tokens: Record<string, string | null>) {
|
|
77
|
+
const flags = Object.entries(tokens).flatMap(([name, value]) => (value === null ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
|
|
78
|
+
const ttl = flags.includes("--token") ? ["--ttl-ms", String(TTL_MS)] : [];
|
|
79
|
+
return run([kind, "report-metadata", id, "--source", "straw-boss", ...ttl, ...flags]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Polls until `check` returns something truthy, or `ms` have passed. */
|
|
83
|
+
export async function waitFor<T>(sleep: Sleep, check: () => T | Promise<T>, ms: number, step = 100): Promise<Awaited<T> | undefined> {
|
|
84
|
+
for (let waited = 0; waited <= ms; waited += step) {
|
|
85
|
+
const value = await check();
|
|
86
|
+
if (value) return value;
|
|
87
|
+
await sleep(step);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The live tower of a workspace, opening its `tower` tab when there is none. One opening at a time per workspace. */
|
|
92
|
+
export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
|
|
93
|
+
const existing = opening.get(workspace.workspace_id);
|
|
94
|
+
if (existing) return existing;
|
|
95
|
+
const pending = open(env, workspace, cwd).finally(() => opening.delete(workspace.workspace_id));
|
|
96
|
+
opening.set(workspace.workspace_id, pending);
|
|
97
|
+
return pending;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Workspace, cwd: string) {
|
|
101
|
+
const ws = workspace.workspace_id;
|
|
102
|
+
const held = async () => {
|
|
103
|
+
const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
|
|
104
|
+
return id && (await live(id)) ? id : undefined;
|
|
105
|
+
};
|
|
106
|
+
const current = await held();
|
|
107
|
+
if (current) return { id: current, opened: false };
|
|
108
|
+
|
|
109
|
+
// A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
|
|
110
|
+
// The tab is told apart by the `sb_role=tower` pane token that only Straw Boss writes; a tab merely labelled "tower"
|
|
111
|
+
// (a pi-herdr-agents subagent can be named so) is someone else's.
|
|
112
|
+
let paneId: string;
|
|
113
|
+
let running = false;
|
|
114
|
+
const marked = (await panes(run, ws)).find((p) => p.tokens?.sb_role === "tower");
|
|
115
|
+
if (marked) {
|
|
116
|
+
paneId = marked.pane_id;
|
|
117
|
+
running = marked.agent === "pi";
|
|
118
|
+
await moveFirst(marked.tab_id);
|
|
119
|
+
} else {
|
|
120
|
+
const dir = workspace.worktree?.checkout_path ?? cwd;
|
|
121
|
+
const created = await result(run, ["tab", "create", "--workspace", ws, "--label", "tower", "--cwd", dir, "--no-focus", "--env", "STRAW_BOSS_ROLE=tower"]);
|
|
122
|
+
paneId = created.root_pane.pane_id;
|
|
123
|
+
// Marked at once, so a tab left without pi is recognised (while the marker lasts) and the label stays display only.
|
|
124
|
+
await report(run, "pane", paneId, { sb_role: "tower" });
|
|
125
|
+
await moveFirst(created.tab.tab_id);
|
|
126
|
+
}
|
|
127
|
+
if (!running) {
|
|
128
|
+
// Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
|
|
129
|
+
const start = ["agent", "start", `tower-${ws.toLowerCase()}`, "--kind", "pi", "--pane", paneId, "--timeout", String(START_MS), "--", "--name", `tower · ${workspace.label}`];
|
|
130
|
+
// 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.
|
|
131
|
+
for (let attempt = 1; ; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
await run(start, START_MS + 5000);
|
|
134
|
+
break;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (attempt === START_ATTEMPTS || !(error as Error).message.includes("agent_pane_busy")) throw error;
|
|
137
|
+
await sleep(500);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const id = await waitFor(sleep, held, TOWER_WAIT_MS, 1000);
|
|
142
|
+
if (!id) throw new Error(`The tower of ${workspace.label} did not publish its token within ${TOWER_WAIT_MS / 1000} s (pane ${paneId})`);
|
|
143
|
+
return { id, opened: true };
|
|
144
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
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 { collect, startDashboard } from "./dashboard.ts";
|
|
7
|
+
import { ensureTower, headOf, herdrApi, type HerdrApi, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
|
|
8
|
+
|
|
9
|
+
// pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
|
|
10
|
+
const REGISTER = "intercom:extension-register";
|
|
11
|
+
const OUTBOX = "intercom:outbox-request";
|
|
12
|
+
const OUTBOX_RESULT = "intercom:outbox-result";
|
|
13
|
+
const NAMESPACE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
|
|
14
|
+
const ENTRY = "straw-boss";
|
|
15
|
+
const PROMPTS = ["main", "tower", "member", "reminder"];
|
|
16
|
+
const REFRESH_MS = 4 * 60_000;
|
|
17
|
+
const CLAIM_MS = 5000;
|
|
18
|
+
const CONNECT_MS = 15_000;
|
|
19
|
+
const SEND_MS = 30_000;
|
|
20
|
+
const SETTLE_MS = 2000;
|
|
21
|
+
|
|
22
|
+
interface Channel {
|
|
23
|
+
snapshot(): { connected: boolean; supported: boolean; owner?: { sessionId: string }; state?: { payload: unknown } };
|
|
24
|
+
publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void;
|
|
25
|
+
commitState(payload: unknown, expectedRevision?: number): void;
|
|
26
|
+
listSessions(): Promise<Session[]>;
|
|
27
|
+
}
|
|
28
|
+
interface SendResult { requestId?: string; status: string; code?: string; detail?: string }
|
|
29
|
+
interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
|
|
30
|
+
type Role = "tower" | "main" | "member";
|
|
31
|
+
type Scope = "tab" | "workspace" | "towers" | "all";
|
|
32
|
+
type Member = ReturnType<typeof netMembers>[number];
|
|
33
|
+
export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
|
|
34
|
+
|
|
35
|
+
/** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
|
|
36
|
+
function netMembers(sessions: Session[], ps: Pane[]) {
|
|
37
|
+
return sessions.flatMap((s) => {
|
|
38
|
+
const { workspace, tab, paneId } = s.herdrLocation ?? {};
|
|
39
|
+
const role = ps.find((p) => p.pane_id === paneId)?.tokens?.sb_role;
|
|
40
|
+
return workspace && tab && role ? [{ s, role, tab, workspace }] : [];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
|
|
44
|
+
|
|
45
|
+
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)) }: Deps = {}) {
|
|
46
|
+
return function strawBoss(pi: ExtensionAPI): void {
|
|
47
|
+
// Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
|
|
48
|
+
if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
|
|
49
|
+
let isTower = env.STRAW_BOSS_ROLE === "tower";
|
|
50
|
+
let asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
|
|
51
|
+
// Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
|
|
52
|
+
let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
|
|
53
|
+
let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
|
|
54
|
+
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
55
|
+
let queue: Promise<void> = Promise.resolve();
|
|
56
|
+
const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
|
|
57
|
+
const role = (): Role | undefined => {
|
|
58
|
+
if (isTower) return "tower";
|
|
59
|
+
if (!joined) return undefined;
|
|
60
|
+
return isMain ? "main" : "member";
|
|
61
|
+
};
|
|
62
|
+
const tell = (text: string, level: "info" | "warning" | "error" = "info") => ui?.notify(text, level);
|
|
63
|
+
const render = (text = "") => text.replace(/\{\{(\w+)\}\}/g, (_, key: string) => ({ dir: promptDir, ...info })[key] || "none yet");
|
|
64
|
+
const tool = <P extends TSchema>(name: string, description: string, parameters: P, body: (params: Static<P>) => Promise<{ text: string; details?: unknown }>) =>
|
|
65
|
+
pi.registerTool({
|
|
66
|
+
name, label: name, description, parameters,
|
|
67
|
+
execute: async (_id, params) => {
|
|
68
|
+
const { text, details } = await body(params);
|
|
69
|
+
return { content: [{ type: "text", text }], details };
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const connect = (eligible: boolean) => {
|
|
73
|
+
const namespace = `straw-boss/tab/${Buffer.from(at.tabId).toString("hex")}`;
|
|
74
|
+
if (!NAMESPACE.test(namespace)) throw new Error(`Tab id ${at.tabId} is too long for an intercom namespace (limit 24 characters)`);
|
|
75
|
+
pi.events.emit(REGISTER, { namespace, ownerEligible: eligible, onReady: (channel: Channel) => (c = channel), onEvent });
|
|
76
|
+
if (!c) throw new Error("pi-intercom is not loaded (Straw Boss needs pi-intercom >= 0.17.1)");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Tokens are display and discovery only: a failed write waits for the next refresh, liveness comes from intercom.
|
|
80
|
+
const publish = async () => {
|
|
81
|
+
const sbRole = role();
|
|
82
|
+
if (sbRole) await report(run, "pane", at.paneId, { sb_role: sbRole }).catch(() => {});
|
|
83
|
+
if (holdsTower) await report(run, "workspace", at.workspaceId, { tower: me() }).catch(() => {});
|
|
84
|
+
};
|
|
85
|
+
const keepFresh = () => {
|
|
86
|
+
timer ??= setInterval(() => void publish(), REFRESH_MS);
|
|
87
|
+
timer.unref();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// The owner records who is main. The first joiner pins itself, so a later owner change never swaps the main agent.
|
|
91
|
+
const pin = (main?: string) => {
|
|
92
|
+
if (main && c.snapshot().owner?.sessionId === me()) c.commitState({ v: 1, main, since: Date.now() });
|
|
93
|
+
};
|
|
94
|
+
const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
|
|
95
|
+
if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
|
|
96
|
+
else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
|
|
97
|
+
if (["presence_update", "session_joined", "session_left"].includes(event.type)) dashboard?.poke();
|
|
98
|
+
};
|
|
99
|
+
const refresh = async () => {
|
|
100
|
+
if (!joined) return;
|
|
101
|
+
try {
|
|
102
|
+
const snap = c.snapshot();
|
|
103
|
+
const sessions = await c.listSessions();
|
|
104
|
+
let main = (snap.state?.payload as { main?: string } | undefined)?.main;
|
|
105
|
+
if (!sessions.some((s) => s.id === main)) {
|
|
106
|
+
// The pinned main is gone: the owner is main until it pins itself.
|
|
107
|
+
main = snap.owner?.sessionId;
|
|
108
|
+
pin(main);
|
|
109
|
+
}
|
|
110
|
+
const ws = (await workspaces(run)).find((w) => w.workspace_id === at.workspaceId);
|
|
111
|
+
const tab = sessions.find((s) => s.id === me())?.herdrLocation?.tab?.label ?? at.tabId;
|
|
112
|
+
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)) };
|
|
113
|
+
const wasMain = isMain;
|
|
114
|
+
isMain = main === me();
|
|
115
|
+
if (isMain !== wasMain) tell(`This session is ${isMain ? "now" : "no longer"} the main agent of ${info.tab}`);
|
|
116
|
+
await publish();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
tell(`Straw Boss: ${(error as Error).message}`, "warning");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const sync = () => (queue = queue.then(refresh));
|
|
122
|
+
// A session that has just started connects to the broker a moment after session_start.
|
|
123
|
+
const connected = async () => {
|
|
124
|
+
if (await waitFor(sleep, () => c.snapshot().connected && c.snapshot().supported, CONNECT_MS)) return;
|
|
125
|
+
throw new Error(c.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
|
|
126
|
+
};
|
|
127
|
+
const joinNet = async (restored = false) => {
|
|
128
|
+
if (joined) return;
|
|
129
|
+
if (barred) throw new Error(barred);
|
|
130
|
+
at = await place(run);
|
|
131
|
+
connect(true);
|
|
132
|
+
await connected();
|
|
133
|
+
if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
|
|
134
|
+
joined = true;
|
|
135
|
+
if (!restored) pi.appendEntry(ENTRY, { asked: true, joined: true });
|
|
136
|
+
addNetTools();
|
|
137
|
+
keepFresh();
|
|
138
|
+
await sync();
|
|
139
|
+
};
|
|
140
|
+
const takeover = async () => {
|
|
141
|
+
await joinNet();
|
|
142
|
+
if (!isMain) {
|
|
143
|
+
if (c.snapshot().owner?.sessionId === me()) pin(me());
|
|
144
|
+
else c.publish({ type: "claim" }, { audience: "owner" });
|
|
145
|
+
if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) throw new Error("The owner did not confirm the takeover within 5 s");
|
|
146
|
+
}
|
|
147
|
+
return `This session is the main agent of tab ${info.tab}`;
|
|
148
|
+
};
|
|
149
|
+
const send = (to: string, message: string) =>
|
|
150
|
+
new Promise<SendResult>((resolve) => {
|
|
151
|
+
const requestId = `straw-boss-${me()}-${++requests}`;
|
|
152
|
+
const done = (result: SendResult) => {
|
|
153
|
+
off();
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
resolve(result);
|
|
156
|
+
};
|
|
157
|
+
const off = pi.events.on(OUTBOX_RESULT, (data) => (data as SendResult).requestId === requestId && done(data as SendResult));
|
|
158
|
+
const timeout = setTimeout(() => done({ status: "failed", detail: `no result within ${SEND_MS / 1000} s` }), SEND_MS);
|
|
159
|
+
pi.events.emit(OUTBOX, { version: 1, requestId, extensionId: "pi-straw-boss", extensionName: "Pi Straw Boss", to, message });
|
|
160
|
+
});
|
|
161
|
+
const openTower = async (query?: string) => {
|
|
162
|
+
const all = await workspaces(run);
|
|
163
|
+
const found = all.filter((w) => (query ? w.workspace_id === query || w.label === query : w.workspace_id === at.workspaceId));
|
|
164
|
+
if (found.length !== 1) {
|
|
165
|
+
throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${query}". Workspaces: ${all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
|
|
166
|
+
}
|
|
167
|
+
// A sub workspace never has a tower: its parent workspace's tower serves it.
|
|
168
|
+
const asked = found[0]!;
|
|
169
|
+
const target = isSub(asked) ? parentOf(all, asked) : asked;
|
|
170
|
+
if (!target) throw new Error(`${asked.label} is a sub workspace with no open parent workspace, so it has no tower; open the parent workspace first`);
|
|
171
|
+
if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
|
|
172
|
+
const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
|
|
173
|
+
const moveFirst = (tabId: string) => api("tab.move", { tab_id: tabId, insert_index: 0 }).catch((error: Error) => tell(`Straw Boss: could not move the tower tab first: ${error.message}`, "warning"));
|
|
174
|
+
return { target, routed: target !== asked ? asked : undefined, ...(await ensureTower({ run, sleep, live, moveFirst }, target, process.cwd())) };
|
|
175
|
+
};
|
|
176
|
+
const status = async () => {
|
|
177
|
+
const sessions = await c.listSessions();
|
|
178
|
+
const [ps, all] = [await panes(run), await workspaces(run)];
|
|
179
|
+
const mine = all.find((w) => w.workspace_id === at.workspaceId);
|
|
180
|
+
const towerOf = (w?: Workspace) => {
|
|
181
|
+
const s = sessions.find((x) => x.id === w?.tokens?.tower);
|
|
182
|
+
return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === headOf(all) };
|
|
183
|
+
};
|
|
184
|
+
const own = netMembers(sessions, ps).filter((m) => m.workspace.id === at.workspaceId && m.role !== "tower");
|
|
185
|
+
const tabs = Object.values(Object.groupBy(own, (m) => m.tab.id)).map((group = []) => ({
|
|
186
|
+
id: group[0]?.tab.id,
|
|
187
|
+
label: group[0]?.tab.label,
|
|
188
|
+
main: group.find((m) => m.role === "main")?.s.id ?? null,
|
|
189
|
+
sessions: group.length,
|
|
190
|
+
}));
|
|
191
|
+
return {
|
|
192
|
+
me: { id: me(), role: role() },
|
|
193
|
+
workspace: { id: at.workspaceId, label: mine?.label },
|
|
194
|
+
head: headOf(all)?.workspace_id,
|
|
195
|
+
tower: towerOf(mine) ?? null,
|
|
196
|
+
tabs,
|
|
197
|
+
towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const broadcast = async (message: string, scope: Scope) => {
|
|
201
|
+
const others = (await c.listSessions()).filter((s) => s.id !== me());
|
|
202
|
+
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">];
|
|
203
|
+
// tab, workspace, and towers reach the net only; all reaches every other pi session on the machine.
|
|
204
|
+
const targets = scope === "all" ? others : netMembers(others, await panes(run)).filter(inScope).map((m) => m.s);
|
|
205
|
+
const results = await Promise.all(targets.map(async (s) => ({ id: s.id, name: nameOf(s), ...(await send(s.id, message)) })));
|
|
206
|
+
const summary = results.map((r) => `${r.name}: ${r.status}${r.code ? ` (${r.code})` : ""}`).join("\n") || "no recipients";
|
|
207
|
+
if (results.some((r) => r.status !== "sent")) throw new Error(`Not every recipient received it:\n${summary}`);
|
|
208
|
+
return { text: `Broadcast to ${results.length} session(s):\n${summary}`, details: { scope, results } };
|
|
209
|
+
};
|
|
210
|
+
function addNetTools() {
|
|
211
|
+
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 () => {
|
|
212
|
+
const result = await status();
|
|
213
|
+
return { text: JSON.stringify(result, null, 2), details: result };
|
|
214
|
+
});
|
|
215
|
+
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.",
|
|
216
|
+
Type.Object({
|
|
217
|
+
message: Type.String({ description: "What the tower needs to know or do: at most two sentences, details by reference." }),
|
|
218
|
+
workspace: Type.Optional(Type.String({ description: "Another workspace's id or exact label. Omit for your own." })),
|
|
219
|
+
}),
|
|
220
|
+
async ({ message, workspace }) => {
|
|
221
|
+
const tower = await openTower(workspace);
|
|
222
|
+
const result = await send(tower.id, message);
|
|
223
|
+
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());
|
|
224
|
+
const via = tower.routed ? ` (${tower.routed.label} is a sub workspace)` : "";
|
|
225
|
+
const text = `Sent to the tower of ${tower.target.label}${via}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
|
|
226
|
+
return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
|
|
227
|
+
});
|
|
228
|
+
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).",
|
|
229
|
+
Type.Object({
|
|
230
|
+
message: Type.String({ description: "The message, once; receivers do not re-broadcast it." }),
|
|
231
|
+
scope: Type.Optional(Type.Union([Type.Literal("tab"), Type.Literal("workspace"), Type.Literal("towers"), Type.Literal("all")], { description: "Default workspace." })),
|
|
232
|
+
}),
|
|
233
|
+
({ message, scope }) => broadcast(message, scope ?? "workspace"));
|
|
234
|
+
}
|
|
235
|
+
const startTower = async () => {
|
|
236
|
+
at = await place(run);
|
|
237
|
+
connect(false);
|
|
238
|
+
await connected();
|
|
239
|
+
if (!saved.tower) pi.appendEntry(ENTRY, { tower: true, workspaceId: at.workspaceId });
|
|
240
|
+
await report(run, "pane", at.paneId, { sb_role: "tower" });
|
|
241
|
+
await sleep(SETTLE_MS);
|
|
242
|
+
const all = await workspaces(run);
|
|
243
|
+
const [ps, sessions] = [await panes(run, at.workspaceId), await c.listSessions()];
|
|
244
|
+
const ws = all.find((w) => w.workspace_id === at.workspaceId);
|
|
245
|
+
const holder = ws?.tokens?.tower;
|
|
246
|
+
const rival = ps.find((p) => p.pane_id !== at.paneId && p.agent === "pi" && p.tokens?.sb_role === "tower");
|
|
247
|
+
// A second tower in one workspace yields before it publishes: the live token holder wins, otherwise the lower tab id.
|
|
248
|
+
if ((holder && holder !== me() && sessions.some((s) => s.id === holder)) || (rival && rival.tab_id < at.tabId)) {
|
|
249
|
+
await report(run, "pane", at.paneId, { sb_role: null });
|
|
250
|
+
await run(["tab", "close", at.tabId]);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
holdsTower = true;
|
|
254
|
+
info = { workspace: ws?.label ?? at.workspaceId, head: headOf(all)?.label ?? "" };
|
|
255
|
+
addNetTools();
|
|
256
|
+
keepFresh();
|
|
257
|
+
await publish();
|
|
258
|
+
// The dashboard lives exactly as long as this session holds the tower role.
|
|
259
|
+
if (hasUI) dashboard = startDashboard({ ui: ui!, collect: () => collect({ run, sessions: () => c.listSessions(), workspaceId: at.workspaceId }) });
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// A session in a sub workspace (a linked git worktree) acts like a subagent: it never joins. Failing to tell bars it too.
|
|
263
|
+
const barReason = async () => {
|
|
264
|
+
try {
|
|
265
|
+
const { workspaceId } = await place(run);
|
|
266
|
+
const all = await workspaces(run);
|
|
267
|
+
const sub = all.find((w) => w.workspace_id === workspaceId);
|
|
268
|
+
return sub && isSub(sub) ? { text: `${sub.label} is a sub workspace, so this session cannot join the net; the main agent of its parent workspace represents it`, level: "info" as const } : undefined;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { text: `Straw Boss could not tell whether this is a sub workspace: ${(error as Error).message}`, level: "error" as const };
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
275
|
+
ui = ctx.ui;
|
|
276
|
+
hasUI = ctx.hasUI;
|
|
277
|
+
prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
|
|
278
|
+
saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
|
|
279
|
+
asked = Boolean(saved.asked);
|
|
280
|
+
isTower ||= Boolean(saved.tower);
|
|
281
|
+
// Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
|
|
282
|
+
let starting: Promise<void> | undefined;
|
|
283
|
+
if (isTower) {
|
|
284
|
+
starting = startTower();
|
|
285
|
+
} else {
|
|
286
|
+
const bar = await barReason();
|
|
287
|
+
barred = bar?.text;
|
|
288
|
+
if (bar) {
|
|
289
|
+
tell(bar.text, bar.level);
|
|
290
|
+
} else {
|
|
291
|
+
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 () => {
|
|
292
|
+
await joinNet();
|
|
293
|
+
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}.`;
|
|
294
|
+
return { text, details: { role: role() } };
|
|
295
|
+
});
|
|
296
|
+
if (saved.joined) starting = joinNet(true);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
pi.on("before_agent_start", (event) => {
|
|
303
|
+
const current = role();
|
|
304
|
+
// A session outside the net is asked once, at its first run, whether it should join.
|
|
305
|
+
const first = !current && !asked && !barred;
|
|
306
|
+
if (first) {
|
|
307
|
+
asked = true;
|
|
308
|
+
pi.appendEntry(ENTRY, { asked: true });
|
|
309
|
+
}
|
|
310
|
+
let text: string | undefined;
|
|
311
|
+
if (current) text = render(prompts[current]);
|
|
312
|
+
else if (first) text = render(prompts.reminder);
|
|
313
|
+
if (text) event.systemPromptOptions.sections[ENTRY] = text;
|
|
314
|
+
else delete event.systemPromptOptions.sections[ENTRY];
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
pi.on("session_shutdown", async () => {
|
|
318
|
+
dashboard?.stop();
|
|
319
|
+
dashboard = undefined;
|
|
320
|
+
clearInterval(timer);
|
|
321
|
+
timer = undefined;
|
|
322
|
+
const held = holdsTower;
|
|
323
|
+
holdsTower = false;
|
|
324
|
+
if (!role() || !at) return;
|
|
325
|
+
await report(run, "pane", at.paneId, { sb_role: null }).catch(() => {});
|
|
326
|
+
if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
pi.registerCommand("boss", {
|
|
330
|
+
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.",
|
|
331
|
+
handler: async (args, ctx) => {
|
|
332
|
+
ui = ctx.ui;
|
|
333
|
+
try {
|
|
334
|
+
if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net. /boss joins it.");
|
|
335
|
+
if (isTower) throw new Error("This session is the tower, not a main agent");
|
|
336
|
+
if (args.trim() !== "tower") return tell(await takeover());
|
|
337
|
+
await joinNet();
|
|
338
|
+
const tower = await openTower();
|
|
339
|
+
const where = (await c.listSessions()).find((s) => s.id === tower.id)?.herdrLocation;
|
|
340
|
+
if (where?.tab) await run(["tab", "focus", where.tab.id]);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
tell((error as Error).message, "error");
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default createStrawBoss();
|