pi-straw-boss 0.1.0 → 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/README.md +40 -61
- package/package.json +4 -2
- package/src/dashboard.ts +178 -0
- package/src/herdr.ts +44 -10
- package/src/index.ts +50 -19
package/README.md
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
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
|
+
```
|
|
4
20
|
|
|
5
|
-
##
|
|
21
|
+
## Why
|
|
6
22
|
|
|
7
|
-
-
|
|
8
|
-
- [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer
|
|
9
|
-
- Herdr 0.9.4 or newer
|
|
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.
|
|
10
24
|
|
|
11
|
-
|
|
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.
|
|
12
30
|
|
|
13
31
|
## Install
|
|
14
32
|
|
|
@@ -16,69 +34,30 @@ Outside Herdr the extension does nothing.
|
|
|
16
34
|
pi install npm:pi-straw-boss
|
|
17
35
|
```
|
|
18
36
|
|
|
19
|
-
|
|
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.
|
|
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.
|
|
29
38
|
|
|
30
|
-
|
|
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 |
|
|
39
|
+
## Quick start
|
|
35
40
|
|
|
36
|
-
|
|
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.
|
|
37
44
|
|
|
38
|
-
## Tools
|
|
45
|
+
## Tools and commands
|
|
39
46
|
|
|
40
|
-
|
|
|
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 |
|
|
47
|
+
| Name | What it does |
|
|
48
48
|
|---|---|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
53
|
-
|
|
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).
|
|
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. |
|
|
60
55
|
|
|
61
|
-
|
|
56
|
+
Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
|
|
62
57
|
|
|
63
|
-
|
|
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
|
-
```
|
|
58
|
+
## Learn more
|
|
80
59
|
|
|
81
|
-
|
|
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.
|
|
82
61
|
|
|
83
62
|
## License
|
|
84
63
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "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",
|
|
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "wayne930242",
|
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
39
40
|
"typebox": "*"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
44
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
43
45
|
"@types/node": "26.6.5",
|
|
44
46
|
"typebox": "1.3.27",
|
|
45
47
|
"typescript": "7.0.2"
|
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
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
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";
|
|
2
5
|
|
|
3
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>;
|
|
4
9
|
export type Sleep = (ms: number) => Promise<void>;
|
|
5
10
|
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
|
|
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> }
|
|
9
17
|
|
|
10
18
|
const TTL_MS = 10 * 60_000;
|
|
11
19
|
const START_MS = 30_000;
|
|
@@ -36,12 +44,34 @@ export async function place(run: HerdrRun): Promise<Place> {
|
|
|
36
44
|
return { paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id };
|
|
37
45
|
}
|
|
38
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
|
+
|
|
39
53
|
export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
|
|
40
54
|
|
|
41
55
|
export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
|
|
42
56
|
return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
|
|
43
57
|
}
|
|
44
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
|
+
|
|
45
75
|
/** Writes display-and-discovery tokens (null clears one). Herdr takes the positional id before the options. */
|
|
46
76
|
export function report(run: HerdrRun, kind: "pane" | "workspace", id: string, tokens: Record<string, string | null>) {
|
|
47
77
|
const flags = Object.entries(tokens).flatMap(([name, value]) => (value === null ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
|
|
@@ -67,7 +97,7 @@ export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
|
|
|
67
97
|
return pending;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
|
-
async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: string) {
|
|
100
|
+
async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Workspace, cwd: string) {
|
|
71
101
|
const ws = workspace.workspace_id;
|
|
72
102
|
const held = async () => {
|
|
73
103
|
const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
|
|
@@ -77,18 +107,22 @@ async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: s
|
|
|
77
107
|
if (current) return { id: current, opened: false };
|
|
78
108
|
|
|
79
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.
|
|
80
112
|
let paneId: string;
|
|
81
113
|
let running = false;
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
running = root.agent === "pi";
|
|
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);
|
|
88
119
|
} else {
|
|
89
120
|
const dir = workspace.worktree?.checkout_path ?? cwd;
|
|
90
121
|
const created = await result(run, ["tab", "create", "--workspace", ws, "--label", "tower", "--cwd", dir, "--no-focus", "--env", "STRAW_BOSS_ROLE=tower"]);
|
|
91
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);
|
|
92
126
|
}
|
|
93
127
|
if (!running) {
|
|
94
128
|
// Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type, type Static, type TSchema } from "typebox";
|
|
6
|
-
import {
|
|
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";
|
|
7
8
|
|
|
8
9
|
// pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
|
|
9
10
|
const REGISTER = "intercom:extension-register";
|
|
@@ -18,8 +19,6 @@ const CONNECT_MS = 15_000;
|
|
|
18
19
|
const SEND_MS = 30_000;
|
|
19
20
|
const SETTLE_MS = 2000;
|
|
20
21
|
|
|
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
22
|
interface Channel {
|
|
24
23
|
snapshot(): { connected: boolean; supported: boolean; owner?: { sessionId: string }; state?: { payload: unknown } };
|
|
25
24
|
publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void;
|
|
@@ -31,7 +30,7 @@ interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
|
|
|
31
30
|
type Role = "tower" | "main" | "member";
|
|
32
31
|
type Scope = "tab" | "workspace" | "towers" | "all";
|
|
33
32
|
type Member = ReturnType<typeof netMembers>[number];
|
|
34
|
-
export interface Deps { run?: HerdrRun; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
|
|
33
|
+
export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
|
|
35
34
|
|
|
36
35
|
/** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
|
|
37
36
|
function netMembers(sessions: Session[], ps: Pane[]) {
|
|
@@ -43,12 +42,14 @@ function netMembers(sessions: Session[], ps: Pane[]) {
|
|
|
43
42
|
}
|
|
44
43
|
const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
|
|
45
44
|
|
|
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 = {}) {
|
|
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 = {}) {
|
|
47
46
|
return function strawBoss(pi: ExtensionAPI): void {
|
|
48
47
|
// Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
|
|
49
48
|
if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
|
|
50
49
|
let isTower = env.STRAW_BOSS_ROLE === "tower";
|
|
51
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;
|
|
52
53
|
let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
|
|
53
54
|
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
54
55
|
let queue: Promise<void> = Promise.resolve();
|
|
@@ -93,6 +94,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
93
94
|
const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
|
|
94
95
|
if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
|
|
95
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();
|
|
96
98
|
};
|
|
97
99
|
const refresh = async () => {
|
|
98
100
|
if (!joined) return;
|
|
@@ -124,6 +126,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
124
126
|
};
|
|
125
127
|
const joinNet = async (restored = false) => {
|
|
126
128
|
if (joined) return;
|
|
129
|
+
if (barred) throw new Error(barred);
|
|
127
130
|
at = await place(run);
|
|
128
131
|
connect(true);
|
|
129
132
|
await connected();
|
|
@@ -158,13 +161,17 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
158
161
|
const openTower = async (query?: string) => {
|
|
159
162
|
const all = await workspaces(run);
|
|
160
163
|
const found = all.filter((w) => (query ? w.workspace_id === query || w.label === query : w.workspace_id === at.workspaceId));
|
|
161
|
-
const [target] = found;
|
|
162
164
|
if (found.length !== 1) {
|
|
163
165
|
throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${query}". Workspaces: ${all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
|
|
164
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`);
|
|
165
171
|
if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
|
|
166
172
|
const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
|
|
167
|
-
|
|
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())) };
|
|
168
175
|
};
|
|
169
176
|
const status = async () => {
|
|
170
177
|
const sessions = await c.listSessions();
|
|
@@ -172,7 +179,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
172
179
|
const mine = all.find((w) => w.workspace_id === at.workspaceId);
|
|
173
180
|
const towerOf = (w?: Workspace) => {
|
|
174
181
|
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
|
|
182
|
+
return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === headOf(all) };
|
|
176
183
|
};
|
|
177
184
|
const own = netMembers(sessions, ps).filter((m) => m.workspace.id === at.workspaceId && m.role !== "tower");
|
|
178
185
|
const tabs = Object.values(Object.groupBy(own, (m) => m.tab.id)).map((group = []) => ({
|
|
@@ -184,7 +191,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
184
191
|
return {
|
|
185
192
|
me: { id: me(), role: role() },
|
|
186
193
|
workspace: { id: at.workspaceId, label: mine?.label },
|
|
187
|
-
head: all
|
|
194
|
+
head: headOf(all)?.workspace_id,
|
|
188
195
|
tower: towerOf(mine) ?? null,
|
|
189
196
|
tabs,
|
|
190
197
|
towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
|
|
@@ -214,7 +221,8 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
214
221
|
const tower = await openTower(workspace);
|
|
215
222
|
const result = await send(tower.id, message);
|
|
216
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());
|
|
217
|
-
const
|
|
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.`;
|
|
218
226
|
return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
|
|
219
227
|
});
|
|
220
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).",
|
|
@@ -243,14 +251,29 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
243
251
|
return;
|
|
244
252
|
}
|
|
245
253
|
holdsTower = true;
|
|
246
|
-
info = { workspace: ws?.label ?? at.workspaceId, head: all
|
|
254
|
+
info = { workspace: ws?.label ?? at.workspaceId, head: headOf(all)?.label ?? "" };
|
|
247
255
|
addNetTools();
|
|
248
256
|
keepFresh();
|
|
249
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 }) });
|
|
250
260
|
};
|
|
251
261
|
|
|
252
|
-
|
|
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) => {
|
|
253
275
|
ui = ctx.ui;
|
|
276
|
+
hasUI = ctx.hasUI;
|
|
254
277
|
prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
|
|
255
278
|
saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
|
|
256
279
|
asked = Boolean(saved.asked);
|
|
@@ -260,12 +283,18 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
260
283
|
if (isTower) {
|
|
261
284
|
starting = startTower();
|
|
262
285
|
} else {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
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
|
+
}
|
|
269
298
|
}
|
|
270
299
|
starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
|
|
271
300
|
});
|
|
@@ -273,7 +302,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
273
302
|
pi.on("before_agent_start", (event) => {
|
|
274
303
|
const current = role();
|
|
275
304
|
// A session outside the net is asked once, at its first run, whether it should join.
|
|
276
|
-
const first = !current && !asked;
|
|
305
|
+
const first = !current && !asked && !barred;
|
|
277
306
|
if (first) {
|
|
278
307
|
asked = true;
|
|
279
308
|
pi.appendEntry(ENTRY, { asked: true });
|
|
@@ -286,6 +315,8 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
|
|
|
286
315
|
});
|
|
287
316
|
|
|
288
317
|
pi.on("session_shutdown", async () => {
|
|
318
|
+
dashboard?.stop();
|
|
319
|
+
dashboard = undefined;
|
|
289
320
|
clearInterval(timer);
|
|
290
321
|
timer = undefined;
|
|
291
322
|
const held = holdsTower;
|