pi-straw-boss 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/prompts/tower.md +1 -1
- package/src/herdr.ts +43 -20
- package/src/index.ts +44 -5
- package/src/opener.ts +90 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Run a few agents on the same project and they start stepping on each other: two
|
|
|
14
14
|
|
|
15
15
|
- **Everyone joins.** Each pi in Herdr joins its tab's net at start as a member; nothing to ask or remember.
|
|
16
16
|
- **A main agent per tab, by promotion.** An agent whose work grows calls `boss_promote`, or is promoted when it first dispatches a subagent. A tab has one main, and nobody takes it over except you with `/boss`.
|
|
17
|
-
- **A tower per workspace.** It opens with the workspace's first main in its own tab, pinned first, and coordinates the main agents and the other towers. You don't need to talk to it.
|
|
17
|
+
- **A tower per workspace.** It opens with the workspace's first main in its own tab, pinned first, and coordinates the main agents and the other towers, and opens a tab with a main agent for work no session owns. You don't need to talk to it.
|
|
18
18
|
- **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
|
|
19
19
|
- **A browser view** of the whole net, `/boss gui`: every workspace as a base with its tower, agents, and subagents, animated and read-only. Click a base to zoom in, click a unit to read its chat as a live comms log; units that wait on you queue up as alerts.
|
|
20
20
|
- **Broadcast** to a tab, a workspace, every tower, or every session.
|
|
@@ -46,6 +46,7 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
|
|
|
46
46
|
| `boss_promote` | Become this tab's main agent (refused while it has one). |
|
|
47
47
|
| `boss_status` | Main agents, this workspace's tower, and the other towers. |
|
|
48
48
|
| `call_tower` | Message this workspace's tower, or another workspace's. |
|
|
49
|
+
| `boss_open_main` | Tower only: open a tab with a new main agent for a request no session owns. |
|
|
49
50
|
| `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
|
|
50
51
|
| `/boss` | Take over as this tab's main agent. |
|
|
51
52
|
| `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/tower.md
CHANGED
|
@@ -2,7 +2,7 @@ You are the tower of Herdr workspace {{workspace}}; agents talk to you, the boss
|
|
|
2
2
|
|
|
3
3
|
Agents and towers message you through intercom; answer with intercom send.
|
|
4
4
|
|
|
5
|
-
- Route each request to the main agent that owns it; when its tab has no main, to the member session doing that work; when no session owns it,
|
|
5
|
+
- Route each request to the main agent that owns it; when its tab has no main, to the member session doing that work; when no session owns it, open a main with boss_open_main without asking the boss, once, passing the request and the boss's decisions verbatim and the sender, and tell the sender if it fails. Other workspaces' work goes to their towers. Relay with boss_status, call_tower, and boss_broadcast; towers coordinate through the head tower, the tower of workspace {{head}}.
|
|
6
6
|
- Friction reports: find the owner and the broken relationship (registration, endpoint, dependency, shared resource, cleanup); have the owner repair it. Return a Straw Boss defect as a finding with evidence.
|
|
7
7
|
- Never ask the boss (no ask_user, robot_hand, focus changes); boss decisions go to the owning main agent.
|
|
8
8
|
- Do no work yourself: no commands, process signals, or edits; even a one-step fix goes to its owner.
|
package/src/herdr.ts
CHANGED
|
@@ -125,6 +125,25 @@ export async function waitFor<T>(sleep: Sleep, check: () => T | Promise<T>, ms:
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** A new tab in `dir` that does not take focus, with `env` (NAME=value) in its panes. */
|
|
129
|
+
export async function createTab(run: HerdrRun, workspace: string, dir: string, env: string[], label?: string) {
|
|
130
|
+
const created = await result(run, ["tab", "create", "--workspace", workspace, ...(label ? ["--label", label] : []), "--cwd", dir, "--no-focus", ...env.flatMap((e) => ["--env", e])]);
|
|
131
|
+
return { tabId: created.tab.tab_id as string, paneId: created.root_pane.pane_id as string };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Starts pi in the pane under a Herdr agent name. 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. */
|
|
135
|
+
export async function startPi(run: HerdrRun, sleep: Sleep, name: string, paneId: string, piArgs: string[]) {
|
|
136
|
+
const start = ["agent", "start", name, "--kind", "pi", "--pane", paneId, "--timeout", String(START_MS), "--", ...piArgs];
|
|
137
|
+
for (let attempt = 1; ; attempt++) {
|
|
138
|
+
try {
|
|
139
|
+
return await run(start, START_MS + 5000);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (attempt === START_ATTEMPTS || !(error as Error).message.includes("agent_pane_busy")) throw error;
|
|
142
|
+
await sleep(500);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
/** The live tower of a workspace, opening its `tower` tab when there is none. One opening at a time per workspace. */
|
|
129
148
|
export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
|
|
130
149
|
const existing = opening.get(workspace.workspace_id);
|
|
@@ -145,35 +164,39 @@ async function open({ run, sleep, live, moveFirst, model }: TowerEnv, workspace:
|
|
|
145
164
|
|
|
146
165
|
// A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
|
|
147
166
|
// The tab is told apart by the `sb_role=tower` pane token that only Straw Boss writes; a tab merely labelled "tower"
|
|
148
|
-
// (a pi-herdr-agents subagent can be named so) is someone else's.
|
|
167
|
+
// (a pi-herdr-agents subagent can be named so) is someone else's. A tab whose token was cleared (its tower quit) is still ours when it has
|
|
168
|
+
// exactly the tower label (the glyph), one pane, and no agent in it.
|
|
149
169
|
let paneId: string;
|
|
150
170
|
let running = false;
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
const here = await panes(run, ws);
|
|
172
|
+
const marked = here.find((p) => p.tokens?.sb_role === "tower");
|
|
173
|
+
const bare = marked ? [] : (await tabs(run, ws)).filter((t) => t.label === TOWER_LABEL).flatMap((t) => {
|
|
174
|
+
const inside = here.filter((p) => p.tab_id === t.tab_id);
|
|
175
|
+
return inside.length === 1 && !inside[0]!.agent && !inside[0]!.tokens?.sb_role ? [inside[0]!] : [];
|
|
176
|
+
});
|
|
177
|
+
const reused = marked ?? bare[0];
|
|
178
|
+
if (reused) {
|
|
179
|
+
paneId = reused.pane_id;
|
|
180
|
+
running = reused.agent === "pi";
|
|
181
|
+
if (!marked) await report(run, "pane", paneId, { sb_role: "tower" });
|
|
182
|
+
await moveFirst(reused.tab_id);
|
|
183
|
+
// Other shell-only leftovers are closed; failing to close one leaves a harmless tab, so it does not stop the tower.
|
|
184
|
+
for (const extra of bare.slice(1)) await run(["tab", "close", extra.tab_id]).catch(() => {});
|
|
156
185
|
} else {
|
|
157
186
|
const dir = workspace.worktree?.checkout_path ?? cwd;
|
|
158
|
-
const created = await
|
|
159
|
-
paneId = created.
|
|
187
|
+
const created = await createTab(run, ws, dir, ["STRAW_BOSS_ROLE=tower"], TOWER_LABEL);
|
|
188
|
+
paneId = created.paneId;
|
|
160
189
|
// Marked at once, so a tab left without pi is recognised (while the marker lasts) and the label stays display only.
|
|
161
190
|
await report(run, "pane", paneId, { sb_role: "tower" });
|
|
162
|
-
await moveFirst(created.
|
|
191
|
+
await moveFirst(created.tabId);
|
|
163
192
|
}
|
|
164
193
|
if (!running) {
|
|
165
194
|
// Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
break;
|
|
172
|
-
} catch (error) {
|
|
173
|
-
if (attempt === START_ATTEMPTS || !(error as Error).message.includes("agent_pane_busy")) throw error;
|
|
174
|
-
await sleep(500);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
195
|
+
// An adopted shell that turns out busy (the user runs something there) gives the token back, so the next open does not count it as the tower.
|
|
196
|
+
await startPi(run, sleep, `tower-${ws.toLowerCase()}`, paneId, ["--name", `tower · ${workspace.label}`, ...(model ? ["--model", model] : [])]).catch(async (error: unknown) => {
|
|
197
|
+
if (reused && !marked) await report(run, "pane", paneId, { sb_role: null }).catch(() => {});
|
|
198
|
+
throw error;
|
|
199
|
+
});
|
|
177
200
|
}
|
|
178
201
|
const id = await waitFor(sleep, held, TOWER_WAIT_MS, 1000);
|
|
179
202
|
if (!id) throw new Error(`The tower of ${workspace.label} did not publish its token within ${TOWER_WAIT_MS / 1000} s (pane ${paneId})`);
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createHub, type Hub } from "./dialogs.ts";
|
|
|
11
11
|
import { answers, type Gui, openBrowser, readRecord, sharedGui, startGui, urlOf } from "./gui.ts";
|
|
12
12
|
import { netSource } from "./net.ts";
|
|
13
13
|
import { clearOrder, markKids, netTokens } from "./order.ts";
|
|
14
|
+
import { opener } from "./opener.ts";
|
|
14
15
|
import { orphans } from "./orphan.ts";
|
|
15
16
|
import { copyText, pairingKeys, showLines } from "./pairing.ts";
|
|
16
17
|
import { type Run, switchRemote, tailscale } from "./remote.ts";
|
|
@@ -64,6 +65,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
64
65
|
const bridge = startBridge(pi, { env, dir: guiDir, local: () => (hub?.running ? hub : undefined) });
|
|
65
66
|
if (env.PI_SUBAGENT_ID) return;
|
|
66
67
|
let isTower = env.STRAW_BOSS_ROLE === "tower";
|
|
68
|
+
// A tab the tower opened for a request starts its pi with this role: it promotes itself as soon as it has joined (opener.ts).
|
|
69
|
+
const opensAsMain = env.STRAW_BOSS_ROLE === "main";
|
|
67
70
|
let joined = false, isMain = false, holdsTower = false, requests = 0;
|
|
68
71
|
const nonce = randomUUID().slice(0, 8);
|
|
69
72
|
// The tab's live main agent as of the last refresh, and the main this session pinned as owner before the broker echoes it back.
|
|
@@ -75,7 +78,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
75
78
|
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
76
79
|
let queue: Promise<void> = Promise.resolve();
|
|
77
80
|
// This session's intercom name, which the subagents it launched show as their launcher, and the subagent panes it has marked.
|
|
78
|
-
let ownName = "", kids = new Set<string>();
|
|
81
|
+
let ownName = "", openedBy: string | undefined, kids = new Set<string>();
|
|
79
82
|
// Watches for subagents this session launched whose result can no longer reach it (orphan.ts).
|
|
80
83
|
let nudges: ReturnType<typeof orphans> | undefined, nudgeTimer: ReturnType<typeof setInterval> | undefined;
|
|
81
84
|
const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
|
|
@@ -116,7 +119,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
116
119
|
ownName = nameOf((known ?? (await c.listSessions().catch(() => []))).find((s) => s.id === me())) || ownName;
|
|
117
120
|
// A failed read of Herdr leaves the subagents' tokens as they are; their TTL still runs.
|
|
118
121
|
const net = await netTokens(run, at, sbRole, ownName || me().slice(0, 8)).catch(() => undefined);
|
|
119
|
-
await report(run, "pane", at.paneId, { sb_role: sbRole, ...net?.own }).catch(() => {});
|
|
122
|
+
await report(run, "pane", at.paneId, { sb_role: sbRole, ...net?.own, ...(openedBy ? { sb_opened_by: openedBy } : {}) }).catch(() => {});
|
|
120
123
|
if (net) kids = await markKids(run, net.kids, kids, net.joined);
|
|
121
124
|
}
|
|
122
125
|
if (holdsTower && !stopped) await report(run, "workspace", at.workspaceId, { tower: me() }).catch(() => {});
|
|
@@ -318,6 +321,21 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
318
321
|
return { text, details: { role: role() } };
|
|
319
322
|
});
|
|
320
323
|
}
|
|
324
|
+
// Tower only. The tower runs no commands: this is its one way to start work, in a new tab of its own workspace.
|
|
325
|
+
function addOpenMain() {
|
|
326
|
+
const open = opener({ run, sleep, workspaceId: at.workspaceId, cwd: process.cwd(), sessions: () => c.listSessions(), send });
|
|
327
|
+
tool("boss_open_main", "Open a new tab in this workspace with a new pi as its main agent and hand it a request that no session owns. Does not ask the boss and does not take focus. Opens at most one main per request; when it fails, tell the sender.",
|
|
328
|
+
Type.Object({
|
|
329
|
+
request: Type.String({ description: "The sender's request exactly as it reached you, with every boss decision it quotes kept verbatim." }),
|
|
330
|
+
sender: Type.String({ description: "The intercom name or session id of the session that made the request; the new main reports back to it." }),
|
|
331
|
+
label: Type.Optional(Type.String({ description: "A short label for the new tab." })),
|
|
332
|
+
}),
|
|
333
|
+
async (input) => {
|
|
334
|
+
const opened = await open(input);
|
|
335
|
+
const text = opened.repeated ? `A main was already opened for this request (tab ${opened.tab}); nothing new opened.` : `Opened tab ${opened.tab} with a main agent (session ${opened.main}) and handed it the request. It reports to ${opened.sender}.`;
|
|
336
|
+
return { text, details: opened };
|
|
337
|
+
});
|
|
338
|
+
}
|
|
321
339
|
const startTower = async () => {
|
|
322
340
|
at = await place(run);
|
|
323
341
|
connect(false);
|
|
@@ -339,6 +357,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
339
357
|
holdsTower = true;
|
|
340
358
|
info = { workspace: ws?.label ?? at.workspaceId, head: headOf(all)?.label ?? "" };
|
|
341
359
|
addNetTools();
|
|
360
|
+
addOpenMain();
|
|
342
361
|
keepFresh();
|
|
343
362
|
await publish();
|
|
344
363
|
// The dashboard lives exactly as long as this session holds the tower role.
|
|
@@ -357,6 +376,17 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
357
376
|
}
|
|
358
377
|
};
|
|
359
378
|
|
|
379
|
+
// STRAW_BOSS_ROLE=main stays in the new tab's shell, so a later pi started there would self-promote. The tower's one-time pane token is what
|
|
380
|
+
// authorises it: this pi consumes the token and keeps the opener's marker (sb_opened_by) fresh, which the tower counts to enforce its limits.
|
|
381
|
+
const claimOpened = async () => {
|
|
382
|
+
if (!opensAsMain) return false;
|
|
383
|
+
const mine = (await panes(run, at.workspaceId).catch(() => [])).find((p) => p.pane_id === at.paneId);
|
|
384
|
+
if (mine?.tokens?.sb_open_main !== "1") return false;
|
|
385
|
+
openedBy = mine.tokens.sb_opened_by;
|
|
386
|
+
await report(run, "pane", at.paneId, { sb_open_main: null }).catch(() => {});
|
|
387
|
+
return true;
|
|
388
|
+
};
|
|
389
|
+
|
|
360
390
|
pi.on("session_start", async (_event, ctx) => {
|
|
361
391
|
stopped = false;
|
|
362
392
|
ui = ctx.ui;
|
|
@@ -379,7 +409,10 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
379
409
|
if (bar) {
|
|
380
410
|
tell(bar.text, bar.level);
|
|
381
411
|
} else {
|
|
382
|
-
starting = joinNet()
|
|
412
|
+
starting = joinNet().then(async () => {
|
|
413
|
+
const taken = (await claimOpened()) && (await promote(false));
|
|
414
|
+
if (taken && !taken.promoted) tell(taken.text, "warning");
|
|
415
|
+
});
|
|
383
416
|
}
|
|
384
417
|
}
|
|
385
418
|
starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
|
|
@@ -408,7 +441,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
408
441
|
if (["subagent", "subagent_resume"].includes(event.toolName) && !isTower && !barred && role()) for (const ms of KID_MARK_MS) setTimeout(() => void publish(), ms).unref();
|
|
409
442
|
});
|
|
410
443
|
|
|
411
|
-
pi.on("session_shutdown", async () => {
|
|
444
|
+
pi.on("session_shutdown", async (event) => {
|
|
412
445
|
if (gui) await switchRemote(gui, false, guiDir, ts).catch(() => {});
|
|
413
446
|
await gui?.stop();
|
|
414
447
|
gui = undefined;
|
|
@@ -423,9 +456,15 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
|
|
|
423
456
|
const held = holdsTower;
|
|
424
457
|
holdsTower = false;
|
|
425
458
|
if (!role() || !at) return;
|
|
426
|
-
await report(run, "pane", at.paneId, { sb_role: null, sb_order: null, sb_mark: null, sb_indent: null, sb_tab: null }).catch(() => {});
|
|
459
|
+
await report(run, "pane", at.paneId, { sb_role: null, sb_opened_by: null, sb_order: null, sb_mark: null, sb_indent: null, sb_tab: null }).catch(() => {});
|
|
427
460
|
await clearOrder(run, [...kids]);
|
|
428
461
|
if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
|
|
462
|
+
// A tower that quits leaves its tab as a plain shell: close the tab, but only when this pane is all it holds. A reload, /new, resume or fork keeps the tab.
|
|
463
|
+
if (isTower && event.reason === "quit") {
|
|
464
|
+
const all = await panes(run, at.workspaceId).catch(() => []);
|
|
465
|
+
const tab = all.find((p) => p.pane_id === at.paneId)?.tab_id;
|
|
466
|
+
if (tab && all.every((p) => p.tab_id !== tab || p.pane_id === at.paneId)) await run(["tab", "close", tab]).catch(() => {});
|
|
467
|
+
}
|
|
429
468
|
});
|
|
430
469
|
|
|
431
470
|
// /boss gui is a read-only viewer: any session may open it, and it joins nothing and writes no token. A session outside the net
|
package/src/opener.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createTab, report, panes, startPi, type HerdrRun, type Session, type Sleep, waitFor, workspaces } from "./herdr.ts";
|
|
3
|
+
|
|
4
|
+
// The one thing a tower does besides routing: when no session owns a request, it opens a main for it (docs/how-it-works.md, "Opening a main").
|
|
5
|
+
// It is narrow on purpose: one new tab in the tower's own workspace, started without focus, in which Straw Boss starts pi. It types into no pane
|
|
6
|
+
// that already exists, and `STRAW_BOSS_ROLE=main` makes that pi promote itself once it has joined (index.ts).
|
|
7
|
+
|
|
8
|
+
const MAIN_WAIT_MS = 60_000;
|
|
9
|
+
/** At most this many mains opened by towers are alive in one workspace, and at most one per sender. */
|
|
10
|
+
const MAX_OPENED_MAINS = 2;
|
|
11
|
+
|
|
12
|
+
/** A limit was hit: nothing opened, and the same request may be tried again once an opened main has finished. */
|
|
13
|
+
class OpenLimitError extends Error {}
|
|
14
|
+
|
|
15
|
+
export interface OpenerEnv {
|
|
16
|
+
run: HerdrRun;
|
|
17
|
+
sleep: Sleep;
|
|
18
|
+
/** The tower's own workspace: a request for another workspace goes to that workspace's tower. */
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
/** Where the new pi runs when the workspace has no checkout path of its own. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
sessions(): Promise<Session[]>;
|
|
23
|
+
send(to: string, message: string): Promise<{ status: string; code?: string; detail?: string }>;
|
|
24
|
+
}
|
|
25
|
+
export interface OpenRequest { request: string; sender: string; label?: string }
|
|
26
|
+
export interface Opened { tab: string; pane: string; main: string; sender: string; repeated?: true }
|
|
27
|
+
|
|
28
|
+
/** The text the new main starts from: who asked, and what, with nothing summarised. */
|
|
29
|
+
export const handoff = (workspace: string, sender: Session, request: string) =>
|
|
30
|
+
[
|
|
31
|
+
`Straw Boss: the tower of workspace ${workspace} opened this tab for you because no session owned the request below, and you are its main agent.`,
|
|
32
|
+
"Do the work, dispatching workers as your main agent prompt describes, and report the outcome to the sender with intercom send.",
|
|
33
|
+
`Sender: ${sender.name ?? sender.id} (session id ${sender.id})`,
|
|
34
|
+
"Request, verbatim:",
|
|
35
|
+
request,
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Opens a main for a request, once, within the limits above. The same sender and request never open a second main, whether the first attempt is still running, succeeded,
|
|
40
|
+
* or failed (a failed attempt closes the tab it created and is not retried: the sender hears of it instead).
|
|
41
|
+
*/
|
|
42
|
+
export function opener(env: OpenerEnv) {
|
|
43
|
+
const seen = new Map<string, Promise<Opened>>();
|
|
44
|
+
const open = async ({ request, sender: asked, label }: OpenRequest): Promise<Opened> => {
|
|
45
|
+
const { run, sleep } = env;
|
|
46
|
+
const sessions = await env.sessions();
|
|
47
|
+
const matches = sessions.filter((s) => s.id === asked || s.name === asked);
|
|
48
|
+
if (matches.length !== 1) throw new Error(`${matches.length ? "Several sessions are named" : "No live session is named or has the id"} "${asked}", so the new main could not report back to it`);
|
|
49
|
+
const sender = matches[0]!;
|
|
50
|
+
const workspace = (await workspaces(run)).find((w) => w.workspace_id === env.workspaceId);
|
|
51
|
+
if (!workspace) throw new Error(`Workspace ${env.workspaceId} is not open any more`);
|
|
52
|
+
// Limits come from live Herdr panes carrying the marker below, so they hold across a tower restart: a closed tab takes its marker with it, and
|
|
53
|
+
// a main that died leaves one that expires with its token TTL. Opens are serialised, so the count and the marker cannot race.
|
|
54
|
+
const marked = (await panes(run, workspace.workspace_id)).filter((p) => p.tokens?.sb_opened_by);
|
|
55
|
+
const ofSender = marked.filter((p) => p.tokens!.sb_opened_by === sender.id).length;
|
|
56
|
+
if (ofSender) throw new OpenLimitError(`Sender ${sender.name ?? sender.id} already has ${ofSender === 1 ? "an opened main" : `${ofSender} opened mains`} running in workspace ${workspace.label}; a sender gets one at a time. Wait for it to finish, or send the request to it.`);
|
|
57
|
+
if (marked.length >= MAX_OPENED_MAINS) throw new OpenLimitError(`Workspace ${workspace.label} already has ${marked.length} mains opened by its tower (the limit is ${MAX_OPENED_MAINS}); try again when one has finished.`);
|
|
58
|
+
const { tabId, paneId } = await createTab(run, workspace.workspace_id, workspace.worktree?.checkout_path ?? env.cwd, ["STRAW_BOSS_ROLE=main"], label);
|
|
59
|
+
try {
|
|
60
|
+
// The marker counts toward the limits at once; sb_open_main authorises exactly one pi in this shell to promote itself (it consumes the token).
|
|
61
|
+
await report(run, "pane", paneId, { sb_opened_by: sender.id, sb_open_main: "1" });
|
|
62
|
+
// Herdr agent names are unique across workspaces and lowercase only.
|
|
63
|
+
await startPi(run, sleep, `main-${randomUUID().slice(0, 8)}`, paneId, ["--name", `main · ${label ?? workspace.label}`]);
|
|
64
|
+
// The pi is the tab's main once it has published its role; only then does it get the request, so its first prompt is the main agent's.
|
|
65
|
+
const main = await waitFor(sleep, async () => {
|
|
66
|
+
const role = (await panes(run, workspace.workspace_id)).find((p) => p.pane_id === paneId)?.tokens?.sb_role;
|
|
67
|
+
return role === "main" ? (await env.sessions()).find((s) => s.herdrLocation?.paneId === paneId) : undefined;
|
|
68
|
+
}, MAIN_WAIT_MS, 500);
|
|
69
|
+
if (!main) throw new Error(`The pi in pane ${paneId} did not become the main agent of its tab within ${MAIN_WAIT_MS / 1000} s`);
|
|
70
|
+
const sent = await env.send(main.id, handoff(workspace.label, sender, request));
|
|
71
|
+
if (sent.status !== "sent") throw new Error(`The new main ${main.name ?? main.id} did not take the request: ${sent.status} ${sent.code ?? ""} ${sent.detail ?? ""}`.trim());
|
|
72
|
+
return { tab: tabId, pane: paneId, main: main.id, sender: sender.id };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Nothing was handed over, so no work is lost with the tab.
|
|
75
|
+
await run(["tab", "close", tabId]).catch(() => {});
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
80
|
+
return (input: OpenRequest): Promise<Opened> => {
|
|
81
|
+
const key = createHash("sha256").update(JSON.stringify([input.sender, input.request])).digest("hex");
|
|
82
|
+
const prior = seen.get(key);
|
|
83
|
+
if (prior) return prior.then((opened) => ({ ...opened, repeated: true as const }), (error: Error) => Promise.reject(new Error(`A main was already tried for this request and is not opened again: ${error.message}`)));
|
|
84
|
+
const pending = queue.then(() => open(input), () => open(input));
|
|
85
|
+
queue = pending.catch(() => {});
|
|
86
|
+
seen.set(key, pending);
|
|
87
|
+
pending.catch((error) => error instanceof OpenLimitError && seen.get(key) === pending && seen.delete(key));
|
|
88
|
+
return pending;
|
|
89
|
+
};
|
|
90
|
+
}
|