pi-straw-boss 0.39.2 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,9 +30,11 @@ Run a few agents on the same project and they start stepping on each other: two
30
30
  - **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`.
31
31
  - **A tower per workspace.** It opens in its own tab, pinned first, when agents start to collide: a member dispatches a subagent (it promotes itself first), a workspace has two members while another workspace already has a tower, an agent promotes itself, or you type `/boss tower`. Mains and the tower go together: a tower that opens promotes each tab's earliest member, and a tower that quits turns every main back into a member (its subagents stay). It 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.
32
32
  - **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
33
+ - **Towers talk to each other.** A tower lists every workspace's tower (`tower_list`: who a message for it reaches, what its tabs work on) and writes to one or all of them (`tower_send`); a workspace without a tower is reached through its mains, else its members. Every `call_tower`, `boss_broadcast`, and `tower_send` message opens with a header the code writes: who sent it, in what role, to whom, and whether a reply is expected ([how](docs/how-it-works.md#talking-between-towers)).
34
+ - **A tower closes the mains it opened.** A main a tower opened for a request reports to the sender and to the tower, and the tower closes it (`boss_close_main`) when nothing is running in it ([how](docs/how-it-works.md#closing-an-opened-main)).
33
35
  - **A digest for the tower**: joins, subagent launches, and renames in its workspace since its last run, read into its next run once and cleared ([how](docs/how-it-works.md#the-towers-digest)).
34
36
  - **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, and Space opens the next one, F enlarges the top dialog, and Escape closes it (none of them while you type); a question is answered from the keyboard too: arrows choose, Tab moves between questions, and Enter confirms and goes on. A header switch raises a system notification, and another plays a sound, when a unit waits on you, fails, or finishes; finished units wait in a list of their own, cleared one by one or all at once, and a finished subagent on the map is dismissed one by one or all at once (Dismiss finished in the header). A unit's comms show its background monitors, and one that fails or goes quiet raises a notice (a quiet entry under the header's Notices button; only a unit waiting on you raises the prominent alert); a unit that is idle while a subagent of it works, or while a monitor of it runs, shows as commanding with a glow, and one that only waits on a running monitor shows its note in red. On a desktop a unit's terminal takes your keyboard once you click into it. It reads in English or Traditional Chinese, following your browser, with a language menu in the header. + New base, the last card on the map, opens a base in a folder you pick with one member and no tower. A base without a tower takes more members (+ Member on the base adds one in a new tab, on a squad beside that squad), and its tower opens by the rules above; a base with a tower takes new mains (+ Main), each in a tab of its own. In a session's composer, a file pasted, dropped, or picked with + (20 MB at most) is stored in a private temp folder and its path goes into the message. × closes a base, a squad, or (in its view) a unit after a confirmation: each agent quits pi as `/quit` would, then its pane, tab, or workspace closes; nothing closes if one of them refuses. Each opening form takes an optional first message, and the folder picker stays inside your home folder and skips hidden folders.
35
- - **Broadcast** to a tab, a workspace, every tower, or every session.
37
+ - **Broadcast** to a tab, a workspace, every tower (a workspace without one, through its mains or members), or every session.
36
38
  - **Tidy tabs.** When a pi starts in a tab or quits out of it, the tab's panes are made equal again, so workers split off one by one don't leave a chain of halves.
37
39
  - **Stranded subagents are asked to report.** A subagent that stays open after finishing, while the pi that launched it was restarted and no longer receives its result, is asked once over intercom to send its final report there ([how it works](docs/how-it-works.md#orphaned-subagents)).
38
40
  - **A net view in Herdr's sidebar.** An optional plugin turns the Agents list into the net, towers first and each subagent under its launcher, on by default and switched by one key.
@@ -80,9 +82,12 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
80
82
  |---|---|
81
83
  | `boss_promote` | Become this tab's main agent (refused while it has one). |
82
84
  | `boss_status` | Main agents, this workspace's tower, and the other towers. |
83
- | `call_tower` | Message this workspace's tower, or another workspace's. |
85
+ | `call_tower` | Message this workspace's tower, or another workspace's (under a header; `reply: false` for a notice). |
84
86
  | `boss_open_main` | Tower only: open a tab with a new main agent for a request no session owns. |
85
- | `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
87
+ | `boss_close_main` | Tower only: close a main this tower opened, once nothing runs in it. |
88
+ | `tower_list` | Tower only: every workspace's tower, who a message for it reaches, its state, and what its tabs work on. |
89
+ | `tower_send` | Tower only: message one other tower (by workspace id or label), or all of them. |
90
+ | `boss_broadcast` | Message a tab, a workspace, all towers, or every session (under a header). |
86
91
  | `boss_rename` | Name this session with a short phrase for its current work; the net and the GUI show it, and a main agent's tab takes it as its label. Main agents call it when their work changes. |
87
92
  | `/boss` | Take over as this tab's main agent. |
88
93
  | `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
package/README.zh-TW.md CHANGED
@@ -30,9 +30,11 @@
30
30
  - **每個分頁一個主智慧體,靠升任產生。** 工作變多的智慧體呼叫 `boss_promote`,或在第一次派出子智慧體(subagent)時自動升任。一個分頁只有一個主智慧體,除了你用 `/boss` 接手以外,沒有人能取代它。
31
31
  - **每個工作區一座塔台。** 工作區出現第一個主智慧體時,塔台就在自己的分頁開起來,固定排在最前面。它負責協調各個主智慧體和其他塔台;沒有 session 負責的工作,它會開一個分頁、啟動新的主智慧體來處理。你不用跟它說話。
32
32
  - **即時儀表板**在塔台裡:誰加入了,他們和他們的子智慧體正在做什麼。
33
+ - **塔台之間可以直接溝通。** 塔台能列出每個工作區的塔台(`tower_list`:訊息會送到誰、各分頁在做什麼),也能對其中一座或全部發訊息(`tower_send`);沒有塔台的工作區,訊息改送它的主智慧體,沒有主智慧體就送成員。`call_tower`、`boss_broadcast`、`tower_send` 的每則訊息開頭都有一行程式寫的標頭:誰送的、什麼角色、送給誰、要不要回覆([細節](docs/how-it-works.md#talking-between-towers),英文)。
34
+ - **塔台會關掉自己開的主智慧體。** 塔台替請求開出的主智慧體做完後,向發話者和那座塔台各回報一次;塔台確認裡面沒有東西在跑,就用 `boss_close_main` 把它關掉([細節](docs/how-it-works.md#closing-an-opened-main),英文)。
33
35
  - **給塔台的摘要**:自塔台上一輪以來,工作區裡有誰加入、啟動了哪些子智慧體、誰改了名字;塔台下一輪會讀進來一次,讀完就清掉([細節](docs/how-it-works.md#the-towers-digest))。
34
36
  - **瀏覽器畫面** `/boss gui` 看得到整張網:每個工作區是一座基地,裡面有塔台、智慧體和子智慧體,畫面會動,但只能看。點基地可以放大,點單位可以把它的對話當成即時通訊紀錄來讀;在等你回覆的單位會排成警示,按空白鍵開下一則,按 F 放大最上面的對話框,按 Esc 關掉它(打字時都不會觸發);回答問題也可以只用鍵盤:方向鍵選項目,Tab 換題,Enter 確認並跳到下一題。頂端有兩個開關:單位在等你、出錯或完成時,一個會跳系統通知,一個會播音效;已完成的單位另外排在自己的清單裡,可以一個一個清,也可以一次全清;地圖上已結束的子智慧體也可以一個一個移除,或用頂端的「移除已完成」一次全移除。單位的通訊畫面也會列出它在背景跑的 Monitor,出錯或太久沒輸出的 Monitor 會列進標頭的「通知」(只有在等你回答的單位才會跳出醒目的警示,出錯和完成都只是安靜的通知);自己閒置、但它的子智慧體在工作或等待,或它的 Monitor 還在跑的單位,會顯示成帶光暈的「指揮中」;只在等 Monitor 跑完的單位,備註會用紅字顯示「監看中」。在桌面上點進單位的終端機,就能直接用鍵盤操作它。畫面有英文和正體中文,預設跟隨瀏覽器語言,也可以從頂端的語言選單切換。地圖最後一張「+新基地」會在選定的資料夾開一座基地,裡面先有一位成員、沒有塔台。沒有塔台的基地只能加成員(基地上的「+成員」開在新分頁,小隊上的開在該小隊旁),等成員自己升格時才會開塔台;有塔台的基地只能加主智慧體(「+主智慧體」,各開在自己的分頁)。在 session 的輸入框貼上、拖進或用「+」選的檔案(最多 20 MB)會存到私有的暫存資料夾,路徑放進訊息。開啟表單都可以先填第一句指令;資料夾選擇只能在家目錄以下,也不會列出點開頭的資料夾。
35
- - **廣播**給一個分頁、一個工作區、所有塔台,或所有 session。
37
+ - **廣播**給一個分頁、一個工作區、所有塔台(沒有塔台的工作區改送主智慧體或成員),或所有 session。
36
38
  - **分頁保持整齊。** pi 在分頁裡啟動或結束時,分頁裡的 pane 會重新平均分配,一個一個切出來的 worker 就不會留下一長串對半切的窄 pane。
37
39
  - **斷線的子智慧體會被提醒回報。** 子智慧體做完後還開著,但開出它的 pi 已經重開、收不到它的結果時,它會收到一則 intercom,請它把最終報告送回去,只會送一次([運作方式](docs/how-it-works.md#orphaned-subagents))。
38
40
  - **Herdr 側邊欄的網路檢視。** 一個選用的 plugin 會把 Agents 清單排成網的樣子:塔台在前,每個子智慧體排在開出它的人底下。預設開啟,按一個鍵就能切換。
@@ -80,9 +82,12 @@ pi install npm:pi-straw-boss
80
82
  |---|---|
81
83
  | `boss_promote` | 成為這個分頁的主智慧體(分頁已經有主智慧體時會被拒絕)。 |
82
84
  | `boss_status` | 列出各個主智慧體、這個工作區的塔台,以及其他塔台。 |
83
- | `call_tower` | 傳訊息給這個工作區的塔台,或別的工作區的塔台。 |
85
+ | `call_tower` | 傳訊息給這個工作區的塔台,或別的工作區的塔台(訊息開頭有標頭;只是通知時可設 `reply: false`)。 |
84
86
  | `boss_open_main` | 只有塔台能用:替沒有 session 負責的請求開一個分頁,啟動新的主智慧體。 |
85
- | `boss_broadcast` | 傳訊息給一個分頁、一個工作區、所有塔台,或所有 session。 |
87
+ | `boss_close_main` | 只有塔台能用:關掉這座塔台自己開的主智慧體,裡面沒有東西在跑時才會關。 |
88
+ | `tower_list` | 只有塔台能用:列出每個工作區的塔台、訊息會送到誰、它的狀態,以及各分頁在做什麼。 |
89
+ | `tower_send` | 只有塔台能用:傳訊息給另一座塔台(用工作區 id 或名稱),或全部塔台。 |
90
+ | `boss_broadcast` | 傳訊息給一個分頁、一個工作區、所有塔台,或所有 session(訊息開頭有標頭)。 |
86
91
  | `boss_rename` | 用一個短語替這個 session 命名,說明它現在在做什麼;net 與 GUI 會顯示它,主智慧體的分頁也會改成這個名稱。主智慧體換工作時會呼叫它。 |
87
92
  | `/boss` | 接手成為這個分頁的主智慧體。 |
88
93
  | `/boss status`、`/boss tower` | 顯示狀態;開啟塔台並切換過去。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.39.2",
3
+ "version": "0.40.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/main.md CHANGED
@@ -7,4 +7,5 @@ You are the main agent of Herdr tab {{tab}}: the boss (the user) works with you
7
7
  - Check each result against its brief. Merging to a shared branch or deploying needs the boss's authorization.
8
8
  - When your work turns to another task, `boss_rename` yourself with a phrase for it (at most 16 characters).
9
9
  - boss_status finds coordinators, call_tower reaches the tower (report coordination friction there), boss_broadcast broadcasts to the net.
10
+ - If a tower opened your tab, report the outcome to the sender and to that tower with intercom send when done, then wait to be closed (say so if you await a boss decision).
10
11
  - Done when every task has landed or is reported blocked and its worktree is removed.
package/prompts/tower.md CHANGED
@@ -2,7 +2,8 @@ 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. Write to agents terse, caveman-style: point first, no ceremony; keep negations, numbers, paths, IDs, and quoted decisions verbatim; full sentences where a fragment could mislead; a brief still carries its goal, context, reasons with their cause, scope, what others own, and acceptance check, because a thin brief makes workers redo or miss work; a reader unsure of the intent restates it or asks before acting.
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, 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}}.
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. Other towers: tower_list shows each workspace's tower and what it works on, tower_send writes to one or all. The head tower, the tower of workspace {{head}}, is a mark and no rank. Each message opens with a code-written header (sender, role, `to`, reply expected or none): never forward to a tower its `to` already names, and report back once after acting on another tower's or a main's request. boss_status and call_tower route inside your workspace.
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
+ - Close a main you opened with boss_close_main after its done report, unless it awaits a boss decision; if it refuses as busy, retry after its next message.
7
8
  - Never ask the boss (no ask_user, robot_hand, focus changes); boss decisions go to the owning main agent.
8
9
  - Do no work yourself: no commands, process signals, edits, or subagents; even a one-step fix goes to its owner.
@@ -266,10 +266,8 @@ export async function startSidecar({ pane, kind, session, record, cli = "straw-n
266
266
  return { text: said, role: kit.role() };
267
267
  },
268
268
  "call-tower": async (r) => {
269
- const message = text(r.message, "message"), tower = await reach.openTower(typeof r.workspace === "string" ? r.workspace : undefined);
270
- const result = await reach.send(tower.id, message);
271
- 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());
272
- return { text: `Sent to the tower of ${tower.target.label}${tower.routed ? ` (${tower.routed.label} is a sub workspace)` : ""}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`, workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened };
269
+ const { text: said, details } = await reach.callTower(text(r.message, "message"), typeof r.workspace === "string" ? r.workspace : undefined);
270
+ return { text: said, workspace: details.workspace, towerId: details.towerId, opened: details.opened };
273
271
  },
274
272
  broadcast: async (r) => {
275
273
  const scope = (r.scope ?? "workspace") as Scope;
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { subagents } from "./dashboard.ts";
3
+ import { panes, waitFor, workspaces } from "./herdr.ts";
4
+ import { type Kit, REGISTER } from "./state.ts";
5
+
6
+ // A tower closes the mains it opened (boss_open_main). It asks the main's own extension to quit as /boss close does, and the main decides: it checks that the
7
+ // request is from the tower of its workspace, that a tower opened it (its pane's `sb_opened_by` token), and that nothing running would be lost, as a stash
8
+ // checks. The ask goes over one intercom extension channel that towers and the mains a tower opened register, `straw-boss/control`, with the "capable" audience;
9
+ // the sender id is the one the session reports to the broker (self-reported, a same-user trust boundary), and a request is for one session (`to`). It does not go through the GUI server, which may be stopped, and a bridge's close
10
+ // (bridge.ts) answers only that server.
11
+ // {type: "close-main", to: <main session id>, reqId} tower → main
12
+ // {type: "close-main-result", to: <tower session id>, reqId, accepted, reason?} main → tower
13
+
14
+ export const CONTROL = "straw-boss/control";
15
+ const ASK_MS = 10_000, RETRY_MS = 15_000, RETRY_STEP_MS = 3000, QUIT_MS = 30_000;
16
+ type Event = { type: string; fromSessionId?: string; payload?: Record<string, unknown> };
17
+ interface Answer { from: string; accepted: boolean; reason?: string; busy?: boolean }
18
+ interface Closed { text: string; main: string; name: string; closed: boolean }
19
+ export type CloseMain = (query: string) => Promise<Closed>;
20
+
21
+ /** `closeSelf` quits this session as /boss close does; `check` says why it cannot be stashed now (the bridge's check: a turn, a queue, a dialog, a monitor). */
22
+ export function createCloseMain({ pi, cfg, s, me }: Kit, { closeSelf, check }: { closeSelf(keepPane: boolean): string | undefined; check(): string | undefined }) {
23
+ const { run, sleep } = cfg;
24
+ let channel: { publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void } | undefined;
25
+ const answers = new Map<string, Answer>();
26
+ const send = (payload: Record<string, unknown>) => channel?.publish({ version: 1, ...payload }, { audience: "capable" });
27
+
28
+ /** Why this main will not close for `from`; none when it will. `busy`: something running would be lost, which may pass; the rest is a no. */
29
+ const judge = async (from: string): Promise<{ reason: string; busy?: true } | undefined> => {
30
+ const no = (reason: string) => ({ reason });
31
+ if (s.isTower) return no("this session is a tower");
32
+ if (!s.joined) return no("this session has not joined the net");
33
+ const [all, workspace] = await Promise.all([panes(run), workspaces(run).then((list) => list.find((w) => w.workspace_id === s.at.workspaceId))]);
34
+ const mine = all.find((p) => p.pane_id === s.at.paneId);
35
+ if (!mine?.tokens?.sb_opened_by) return no("no tower opened this main, so only the boss closes it");
36
+ if (!workspace?.tokens?.tower || workspace.tokens.tower !== from) return no("the request is not from the tower of this workspace");
37
+ const running = subagents(mine, all, cfg.now()).filter((row) => !row.ended).length;
38
+ const busy = [check(), running && `${running} subagent${running > 1 ? "s are" : " is"} running`].filter(Boolean).join(", ");
39
+ return busy ? { reason: busy, busy: true } : undefined;
40
+ };
41
+ const answer = async (from: string, reqId: string) => {
42
+ const refused = await judge(from).catch((error: Error) => ({ reason: `it could not check itself: ${error.message}` }));
43
+ const quit = refused ? undefined : closeSelf(false);
44
+ const why = refused ?? (quit ? { reason: quit } : undefined);
45
+ send({ type: "close-main-result", to: from, reqId, accepted: !why, ...(why ?? {}) });
46
+ };
47
+ const onEvent = (event: Event) => {
48
+ const p = event.payload, from = event.fromSessionId;
49
+ if (event.type !== "message" || p?.version !== 1 || !from || typeof p.reqId !== "string" || p.reqId.length > 64) return;
50
+ if (p.type === "close-main" && p.to === me()) void answer(from, p.reqId);
51
+ if (p.type === "close-main-result" && p.to === me() && typeof p.accepted === "boolean") answers.set(p.reqId, { from, accepted: p.accepted, ...(typeof p.reason === "string" ? { reason: p.reason.slice(0, 300) } : {}), ...(p.busy === true ? { busy: true } : {}) });
52
+ };
53
+
54
+ /** The ask: the answer of the session asked, or none within ASK_MS (an older pi-straw-boss never registered the channel). */
55
+ const ask = async (main: string) => {
56
+ const reqId = randomUUID().slice(0, 12);
57
+ send({ type: "close-main", to: main, reqId });
58
+ const got = await waitFor(sleep, () => (answers.get(reqId)?.from === main ? answers.get(reqId) : undefined), ASK_MS, 100);
59
+ answers.delete(reqId);
60
+ return got;
61
+ };
62
+
63
+ return {
64
+ /** Every tower, and every main a tower opened, hears and answers asks; registering twice is a no-op. */
65
+ register() {
66
+ if (channel) return;
67
+ pi.events.emit(REGISTER, { namespace: CONTROL, ownerEligible: false, onReady: (c: NonNullable<typeof channel>) => (channel = c), onEvent });
68
+ if (!channel) throw new Error("pi-intercom is not loaded (Straw Boss needs pi-intercom >= 0.17.1)");
69
+ },
70
+ /** The tower's side: a main this workspace's tower opened, by intercom name or session id. */
71
+ closeMain: (async (query: string) => {
72
+ if (!channel) throw new Error("This session cannot reach the mains' control channel yet");
73
+ const [sessions, all] = await Promise.all([s.c.listSessions(), panes(run)]);
74
+ const found = sessions.filter((x) => x.id === query || x.name === query);
75
+ if (found.length !== 1) throw new Error(`${found.length ? "Several sessions are named" : "No live session is named or has the id"} "${query}"`);
76
+ const main = found[0]!, name = main.name ?? main.id, pane = all.find((p) => p.pane_id === main.herdrLocation?.paneId);
77
+ if (pane?.workspace_id !== s.at.workspaceId) throw new Error(`${name} is not in this workspace; a tower closes only the mains it opened here`);
78
+ if (pane.tokens?.sb_role !== "main") throw new Error(`${name} is not a main agent`);
79
+ if (!pane.tokens.sb_opened_by) throw new Error(`${name} was not opened by a tower (the boss opened it), so only the boss closes it`);
80
+ if (!["idle", "done"].includes(pane.agent_status ?? "")) throw new Error(`${name} is ${pane.agent_status ?? "in an unknown state"}; try again when it is idle`);
81
+ // A main reports its outcome in the turn that ends right after, so a refusal for something running is asked again for a while; any other refusal is final.
82
+ let got = await ask(main.id);
83
+ for (let waited = 0; got?.busy && waited < RETRY_MS; waited += RETRY_STEP_MS) (await sleep(RETRY_STEP_MS), (got = await ask(main.id)));
84
+ if (!got) throw new Error(`${name} did not answer the close request within ${ASK_MS / 1000} s (an older pi-straw-boss, or a stuck session); close it in its terminal`);
85
+ if (!got.accepted) throw new Error(`${name} will not close: ${got.reason ?? "refused"}`);
86
+ const gone = await waitFor(sleep, async () => !(await s.c.listSessions().catch(() => [main])).some((x) => x.id === main.id), QUIT_MS, 500);
87
+ return { text: gone ? `${name} quit and its pane closed.` : `${name} accepted and is shutting down; its pane closes once pi has exited.`, main: main.id, name, closed: Boolean(gone) };
88
+ }) as CloseMain,
89
+ };
90
+ }
91
+ export type Control = ReturnType<typeof createCloseMain>;
package/src/header.ts ADDED
@@ -0,0 +1,31 @@
1
+ // The line that opens every message Straw Boss sends through intercom for a session (call_tower, boss_broadcast, tower_send, a handoff). The code
2
+ // writes it, never the model, so a receiver can tell from the line alone who sent the message and in what role, what kind of message it is, who else
3
+ // got it, and whether the sender waits for a reply. It has the shape of the line a net sidecar types before a message (src/agent/sidecar.ts), with the
4
+ // sender's own fields added:
5
+ // [straw net from=<name> id=<session id> role=<tower|main|member|session> [head=yes] workspace=<label> kind=<kind> scope=<scope> to=<recipients> reply=<expected|none>] <message>
6
+ // The sidecar puts its own line, with the role Herdr's tokens give the sender, before this one; the two are not merged.
7
+
8
+ export type Kind = "call_tower" | "broadcast" | "tower_send" | "handoff";
9
+ /** `session` is a pi session that did not join the net (a scope `all` broadcast reaches it). `head`: the tower of the head workspace. */
10
+ export interface Party { name: string; id: string; role: "tower" | "main" | "member" | "session"; head?: boolean; workspace?: string }
11
+ export interface Stamp { from: Party; kind: Kind; scope: string; to: Party[]; reply: boolean }
12
+
13
+ /** Control characters and runs of whitespace become one space, so a name cannot start a second line or a second field. */
14
+ export const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/\s+/g, " ").trim();
15
+ const quote = (value: string) => (/[\s"\]=;]/.test(clean(value)) ? `"${clean(value).replace(/"/g, "'")}"` : clean(value));
16
+ const roleOf = (p: Party) => (p.head ? "head tower" : p.role);
17
+ /** A header names at most this many recipients, then how many more there are (a scope `all` broadcast can reach every session on the machine). */
18
+ const MAX_NAMED = 20;
19
+ const recipient = (p: Party) => `${clean(p.name)} (${roleOf(p)}${p.workspace ? ` of ${clean(p.workspace)}` : ""})`;
20
+
21
+ export function header({ from, kind, scope, to, reply }: Stamp): string {
22
+ const fields = [
23
+ `from=${quote(from.name || from.id)}`, `id=${quote(from.id)}`, `role=${from.role}`, ...(from.head ? ["head=yes"] : []),
24
+ ...(from.workspace ? [`workspace=${quote(from.workspace)}`] : []),
25
+ `kind=${kind}`, `scope=${scope}`, `to=${quote([...to.slice(0, MAX_NAMED).map(recipient), ...(to.length > MAX_NAMED ? [`+${to.length - MAX_NAMED} more`] : [])].join("; "))}`, `reply=${reply ? "expected" : "none"}`,
26
+ ];
27
+ return `[straw net ${fields.join(" ")}]`;
28
+ }
29
+
30
+ /** The message under its header. */
31
+ export const stamp = (s: Stamp, message: string) => `${header(s)} ${message}`;
@@ -0,0 +1,21 @@
1
+ import type { Digest } from "./digest.ts";
2
+ import { headOf, type HerdrRun, workspaces } from "./herdr.ts";
3
+ import type { HerdrEvents } from "./server-watch.ts";
4
+
5
+ // The head tower is the tower of the first workspace in Herdr's list that is not a sub workspace, so it changes when workspaces come, go, or move. A tower
6
+ // that sees it change writes one digest line in its own workspace ("head tower is now Alpha"), which it reads at its next run (digest.ts): each tower
7
+ // notes it for itself, so every tower hears of it however it was told to coordinate before.
8
+ const HEAD_EVENTS = ["workspace.created", "workspace.closed", "workspace.moved", "workspace.reordered"];
9
+
10
+ /** Watches the head workspace from now on; the first reading is the baseline and notes nothing. Returns the stop function. */
11
+ export function watchHead({ run, events, digest, workspaceId }: { run: HerdrRun; events: HerdrEvents; digest: Digest; workspaceId: string }): () => void {
12
+ let known: string | undefined, reading: Promise<unknown> = Promise.resolve();
13
+ // Readings run one at a time, so two events in a row note a change once.
14
+ const read = () => void (reading = reading.then(async () => {
15
+ const head = headOf(await workspaces(run));
16
+ if (!head) return;
17
+ if (known !== undefined && known !== head.workspace_id) await digest.note(workspaceId, `head tower is now ${head.label}`);
18
+ known = head.workspace_id;
19
+ }).catch(() => {}));
20
+ return events(HEAD_EVENTS, () => read());
21
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { startBridge } from "./bridge.ts";
4
4
  import { interceptUi } from "./intercept.ts";
5
5
  import { registerBoss } from "./boss.ts";
6
6
  import { createBriefing } from "./briefing.ts";
7
+ import { createCloseMain } from "./closemain.ts";
7
8
  import { createDigest } from "./digest.ts";
8
9
  import { afterExit, createClosing } from "./closing.ts";
9
10
  import { createElection } from "./election.ts";
@@ -49,10 +50,11 @@ export function createStrawBoss({ run: given, world: reading, api = herdrApi(),
49
50
  const guard = createGuard(kit, { ...election, openTower: reach.openTower });
50
51
  // A join adds the tools of a joined session, and the tools promote through the membership: each is built with the other's hook.
51
52
  const membership = createMembership(kit, { presence, election, guard, briefing, openTower: reach.openTower, register: () => (tools.addNetTools(), tools.addPromote()) });
52
- const tools = createTools(kit, { ...reach, promote: membership.promote, digest });
53
+ const tools = createTools(kit, { ...reach, promote: membership.promote, digest, closeMain: (query) => control.closeMain(query) });
53
54
  const host = createHost(kit, { bridge, connected: membership.connected });
54
55
  closeSelf = createClosing(kit);
55
- registerSession(kit, { briefing, presence, membership, guard, host, reach, digest, startTower: createTower(kit, { membership, presence, tools }) });
56
+ const control = createCloseMain(kit, { closeSelf, check: bridge.stashCheck });
57
+ registerSession(kit, { briefing, presence, membership, guard, host, reach, digest, control, startTower: createTower(kit, { membership, presence, tools, control, digest }) });
56
58
  registerBoss(kit, { host, membership, reach, closeSelf, stashing: createStashing(kit, { host }) });
57
59
  };
58
60
  }
package/src/opener.ts CHANGED
@@ -21,16 +21,19 @@ export interface OpenerEnv {
21
21
  cwd: string;
22
22
  sessions(): Promise<Session[]>;
23
23
  send(to: string, message: string): Promise<{ status: string; code?: string; detail?: string }>;
24
+ /** The tower itself: the main reports to it as well as to the sender, and it closes the main afterwards (closemain.ts). */
25
+ tower(): { id: string; name: string };
24
26
  }
25
27
  export interface OpenRequest { request: string; sender: string; label?: string }
26
28
  export interface Opened { tab: string; pane: string; main: string; sender: string; repeated?: true }
27
29
 
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
+ /** The text the new main starts from: who asked, and what, with nothing summarised. The main answers the sender and the tower that opened it, then waits to be closed. */
31
+ export const handoff = (workspace: string, sender: Session, request: string, tower: { id: string; name: string }) =>
30
32
  [
31
33
  `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.",
34
+ "Do the work, dispatching workers as your main agent prompt describes. When it is done, report the outcome with intercom send to both: the sender and the tower that opened this tab. Then wait; the tower closes this tab after your done report, so end it with the result and nothing you still have to do. If you wait for a boss decision, say so in your report and the tower leaves you open.",
33
35
  `Sender: ${sender.name ?? sender.id} (session id ${sender.id})`,
36
+ `Tower: ${tower.name} (session id ${tower.id})`,
34
37
  "Request, verbatim:",
35
38
  request,
36
39
  ].join("\n");
@@ -67,7 +70,7 @@ export function opener(env: OpenerEnv) {
67
70
  return role === "main" ? (await env.sessions()).find((s) => s.herdrLocation?.paneId === paneId) : undefined;
68
71
  }, MAIN_WAIT_MS, 500);
69
72
  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));
73
+ const sent = await env.send(main.id, handoff(workspace.label, sender, request, env.tower()));
71
74
  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
75
  return { tab: tabId, pane: paneId, main: main.id, sender: sender.id };
73
76
  } catch (error) {
package/src/reach.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { sessionMark } from "./dashboard.ts";
3
+ import { type Kind, type Party, stamp } from "./header.ts";
2
4
  import { headOf, isSub, type Pane, panes, parentOf, type Session, towerModel, workspaces, type Workspace } from "./herdr.ts";
3
5
  import type { Kit, Scope, SendResult } from "./state.ts";
4
6
  import { ensureTower } from "./towertab.ts";
@@ -81,16 +83,107 @@ export function createReach({ pi, cfg, s, guiDir, me, role, tell }: Kit) {
81
83
  towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
82
84
  };
83
85
  };
84
- const broadcast = async (message: string, scope: Scope) => {
85
- const others = (await s.c.listSessions()).filter((x) => x.id !== me());
86
- const inScope = (m: Member) => ({ tab: m.tab.id === s.at.tabId, workspace: m.workspace.id === s.at.workspaceId, towers: m.role === "tower" })[scope as Exclude<Scope, "all">];
87
- // tab, workspace, and towers reach the net only; all reaches every other pi session on the machine.
88
- const targets = scope === "all" ? others : netMembers(others, await panes(run)).filter(inScope).map((m) => m.s);
89
- const results = await Promise.all(targets.map(async (x) => ({ id: x.id, name: nameOf(x), ...(await send(x.id, message)) })));
90
- const summary = results.map((r) => `${r.name}: ${r.status}${r.code ? ` (${r.code})` : ""}`).join("\n") || "no recipients";
86
+ /** One reading of the net: the live sessions, every pane, every workspace. */
87
+ const read = async () => {
88
+ const [sessions, ps, all] = await Promise.all([s.c.listSessions(), panes(run), workspaces(run)]);
89
+ return { sessions, ps, all };
90
+ };
91
+ type Net = Awaited<ReturnType<typeof read>>;
92
+ const ROLES = new Set(["tower", "main", "member"]);
93
+ /** A session as a header names it: its role is the one its pane's token publishes, `session` for a pi that did not join. */
94
+ const partyOf = (x: Session, net: Net): Party => {
95
+ const token = net.ps.find((p) => p.pane_id === x.herdrLocation?.paneId)?.tokens?.sb_role ?? "";
96
+ const role = ROLES.has(token) ? (token as Party["role"]) : "session";
97
+ const workspace = x.herdrLocation?.workspace;
98
+ return { name: nameOf(x) || x.id, id: x.id, role, ...(role === "tower" && workspace?.id === headOf(net.all)?.workspace_id ? { head: true } : {}), ...(workspace?.label ? { workspace: workspace.label } : {}) };
99
+ };
100
+ const selfParty = (net: Net): Party => {
101
+ const mine = net.sessions.find((x) => x.id === me()), workspace = net.all.find((w) => w.workspace_id === s.at.workspaceId);
102
+ return { name: nameOf(mine) || s.ownName || me(), id: me(), role: role() ?? "session", ...(s.isTower && workspace && workspace === headOf(net.all) ? { head: true } : {}), ...(workspace ? { workspace: workspace.label } : {}) };
103
+ };
104
+ /** The message under its header, once for the whole audience: every recipient reads the same line, with the others named in it. */
105
+ const deliver = async (targets: Session[], message: string, kind: Kind, scope: string, reply: boolean, net: Net) => {
106
+ const line = stamp({ from: selfParty(net), kind, scope, to: targets.map((x) => partyOf(x, net)), reply }, message);
107
+ return Promise.all(targets.map(async (x) => ({ id: x.id, name: nameOf(x), ...(await send(x.id, line)) })));
108
+ };
109
+ /** One session by id, with a header. The result is intercom's. */
110
+ const post = async (to: string, message: string, kind: Kind, scope: string, reply: boolean): Promise<SendResult> => {
111
+ const net = await read();
112
+ const [result] = await deliver([net.sessions.find((x) => x.id === to) ?? { id: to }], message, kind, scope, reply, net);
113
+ return result!;
114
+ };
115
+ const callTower = async (message: string, workspace: string | undefined, reply = true) => {
116
+ const tower = await openTower(workspace);
117
+ const result = await post(tower.id, message, "call_tower", "tower", reply);
118
+ 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());
119
+ const via = tower.routed ? ` (${tower.routed.label} is a sub workspace)` : "";
120
+ const text = `Sent to the tower of ${tower.target.label}${via}${tower.opened ? " (opened it)" : ""}. ${reply ? "It replies through intercom." : "It is told no reply is expected."}`;
121
+ return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
122
+ };
123
+
124
+ /** Who a message for a workspace's tower reaches: its live tower; in a workspace without one, its mains, else its members (a tower only coordinates, so a workspace without one still has someone to hear). */
125
+ const stationsOf = (net: Net) => {
126
+ const members = netMembers(net.sessions, net.ps), head = headOf(net.all);
127
+ return net.all.filter((w) => !isSub(w)).map((workspace) => {
128
+ const tower = net.sessions.find((x) => x.id === workspace.tokens?.tower);
129
+ const here = members.filter((m) => m.workspace.id === workspace.workspace_id && m.role !== "tower");
130
+ const [mains, plain] = [here.filter((m) => m.role === "main").map((m) => m.s), here.filter((m) => m.role === "member").map((m) => m.s)];
131
+ const [via, sessions]: ["tower" | "main" | "member" | "none", Session[]] = tower ? ["tower", [tower]] : mains.length ? ["main", mains] : plain.length ? ["member", plain] : ["none", []];
132
+ return { workspace, head: workspace === head, self: workspace.workspace_id === s.at.workspaceId, via, sessions, members: here };
133
+ });
134
+ };
135
+ type Station = ReturnType<typeof stationsOf>[number];
136
+ /** The stations a message from this session can reach: not itself, and not the fallback sessions of its own workspace (its siblings), which are no tower. */
137
+ const reachable = (net: Net) => stationsOf(net).map((st): Station => ({ ...st, sessions: st.sessions.filter((x) => x.id !== me()) })).filter((st) => st.sessions.length && !(st.self && st.via !== "tower"));
138
+ const noTower = (st: Station) => (st.via === "tower" ? undefined : `no tower in ${st.workspace.label}: ${st.via}`);
139
+ const report = (results: Awaited<ReturnType<typeof deliver>>, notes: Map<string, string>, scope: string) => {
140
+ const summary = results.map((r) => `${r.name}: ${r.status}${r.code ? ` (${r.code})` : ""}${notes.has(r.id) ? ` [${notes.get(r.id)}]` : ""}`).join("\n") || "no recipients";
91
141
  if (results.some((r) => r.status !== "sent")) throw new Error(`Not every recipient received it:\n${summary}`);
92
142
  return { text: `Broadcast to ${results.length} session(s):\n${summary}`, details: { scope, results } };
93
143
  };
94
- return { send, openTower, status, broadcast };
144
+ const broadcast = async (message: string, scope: Scope, reply = false) => {
145
+ const net = await read(), others = net.sessions.filter((x) => x.id !== me());
146
+ const notes = new Map<string, string>();
147
+ let targets: Session[];
148
+ if (scope === "towers") {
149
+ targets = reachable(net).flatMap((st) => st.sessions.map((x) => (noTower(st) && notes.set(x.id, noTower(st)!), x)));
150
+ } else {
151
+ const inScope = (m: Member) => ({ tab: m.tab.id === s.at.tabId, workspace: m.workspace.id === s.at.workspaceId })[scope as "tab" | "workspace"];
152
+ // tab and workspace reach the net only; all reaches every other pi session on the machine.
153
+ targets = scope === "all" ? others : netMembers(others, net.ps).filter(inScope).map((m) => m.s);
154
+ }
155
+ return report(await deliver(targets, message, "broadcast", scope, reply, net), notes, scope);
156
+ };
157
+
158
+ /** Every workspace's tower for a tower to choose from: who hears a message for it, what that session is doing, and what each tab is working on. */
159
+ const towerList = async () => {
160
+ const net = await read(), mark = (x: Session) => ({ id: x.id, name: nameOf(x), state: sessionMark(x, net.ps.find((p) => p.pane_id === x.herdrLocation?.paneId)) });
161
+ const mine = net.all.find((w) => w.workspace_id === s.at.workspaceId);
162
+ const towers = stationsOf(net).map((st) => {
163
+ const tabs = Object.values(Object.groupBy(st.members, (m) => m.tab.id)).map((group = []) => ({ label: group[0]!.tab.label, main: nameOf(group.find((m) => m.role === "main")?.s) || null, members: group.filter((m) => m.role === "member").map((m) => nameOf(m.s)) }));
164
+ return { workspace: { id: st.workspace.workspace_id, label: st.workspace.label }, head: st.head, self: st.self, reach: st.via, sessions: st.sessions.map(mark), tabs };
165
+ });
166
+ const result = { me: { id: me(), role: role(), workspace: { id: s.at.workspaceId, label: mine?.label } }, head: headOf(net.all)?.workspace_id, towers };
167
+ return { text: JSON.stringify(result, null, 2), details: result };
168
+ };
169
+ /** One tower by workspace id or label (a sub workspace goes to its parent), or every other tower; a workspace without a tower is reached through its mains, else its members. */
170
+ const towerSend = async (message: string, workspace: string | undefined, reply = false) => {
171
+ const net = await read(), stations = reachable(net), notes = new Map<string, string>();
172
+ let chosen = stations;
173
+ if (workspace !== undefined) {
174
+ const found = net.all.filter((w) => w.workspace_id === workspace || w.label === workspace);
175
+ if (found.length !== 1) throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${workspace}". Workspaces: ${net.all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
176
+ const asked = found[0]!, target = isSub(asked) ? parentOf(net.all, asked) : asked;
177
+ if (!target) throw new Error(`${asked.label} is a sub workspace with no open parent workspace, so it has no tower`);
178
+ chosen = stations.filter((st) => st.workspace === target);
179
+ if (!chosen.length) {
180
+ const st = stationsOf(net).find((x) => x.workspace === target);
181
+ throw new Error(st?.self && st.via === "tower" ? "This session is the tower of that workspace" : `${target.label} has no tower and no joined session to hear a message`);
182
+ }
183
+ }
184
+ const targets = chosen.flatMap((st) => st.sessions.map((x) => (noTower(st) && notes.set(x.id, noTower(st)!), x)));
185
+ return report(await deliver(targets, message, "tower_send", workspace === undefined ? "towers" : "tower", reply, net), notes, workspace === undefined ? "towers" : "tower");
186
+ };
187
+ return { send, post, callTower, openTower, status, broadcast, towerList, towerSend };
95
188
  }
96
189
  export type Reach = ReturnType<typeof createReach>;
package/src/session.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Briefing } from "./briefing.ts";
2
+ import type { Control } from "./closemain.ts";
2
3
  import type { Digest } from "./digest.ts";
3
4
  import type { Guard } from "./guard.ts";
4
5
  import { answers, newer, readRecord } from "./handover.ts";
@@ -14,7 +15,7 @@ const KID_MARK_MS = [3_000, 15_000];
14
15
  const SUBAGENT_TOOLS = ["subagent", "subagent_resume"];
15
16
 
16
17
  /** What the session does at its start, around each run, and at its end. */
17
- export function registerSession(kit: Kit, { briefing, presence, membership, guard, host, reach, digest, startTower }: { briefing: Briefing; presence: Presence; membership: Membership; guard: Guard; host: ReturnType<typeof createHost>; reach: Reach; digest: Digest; startTower(): Promise<void> }) {
18
+ export function registerSession(kit: Kit, { briefing, presence, membership, guard, host, reach, digest, control, startTower }: { briefing: Briefing; presence: Presence; membership: Membership; guard: Guard; host: ReturnType<typeof createHost>; reach: Reach; digest: Digest; control: Control; startTower(): Promise<void> }) {
18
19
  const { pi, cfg, s, guiDir, me, role, tell } = kit;
19
20
  const { run, env, version } = cfg;
20
21
  const who = () => pi.getSessionName() || s.ownName || me().slice(0, 8);
@@ -48,6 +49,7 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
48
49
  const mine = (await panes(run, s.at.workspaceId).catch(() => [])).find((p) => p.pane_id === s.at.paneId);
49
50
  if (mine?.tokens?.sb_open_main !== "1") return false;
50
51
  s.openedBy = mine.tokens.sb_opened_by;
52
+ pi.appendEntry(ENTRY, { openedBy: s.openedBy ?? null, openedPane: s.at.paneId }); // null, not undefined: a later claim (a pop) overrides an earlier opener
51
53
  await report(run, "pane", s.at.paneId, { sb_open_main: null }).catch(() => {});
52
54
  return true;
53
55
  };
@@ -79,7 +81,13 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
79
81
  if (!s.stopped) unwatch = cfg.watch(presence.restore); // a shutdown that came while Herdr was asked about the workspace has stopped nothing yet
80
82
  starting = membership.joinNet().then(async () => {
81
83
  note(`${who()} joined tab ${s.info.tab}`);
82
- const taken = (await claimOpened()) && (await membership.promote(false));
84
+ const claimed = await claimOpened();
85
+ if (!claimed && event.reason !== "startup" && s.saved.openedBy && s.saved.openedPane === s.at.paneId) s.openedBy = s.saved.openedBy; // only the same pi in the tower's pane (/reload, /fork): `pi --session` anywhere is the boss's main
86
+ if (s.openedBy) {
87
+ control.register(); // the tower that opened this main may ask it to close (closemain.ts), also after a reload
88
+ await presence.publish(); // sb_opened_by was cleared at shutdown
89
+ }
90
+ const taken = claimed && (await membership.promote(false));
83
91
  if (taken && !taken.promoted) tell(taken.text, "warning");
84
92
  });
85
93
  }
@@ -133,6 +141,8 @@ export function registerSession(kit: Kit, { briefing, presence, membership, guar
133
141
  await host.release(event.reason !== "quit"); // a /reload, /new, or /resume starts the GUI again in this process
134
142
  s.dashboard?.stop();
135
143
  s.dashboard = undefined;
144
+ s.headWatch?.();
145
+ s.headWatch = undefined;
136
146
  presence.stop();
137
147
  guard.stop();
138
148
  stopWatching();
package/src/state.ts CHANGED
@@ -24,7 +24,7 @@ export interface Channel {
24
24
  listSessions(): Promise<Session[]>;
25
25
  }
26
26
  export interface SendResult { requestId?: string; status: string; code?: string; detail?: string }
27
- export interface Saved { tower?: boolean }
27
+ export interface Saved { tower?: boolean; openedBy?: string | null; openedPane?: string }
28
28
  export type Role = "tower" | "main" | "member";
29
29
  export type Scope = "tab" | "workspace" | "towers" | "all";
30
30
  /**
@@ -65,6 +65,8 @@ export interface State {
65
65
  queue: Promise<void>;
66
66
  /** Set while this session holds the tower role. */
67
67
  dashboard: ReturnType<typeof startDashboard> | undefined;
68
+ /** A tower's watch on the head workspace (headwatch.ts); stops with the session. */
69
+ headWatch: (() => void) | undefined;
68
70
  // The GUI: the hub and server run in the session that shares it; `viewer` is the channel of a session outside the net that only reads.
69
71
  hub: Hub | undefined;
70
72
  gui: Gui | undefined;
@@ -94,7 +96,7 @@ export function createKit(pi: ExtensionAPI, cfg: Config): Kit {
94
96
  // SAFETY: as `c` above.
95
97
  at: undefined as unknown as Place,
96
98
  mainId: undefined, pinned: undefined, info: {}, ownName: "", openedBy: undefined, kids: new Set(),
97
- ui: undefined, hasUI: false, saved: {}, queue: Promise.resolve(), dashboard: undefined, hub: undefined, gui: undefined, viewer: undefined, quit: undefined, closing: undefined,
99
+ ui: undefined, hasUI: false, saved: {}, queue: Promise.resolve(), dashboard: undefined, headWatch: undefined, hub: undefined, gui: undefined, viewer: undefined, quit: undefined, closing: undefined,
98
100
  };
99
101
  const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
100
102
  const role = (): Role | undefined => {
package/src/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Type, type Static, type TSchema } from "typebox";
2
2
  import type { Digest } from "./digest.ts";
3
3
  import type { Membership } from "./membership.ts";
4
+ import type { CloseMain } from "./closemain.ts";
4
5
  import { opener } from "./opener.ts";
5
6
  import type { Reach } from "./reach.ts";
6
7
  import type { Kit } from "./state.ts";
@@ -9,7 +10,7 @@ import type { Kit } from "./state.ts";
9
10
  export const tabLabel = (name: string): string => Array.from(name).length > 16 ? `${Array.from(name).slice(0, 16).join("").trimEnd()}…` : name;
10
11
 
11
12
  /** The tools the agent calls: what a joined session has (status, call_tower, broadcast, rename, promote), and the tower's boss_open_main. */
12
- export function createTools({ pi, cfg, s, me, role }: Kit, { send, openTower, status, broadcast, promote, digest }: Pick<Reach, "send" | "openTower" | "status" | "broadcast"> & Pick<Membership, "promote"> & { digest: Digest }) {
13
+ export function createTools({ pi, cfg, s, me, role }: Kit, { post, callTower, status, broadcast, towerList, towerSend, promote, digest, closeMain }: Pick<Reach, "post" | "callTower" | "status" | "broadcast" | "towerList" | "towerSend"> & Pick<Membership, "promote"> & { digest: Digest; closeMain: CloseMain }) {
13
14
  const tool = <P extends TSchema>(name: string, description: string, parameters: P, body: (params: Static<P>) => Promise<{ text: string; details?: unknown }>) =>
14
15
  pi.registerTool({
15
16
  name, label: name, description, parameters,
@@ -24,25 +25,20 @@ export function createTools({ pi, cfg, s, me, role }: Kit, { send, openTower, st
24
25
  const result = await status();
25
26
  return { text: JSON.stringify(result, null, 2), details: result };
26
27
  });
27
- 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.",
28
+ 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 message goes out under a header the code writes (your name, role, and workspace, the recipient, and whether you expect a reply). A tower replies through intercom unless you say reply is false.",
28
29
  Type.Object({
29
30
  message: Type.String({ description: "What the tower needs to know or do: at most two sentences, details by reference." }),
30
31
  workspace: Type.Optional(Type.String({ description: "Another workspace's id or exact label. Omit for your own." })),
32
+ reply: Type.Optional(Type.Boolean({ description: "Whether you wait for the tower's answer. Default true; false for a notice." })),
31
33
  }),
32
- async ({ message, workspace }) => {
33
- const tower = await openTower(workspace);
34
- const result = await send(tower.id, message);
35
- 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());
36
- const via = tower.routed ? ` (${tower.routed.label} is a sub workspace)` : "";
37
- const text = `Sent to the tower of ${tower.target.label}${via}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
38
- return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
39
- });
40
- 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).",
34
+ ({ message, workspace, reply }) => callTower(message, workspace, reply ?? true));
35
+ tool("boss_broadcast", "Send one message to every other session in a scope. tab, workspace, and towers reach only sessions that joined the net (towers: each workspace's tower, else its mains, else its members); all reaches every pi session on this machine (for notices such as the boss going to sleep). The message goes out under a header the code writes (your name and role, the scope, every recipient). A tower reaches other towers with tower_send instead.",
41
36
  Type.Object({
42
37
  message: Type.String({ description: "The message, once; receivers do not re-broadcast it." }),
43
38
  scope: Type.Optional(Type.Union([Type.Literal("tab"), Type.Literal("workspace"), Type.Literal("towers"), Type.Literal("all")], { description: "Default workspace." })),
39
+ reply: Type.Optional(Type.Boolean({ description: "Whether you wait for answers. Default false." })),
44
40
  }),
45
- ({ message, scope }) => broadcast(message, scope ?? "workspace"));
41
+ ({ message, scope, reply }) => broadcast(message, scope ?? "workspace", reply ?? false));
46
42
  // The session's name is what the net and the GUI show for it. A main agent names its tab too: the tab is its squad, and pi-auto-tab-name
47
43
  // leaves a tab alone once anyone else labelled it. A tower keeps its own name.
48
44
  if (!s.isTower) tool("boss_rename", "Name this session with a short phrase for the work it is doing now, at most 16 characters (e.g. \"MP-3584 cost mask\", \"重構通知模組\"). The net and the GUI show it, and a main agent's Herdr tab takes it as its label.",
@@ -69,8 +65,9 @@ export function createTools({ pi, cfg, s, me, role }: Kit, { send, openTower, st
69
65
  },
70
66
  // Tower only. The tower runs no commands: this is its one way to start work, in a new tab of its own workspace.
71
67
  addOpenMain() {
72
- const open = opener({ run: cfg.run, sleep: cfg.sleep, workspaceId: s.at.workspaceId, cwd: process.cwd(), sessions: () => s.c.listSessions(), send });
73
- 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.",
68
+ // The request reaches the new main under a header, and names the tower the main reports to as well as the sender.
69
+ const open = opener({ run: cfg.run, sleep: cfg.sleep, workspaceId: s.at.workspaceId, cwd: process.cwd(), sessions: () => s.c.listSessions(), send: (to, message) => post(to, message, "handoff", "main", true), tower: () => ({ id: me(), name: s.ownName || me() }) });
70
+ 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. The main reports its outcome to the sender and to you; close it with boss_close_main after its done report.",
74
71
  Type.Object({
75
72
  request: Type.String({ description: "The sender's request exactly as it reached you, with every boss decision it quotes kept verbatim." }),
76
73
  sender: Type.String({ description: "The intercom name or session id of the session that made the request; the new main reports back to it." }),
@@ -78,10 +75,27 @@ export function createTools({ pi, cfg, s, me, role }: Kit, { send, openTower, st
78
75
  }),
79
76
  async (input) => {
80
77
  const opened = await open(input);
81
- 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}.`;
78
+ 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} and to you.`;
82
79
  return { text, details: opened };
83
80
  });
84
81
  },
82
+ // Tower only. Towers talk to each other with these: who is out there, and one message to one tower or all. Closing is for the mains a tower opened itself.
83
+ addTowerComms() {
84
+ tool("tower_list", "List every workspace's tower: its workspace, whether it is the head tower or you, who a message for it reaches (its tower; in a workspace without one, its mains, else its members), that session's state (idle or working), and what its tabs are working on (each tab's main and members by the names they gave themselves).", Type.Object({}), towerList);
85
+ tool("tower_send", "Send one message to another tower (workspace id or exact label), or to every other tower when workspace is omitted. A workspace without a tower is reached through its mains, else its members, and the result says so. The message goes out under a header the code writes (your name, role, and workspace, every recipient, whether you expect a reply): do not send again to a tower the header of a message you got already names as a recipient.",
86
+ Type.Object({
87
+ message: Type.String({ description: "The message, once: what the other towers need to know or do, details by reference." }),
88
+ workspace: Type.Optional(Type.String({ description: "One workspace's id or exact label. Omit for every other tower." })),
89
+ reply: Type.Optional(Type.Boolean({ description: "Whether you wait for an answer. Default false; true only when you act on it." })),
90
+ }),
91
+ ({ message, workspace, reply }) => towerSend(message, workspace, reply ?? false));
92
+ tool("boss_close_main", "Close a main you opened with boss_open_main: ask it to quit as /boss close would, so its pane and tab close. Only mains opened by a tower in your workspace, never one the boss opened. Refused while it has a running turn, a queued message, an open dialog, a monitor, or a subagent; try again later. Close an opened main after its done report, unless it waits for a boss decision.",
93
+ Type.Object({ main: Type.String({ description: "The opened main's intercom name or session id." }) }),
94
+ async ({ main }) => {
95
+ const closed = await closeMain(main);
96
+ return { text: closed.text, details: closed };
97
+ });
98
+ },
85
99
  };
86
100
  }
87
101
  export type Tools = ReturnType<typeof createTools>;
package/src/tower.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import type { Control } from "./closemain.ts";
1
2
  import { collect, startDashboard } from "./dashboard.ts";
3
+ import type { Digest } from "./digest.ts";
2
4
  import { answers, readRecord, urlOf } from "./handover.ts";
5
+ import { watchHead } from "./headwatch.ts";
3
6
  import { headOf, report, waitFor, workspaces, panes, place } from "./herdr.ts";
4
7
  import type { Membership } from "./membership.ts";
5
8
  import type { Presence } from "./presence.ts";
@@ -11,7 +14,7 @@ const SETTLE_MS = 2000;
11
14
  const SCREEN_EVENTS = ["pane.created", "pane.closed", "pane.updated", "pane.moved", "pane.exited", "pane.agent_detected", "tab.created", "tab.closed", "tab.renamed", "tab.moved", "tab.focused", "workspace.focused", "workspace.created", "workspace.updated", "workspace.metadata_updated", "workspace.renamed", "workspace.moved", "workspace.reordered", "workspace.closed"];
12
15
 
13
16
  /** A session that is a tower (`STRAW_BOSS_ROLE=tower`, or marked so in its session) takes the role: its marks, its tools, its dashboard. */
14
- export function createTower({ pi, cfg, s, guiDir, me }: Kit, { membership, presence, tools }: { membership: Membership; presence: Presence; tools: Tools }) {
17
+ export function createTower({ pi, cfg, s, guiDir, me }: Kit, { membership, presence, tools, control, digest }: { membership: Membership; presence: Presence; tools: Tools; control: Control; digest: Digest }) {
15
18
  const { run, world, sleep, events } = cfg;
16
19
  return async function startTower() {
17
20
  s.at = await place(run);
@@ -22,6 +25,7 @@ export function createTower({ pi, cfg, s, guiDir, me }: Kit, { membership, prese
22
25
  await sleep(SETTLE_MS);
23
26
  const all = await workspaces(run);
24
27
  const [ps, sessions] = [await panes(run, s.at.workspaceId), await s.c.listSessions()];
28
+ if (s.stopped) return; // a shutdown during the waits: nothing may start after it
25
29
  const ws = all.find((w) => w.workspace_id === s.at.workspaceId);
26
30
  const holder = ws?.tokens?.tower;
27
31
  const rival = ps.find((p) => p.pane_id !== s.at.paneId && p.agent === "pi" && p.tokens?.sb_role === "tower");
@@ -36,6 +40,9 @@ export function createTower({ pi, cfg, s, guiDir, me }: Kit, { membership, prese
36
40
  s.info = { workspace: ws?.label ?? s.at.workspaceId, head: headOf(all)?.label ?? "" };
37
41
  tools.addNetTools();
38
42
  tools.addOpenMain();
43
+ tools.addTowerComms();
44
+ control.register();
45
+ s.headWatch = watchHead({ run, events, digest, workspaceId: s.at.workspaceId });
39
46
  presence.keepFresh();
40
47
  await presence.publish();
41
48
  // The dashboard lives exactly as long as this session holds the tower role.