pi-straw-boss 0.34.0 → 0.35.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 CHANGED
@@ -86,6 +86,7 @@ The tower only routes and coordinates, so it can run a lighter model: set `tower
86
86
  | `/boss` | Take over as this tab's main agent. |
87
87
  | `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
88
88
  | `/boss close` | Quit this session and close its pane. |
89
+ | `/boss close all` | Quit every tower, main, and member on the net and close their bases. Every base is checked first as a stash checks it; anything running that would be lost anywhere (a turn, a queued message, a dialog, a monitor, a subagent, a busy shell) closes nothing, and every reason is listed. Workspaces outside the net are left alone. |
89
90
  | `/boss stash [name]`, `/boss pop [name]` | Put this whole base away (its tower, mains, members, and idle shells) and close it; open it again later, every pi resumed from its own session file in its old role. A stash refuses, naming why, while anything a resume would lose is there: a running turn, a queued message, an open dialog, a running monitor, a subagent, or a busy shell. |
90
91
  | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server, from any session. The machine runs one server on one URL: it comes back after its session's `/reload`, and a session on a newer pi-straw-boss takes it over, so open pages reload into the new version. Type into a session from its comms view, or work its terminal with the key pad under the terminal tile, or on a desktop by clicking into it and typing; answer a waiting `robot_hand` selector, `ask_user_question` questionnaire, or pi-code hook confirmation there or in its alert (so can any extension's `select` and `confirm`, also from a paired device; other dialogs show read-only, to answer in the terminal; `secret_drop` always takes its secret in the terminal, never on a page, local or over the tailnet, because pi-intercom identities are self-reported). Documents a session shows with [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 or newer open beside its comms: HTML in a sandboxed frame whose form answers the agent, text read-only. |
91
92
  | `/boss gui remote`, `/boss gui remote off` | Reach the GUI from your phone over your tailnet (`tailscale serve`, never Funnel): pair by QR code, and a phone stays paired (90 days from its last use, restarts and `remote off` included) until you unpair it in the Phone tile; remote access stays on across a stop, a `/reload` and a new server until `remote off`; off removes only its own serve entry. The serve entry points at a second loopback listener of the GUI server, so a request is remote because of the listener it arrived on, whatever headers it carries. |
package/README.zh-TW.md CHANGED
@@ -85,6 +85,7 @@ pi install npm:pi-straw-boss
85
85
  | `boss_rename` | 用一個短語替這個 session 命名,說明它現在在做什麼;net、GUI,以及裝了 pi-auto-tab-name 時的分頁名稱都會跟著顯示。主智慧體換工作時會呼叫它。 |
86
86
  | `/boss` | 接手成為這個分頁的主智慧體。 |
87
87
  | `/boss status`、`/boss tower` | 顯示狀態;開啟塔台並切換過去。 |
88
+ | `/boss close all` | 讓網上所有塔台、主智慧體和成員結束,並關掉它們的基地。會先用 stash 的同一套檢查看過每個基地;任何地方有會遺失的東西(正在跑的回合、排隊中的訊息、對話框、Monitor、子智慧體、前景有程式的 shell),就一個都不關,並列出所有原因。不屬於網的工作區不會被碰。 |
88
89
  | `/boss stash [名稱]`、`/boss pop [名稱]` | 把整個基地(塔台、主智慧體、成員和閒置的 shell)收起來並關掉;之後再打開,每個 pi 都從自己的 session 檔接回原本的角色。只要有接回後會遺失的東西就拒絕並說明原因:正在跑的回合、排隊中的訊息、開著的對話框、執行中的 Monitor、子智慧體,或前景有程式在跑的 shell。 |
89
90
  | `/boss gui`、`/boss gui stop` | 在瀏覽器開啟網(任何 session 都能開,不會加入任何東西);停止它的伺服器,在哪個 session 都可以。整台機器只跑一個伺服器、一個網址:開它的 session `/reload` 之後,會在原網址重開;版本較新的 session 會接手,開著的頁面也會自動換成新版。可以在通訊畫面直接打字給某個 session,或用終端機畫面下方的按鍵面板操作它的終端機,在桌面上也可以點進終端機直接打字;在等回覆的 `robot_hand` 選單、`ask_user_question` 問卷或 pi-code 的 hook 確認,可以在那裡或它的警示裡作答(任何 extension 的 `select` 和 `confirm` 也可以,`confirm` 也可以從配對的裝置作答;其他對話框只能看,要回到終端機回答;`secret_drop` 的機密一律在終端機輸入,任何頁面(本機或 tailnet)都不接受,因為 pi-intercom 的身分是自己申報的,無法驗證)。session 用 [pi-herdr-doc-review](https://www.npmjs.com/package/pi-herdr-doc-review) 0.4.0 以上顯示的文件,會開在它的通訊畫面旁邊:HTML 放在沙箱框架裡,表單送出的答案會傳給智慧體;純文字只能讀。 |
90
91
  | `/boss gui remote`、`/boss gui remote off` | 透過你的 tailnet 用手機連 GUI(`tailscale serve`,絕不用 Funnel):掃 QR code 配對,手機配對後會一直有效(從最後一次使用起算 90 天,重啟與 `remote off` 都不影響),直到你在「手機」視窗解除配對;遠端存取在 stop、`/reload`、換新伺服器之後仍會保持開啟,直到 `remote off`;關閉時只移除它自己的 serve 設定。serve 指向 GUI 伺服器的第二個 loopback 監聽埠,請求算不算遠端取決於它進來的監聽埠,不看它帶什麼標頭。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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/src/boss.ts CHANGED
@@ -5,12 +5,12 @@ import type { Kit } from "./state.ts";
5
5
  import type { createStashing } from "./stashing.ts";
6
6
 
7
7
  /** What Tab completes after /boss; a bare /boss (take over as the main agent) has nothing to complete. */
8
- const BOSS_ARGUMENTS = Object.entries({ status: "Show the coordinators", close: "Quit this session and close its pane", tower: "Open and focus the workspace tower", gui: "Open the net in the browser", "gui stop": "Close the browser GUI", "gui remote": "Open the GUI to your phone over Tailscale", "gui remote off": "Close the Tailscale entry", stash: "Put this base away: its tower, mains, and members", pop: "Open a stashed base again" }).map(([value, description]) => ({ value, label: value, description }));
8
+ const BOSS_ARGUMENTS = Object.entries({ status: "Show the coordinators", close: "Quit this session and close its pane", "close all": "Quit every tower, main, and member on the net and close their bases", tower: "Open and focus the workspace tower", gui: "Open the net in the browser", "gui stop": "Close the browser GUI", "gui remote": "Open the GUI to your phone over Tailscale", "gui remote off": "Close the Tailscale entry", stash: "Put this base away: its tower, mains, and members", pop: "Open a stashed base again" }).map(([value, description]) => ({ value, label: value, description }));
9
9
 
10
10
  /** The /boss command. */
11
11
  export function registerBoss({ pi, cfg, s, tell }: Kit, { host, membership, reach, closeSelf, stashing }: { host: ReturnType<typeof createHost>; membership: Membership; reach: Reach; closeSelf(keepPane: boolean): string | undefined; stashing: ReturnType<typeof createStashing> }) {
12
12
  pi.registerCommand("boss", {
13
- description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower focuses the workspace tower (a main opens it when there is none); /boss close quits this session and closes its pane; /boss stash [name] puts this base away (tower, mains, members) and /boss pop [name] opens it again; /boss gui opens the net in the browser (/boss gui stop closes it; /boss gui remote opens it to your phone over Tailscale, /boss gui remote off closes that).",
13
+ description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower focuses the workspace tower (a main opens it when there is none); /boss close quits this session and closes its pane (/boss close all: every tower, main, and member on the net); /boss stash [name] puts this base away (tower, mains, members) and /boss pop [name] opens it again; /boss gui opens the net in the browser (/boss gui stop closes it; /boss gui remote opens it to your phone over Tailscale, /boss gui remote off closes that).",
14
14
  // pi passes the whole argument text and replaces it with the chosen value, so each value is a full argument line.
15
15
  getArgumentCompletions: (prefix) => {
16
16
  const stashed = stashing.names().map((name) => ({ value: `pop ${name}`, label: `pop ${name}`, description: "Open this stashed base again" }));
@@ -24,6 +24,7 @@ export function registerBoss({ pi, cfg, s, tell }: Kit, { host, membership, reac
24
24
  const why = args.trim() === "close" ? (closeSelf(false) ?? "") : undefined;
25
25
  if (why !== undefined) return void (why && tell(why, "error"));
26
26
  if (args.trim() === "status") return tell(s.joined || s.isTower ? JSON.stringify(await reach.status(), null, 2) : "Not in the net: still joining, or this session cannot join.");
27
+ if (args.trim() === "close all") return await stashing.closeAll();
27
28
  const stash = /^(stash|pop)(?:\s+(.+))?$/.exec(args.trim());
28
29
  if (stash) return await (stash[1] === "stash" ? stashing.stash(stash[2]) : stashing.pop(stash[2]));
29
30
  if (s.isTower) throw new Error("This session is the tower, not a main agent");
package/src/closer.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Reply } from "./control.ts";
2
2
  import { type HerdrRun, panes, type Session, type Sleep } from "./herdr.ts";
3
- import { stashBase } from "./stash.ts";
3
+ import { closeAll, stashBase } from "./stash.ts";
4
4
 
5
5
  // What the page's close buttons ask for. Closing is each session's own (index.ts): its bridge is asked to quit it as /boss close does, and its
6
6
  // shutdown clears its tokens (a tower's clears the base's mains). One unit closes its own pane afterwards; for a squad or a base every agent
@@ -11,6 +11,7 @@ import { stashBase } from "./stash.ts";
11
11
  // POST /<token>/api/close/tab {workspace, tab} every agent in the tab quits, then the tab closes
12
12
  // POST /<token>/api/close/base {workspace} every agent in the workspace quits, then the workspace closes
13
13
  // POST /<token>/api/close/stash {workspace, name} the base is checked and recorded first (stash.ts), then closed as above
14
+ // POST /<token>/api/close/all every base of the net is checked, then each closes as above (stash.ts closeAll)
14
15
 
15
16
  const EXIT_MS = 30_000, JOINED = new Set(["main", "member", "tower"]);
16
17
  /** `check` and `stateDir` are a stash's: without them this closer stashes nothing. */
@@ -45,11 +46,13 @@ export function createCloser({ run, sleep, me, sessions, ask, check, stateDir }:
45
46
  const r = await ask(json.key, false);
46
47
  return r?.accepted ? { status: 200, body: { closing: true } } : no(409, "refused", r?.reason ?? "The session did not answer; close it in its terminal.");
47
48
  }
48
- if (kind === "stash" && check && stateDir) {
49
+ if ((kind === "stash" && stateDir) || kind === "all") {
50
+ if (!check) return no(404, "not_found", "No such action.");
49
51
  if (busy) return no(429, "busy", "Another close is still on its way.");
50
52
  busy = true;
51
53
  try {
52
- return await stashBase({ run, sleep, sessions, check, stateDir }, json, (workspace) => batch(workspace, undefined, true), refresh);
54
+ const quit = (workspace: string) => batch(workspace, undefined, true);
55
+ return await (kind === "all" ? closeAll({ run, sleep, sessions, check }, me(), quit, refresh, typeof json.by === "string" ? json.by : undefined) : stashBase({ run, sleep, sessions, check, stateDir: stateDir! }, json, quit, refresh));
53
56
  } catch (error) {
54
57
  return no(502, "stash_failed", (error as Error).message);
55
58
  } finally {
package/src/stash.ts CHANGED
@@ -38,8 +38,9 @@ export function listStashes(stateDir: string): string[] {
38
38
  const str = (v: unknown): v is string => typeof v === "string" && v.length > 0;
39
39
  function paneOk(p: unknown): p is StashPane {
40
40
  const x = p as Partial<StashPane> | null;
41
- return x !== null && typeof x === "object" && (x.kind === "shell" || ROLES.has(x.kind as string)) && str(x.cwd) && (x.kind === "shell" || str(x.session)) && (x.name === undefined || typeof x.name === "string");
41
+ return x !== null && typeof x === "object" && (x.kind === "shell" || ROLES.has(x.kind as string)) && str(x.cwd) && (x.session === undefined || str(x.session)) && (x.name === undefined || typeof x.name === "string");
42
42
  }
43
+ /** `session` is absent for a pi that never wrote its file (pi writes it at the first reply): it has no conversation to lose, so a pop starts it fresh in its role. */
43
44
  /** What a pop can open: the stash checks it before it closes anything, the pop before it opens anything. */
44
45
  const stashOk = (st: Stash | null) => st?.version === 1 && str(st.label) && str(st.cwd) && Array.isArray(st.tabs) && st.tabs.length > 0 && st.tabs.every((t) => Array.isArray(t?.panes) && t.panes.length > 0 && t.panes.every(paneOk) && (t.label === undefined || typeof t.label === "string"));
45
46
  export function readStash(stateDir: string, name: string, file = stashFile(stateDir, name)): Stash {
@@ -65,8 +66,9 @@ async function foreground(run: HerdrRun, paneId: string): Promise<string[] | und
65
66
  }
66
67
  }
67
68
 
68
- /** Herdr's side of the check, and the manifest's tabs: tower tab first, then Herdr's tab order, each tab's panes in Herdr's order. */
69
- export async function survey(run: HerdrRun, workspaceId: string, live: Session[]) {
69
+ /** Herdr's side of the check, and the manifest's tabs: tower tab first, then Herdr's tab order, each tab's panes in Herdr's order. `resume`: false for
70
+ * a close, which needs nothing a pop would read (session files, folders), only that nothing running is lost. */
71
+ export async function survey(run: HerdrRun, workspaceId: string, live: Session[], resume = true) {
70
72
  const all = await workspaces(run), ws = all.find((w) => w.workspace_id === workspaceId);
71
73
  if (!ws) return undefined;
72
74
  const reasons: string[] = [], agents: Session[] = [], out: StashTab[] = [];
@@ -84,20 +86,21 @@ export async function survey(run: HerdrRun, workspaceId: string, live: Session[]
84
86
  const cwd = p.cwd ?? ws.worktree?.checkout_path ?? "", session = sessionAt(p), who = session?.name ?? `pane ${p.pane_id}`;
85
87
  if (!p.agent) {
86
88
  const fg = await foreground(run, p.pane_id);
87
- if (!cwd) reasons.push(`pane ${p.pane_id} has no folder Herdr knows of`);
89
+ if (!cwd && resume) reasons.push(`pane ${p.pane_id} has no folder Herdr knows of`);
88
90
  if (!fg) reasons.push(`Herdr cannot say what runs in pane ${p.pane_id}`);
89
91
  else if (fg.length) reasons.push(`pane ${p.pane_id} runs ${fg.join(", ")}`);
90
92
  tab.panes.push({ kind: "shell", cwd });
91
93
  continue;
92
94
  }
93
95
  const role = p.tokens?.sb_role ?? "";
94
- if (p.agent !== "pi") reasons.push(`pane ${p.pane_id} runs ${p.agent}, which a stash cannot resume`);
96
+ if (p.agent !== "pi") reasons.push(`pane ${p.pane_id} runs ${p.agent}, which Straw Boss cannot ask to quit`);
95
97
  else if (!ROLES.has(role)) reasons.push(`${who} is a subagent or outside the net`);
96
98
  if (p.agent !== "pi" || !ROLES.has(role)) continue;
97
99
  if (!SETTLED.has(p.agent_status ?? "")) reasons.push(`${who} is ${p.agent_status ?? "in an unknown state"}`);
98
- const file = p.agent_session?.value;
99
- if (!file || !existsSync(file)) reasons.push(`${who} has no session file Herdr knows of`);
100
- if (!cwd) reasons.push(`${who} has no folder Herdr knows of`);
100
+ // A path Herdr names whose file does not exist yet is a session that never replied: nothing to resume, it starts fresh at pop.
101
+ const named = p.agent_session?.value, file = named && existsSync(named) ? named : undefined;
102
+ if (!named && resume) reasons.push(`${who} has no session file Herdr knows of`);
103
+ if (!cwd && resume) reasons.push(`${who} has no folder Herdr knows of`);
101
104
  if (session) agents.push(session);
102
105
  else reasons.push(`${who} is not on the intercom net`);
103
106
  tab.panes.push({ kind: role as StashRole, cwd, ...(file ? { session: file } : {}), ...(session?.name ? { name: session.name } : {}) });
@@ -107,10 +110,59 @@ export async function survey(run: HerdrRun, workspaceId: string, live: Session[]
107
110
  const towered = (t: StashTab) => t.panes.some((p) => p.kind === "tower");
108
111
  const tabsOut = [...out.filter(towered), ...out.filter((t) => !towered(t))];
109
112
  const cwd = tabsOut.flatMap((t) => t.panes).find((p) => p.kind === "tower")?.cwd ?? ws.worktree?.checkout_path ?? tabsOut[0]?.panes[0]?.cwd ?? "";
110
- if (!tabsOut.length) reasons.push(`${ws.label} has nothing in it to stash`);
113
+ if (!tabsOut.length && resume) reasons.push(`${ws.label} has nothing in it to stash`);
111
114
  return { label: ws.label, cwd, tabs: tabsOut, reasons, agents };
112
115
  }
113
116
 
117
+ /** Each session's own side, through its bridge. A bridge that has not told this server about itself yet (one just started) gets another round after the net is read again. */
118
+ async function askSessions(d: Pick<StashDeps, "sleep" | "check">, agents: Session[], refresh?: () => Promise<unknown>) {
119
+ const reasons: string[] = [], named = (s: Session) => s.name ?? s.id.slice(0, 8);
120
+ let pending = agents;
121
+ for (let round = 0; pending.length && round < CHECK_ROUNDS; round++) {
122
+ if (round) (await d.sleep(CHECK_GAP_MS), await refresh?.());
123
+ const got = await Promise.all(pending.map(async (s) => ({ s, r: await d.check(s.id) })));
124
+ for (const { s, r } of got) if (r?.reason) reasons.push(`${named(s)}: ${r.reason}`);
125
+ pending = got.filter(({ r }) => !r).map(({ s }) => s);
126
+ }
127
+ for (const s of pending) reasons.push(`${named(s)} did not answer the check (an older pi-straw-boss, or a stuck session)`);
128
+ return reasons;
129
+ }
130
+
131
+ /**
132
+ * Close all: every base of the net (a workspace with a live tower or a joined main or member; sub workspaces and workspaces outside the net are
133
+ * left alone) is checked as a stash checks it, minus what only a pop needs. One refusal anywhere closes nothing. Then each base closes as its
134
+ * close button does, each session quitting only while it can still be stashed, and the base of the server's own session last (it ends with it).
135
+ */
136
+ export async function closeAll(d: Pick<StashDeps, "run" | "sleep" | "sessions" | "check">, me: string, closeBase: (workspace: string) => Promise<Reply>, refresh?: () => Promise<unknown>, by?: string): Promise<Reply> {
137
+ const live = await d.sessions(), alive = new Set(live.map((s) => s.id)), ps = await panes(d.run), lives = (p: Pane) => live.some((s) => s.herdrLocation?.paneId === p.pane_id);
138
+ // A role token outlives its pi up to its TTL: only a live tower, main, or member puts a workspace on the net.
139
+ const bases = (await workspaces(d.run)).filter((w) => !isSub(w) && ((w.tokens?.tower && alive.has(w.tokens.tower)) || ps.some((p) => p.workspace_id === w.workspace_id && (p.tokens?.sb_role === "main" || p.tokens?.sb_role === "member") && lives(p))));
140
+ if (!bases.length) return no(404, "not_found", "No base is on the net.");
141
+ const surveyAll = async (sessions: Session[]) => Promise.all(bases.map(async (w) => ({ w, sv: await survey(d.run, w.workspace_id, sessions, false) })));
142
+ const first = await surveyAll(live), reasons = first.flatMap(({ w, sv }) => (sv ? sv.reasons.map((r) => `${w.label}: ${r}`) : []));
143
+ reasons.push(...(await askSessions(d, first.flatMap(({ sv }) => sv?.agents ?? []), refresh)));
144
+ if (!reasons.length) reasons.push(...(await surveyAll(await d.sessions())).flatMap(({ w, sv }) => (sv?.reasons ?? []).map((r) => `${w.label}: ${r}`)));
145
+ if (reasons.length) return no(409, "refused", `Nothing was closed: ${reasons.join("; ")}.`);
146
+ // The invoker's base closes after the others, so it hears how the run ended; the server's own base last of all, since it ends with it.
147
+ const baseOf = (id?: string) => (id ? live.find((s) => s.id === id)?.herdrLocation?.workspace?.id : undefined), mine = baseOf(me), theirs = baseOf(by);
148
+ const rank = (w: { workspace_id: string }) => (w.workspace_id === mine ? 2 : w.workspace_id === theirs ? 1 : 0);
149
+ const order = bases.toSorted((a, b) => rank(a) - rank(b)), done: string[] = [];
150
+ const stop = (why: string) => no(409, "close_failed", `${why} Closed before it: ${done.join(", ") || "none"}; the rest stay open.`);
151
+ for (const w of order) {
152
+ // The run takes minutes: each base is looked at again just before it closes (a shell job, a subagent left running), and the server's own
153
+ // session, which its batch does not ask, is checked again before its base closes with it.
154
+ const sv = await survey(d.run, w.workspace_id, await d.sessions(), false);
155
+ if (!sv) continue; // closed by someone else meanwhile
156
+ const late = [...sv.reasons, ...(w.workspace_id === mine ? ((r) => (r?.reason ? [r.reason] : r ? [] : ["this server's session did not answer"]))(await d.check(me)) : [])];
157
+ if (late.length) return stop(`${w.label} cannot close now: ${late.join("; ")}.`);
158
+ const r = await closeBase(w.workspace_id);
159
+ if (r.status === 404) continue;
160
+ if (r.status !== 200) return stop((r.body as { reason?: string }).reason ?? `${w.label} did not close.`);
161
+ done.push(w.label);
162
+ }
163
+ return { status: 200, body: { closed: done } };
164
+ }
165
+
114
166
  export interface StashDeps { run: HerdrRun; sleep: Sleep; sessions(): Promise<Session[]>; check(key: string): Promise<{ reason?: string } | undefined>; stateDir: string; now?: () => Date }
115
167
 
116
168
  /** The server's stash: every check first (nothing closes on a refusal), then the manifest, then `closeBase` (the close-base batch). */
@@ -121,16 +173,7 @@ export async function stashBase(d: StashDeps, json: Record<string, unknown>, clo
121
173
  if (existsSync(file)) return no(409, "exists", `Something is stashed as "${name}" already; pop it or pick another name.`);
122
174
  const sv = await survey(d.run, json.workspace, await d.sessions());
123
175
  if (!sv) return no(404, "not_found", "That base is not open any more.");
124
- // A bridge that has not told this server about itself yet (one just started) gets another round after the net is read again.
125
- const reasons = [...sv.reasons], named = (s: Session) => s.name ?? s.id.slice(0, 8);
126
- let pending = sv.agents;
127
- for (let round = 0; pending.length && round < CHECK_ROUNDS; round++) {
128
- if (round) (await d.sleep(CHECK_GAP_MS), await refresh?.());
129
- const got = await Promise.all(pending.map(async (s) => ({ s, r: await d.check(s.id) })));
130
- for (const { s, r } of got) if (r?.reason) reasons.push(`${named(s)}: ${r.reason}`);
131
- pending = got.filter(({ r }) => !r).map(({ s }) => s);
132
- }
133
- for (const s of pending) reasons.push(`${named(s)} did not answer the stash check (an older pi-straw-boss, or a stuck session)`);
176
+ const reasons = [...sv.reasons, ...(await askSessions(d, sv.agents, refresh))];
134
177
  // Herdr's side once more, since the rounds took seconds: a shell job or a subagent started meanwhile refuses too. The sessions' own side is
135
178
  // asked again by the close itself (`ifStashable`), so a turn started now refuses that session's quit and stops the batch.
136
179
  if (!reasons.length) reasons.push(...((await survey(d.run, json.workspace, await d.sessions()))?.reasons ?? ["the base is not open any more"]));
@@ -159,11 +202,11 @@ async function resume({ run, sleep, live }: PopDeps, ws: string, paneId: string,
159
202
  if (p.kind === "main") await report(run, "pane", paneId, { sb_open_main: "1" }); // authorises exactly this pi to promote itself (session.ts)
160
203
  // Herdr agent names are unique across workspaces and lowercase only.
161
204
  const agent = p.kind === "tower" ? `tower-${ws.toLowerCase()}` : `${p.kind}-${randomUUID().slice(0, 8)}`;
162
- await startPi(run, sleep, agent, paneId, ["--session", p.session!, ...(p.name ? ["--name", p.name] : [])]);
205
+ await startPi(run, sleep, agent, paneId, [...(p.session ? ["--session", p.session] : []), ...(p.name ? ["--name", p.name] : [])]);
163
206
  const back = p.kind === "tower"
164
207
  ? await waitFor(sleep, async () => ((id) => id && live(id))((await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower), JOIN_MS, 1000)
165
208
  : await waitFor(sleep, async () => ((r) => (p.kind === "main" ? r === "main" : r === "member" || r === "main"))((await panes(run, ws)).find((x) => x.pane_id === paneId)?.tokens?.sb_role), JOIN_MS, 500);
166
- if (!back) throw new Error(`${p.name ?? p.session} did not come back as the ${p.kind} within ${JOIN_MS / 1000} s (pane ${paneId})`);
209
+ if (!back) throw new Error(`${p.name ?? p.session ?? p.kind} did not come back as the ${p.kind} within ${JOIN_MS / 1000} s (pane ${paneId})`);
167
210
  }
168
211
 
169
212
  /**
package/src/stashing.ts CHANGED
@@ -12,7 +12,7 @@ import type { Kit } from "./state.ts";
12
12
  // The checks take up to three rounds of a few seconds, then every agent has 30 s to quit.
13
13
  const STASH_MS = 120_000;
14
14
 
15
- export function createStashing({ cfg, s, guiDir, tell }: Kit, { host }: { host: Pick<ReturnType<typeof createHost>, "share" | "sessions"> }) {
15
+ export function createStashing({ cfg, s, guiDir, tell, me }: Kit, { host }: { host: Pick<ReturnType<typeof createHost>, "share" | "sessions"> }) {
16
16
  const { run, sleep } = cfg;
17
17
  return {
18
18
  names: () => listStashes(guiDir),
@@ -31,6 +31,18 @@ export function createStashing({ cfg, s, guiDir, tell }: Kit, { host }: { host:
31
31
  const why = typeof reply.body.reason === "string" ? reply.body.reason : reply.status ? `the server answered ${reply.status}` : "the GUI server did not answer";
32
32
  tell(reply.status === 404 && reply.body.error === "not_found" ? `The GUI server cannot stash yet (an older pi-straw-boss in session ${shared.owner}); /reload it first` : why, "error");
33
33
  },
34
+ /** Quits every tower, main, and member on the net and closes their bases, or nothing when anything there would be lost. */
35
+ async closeAll() {
36
+ const shared = await host.share();
37
+ const record = readRecord(guiDir);
38
+ if (shared.stale || !record) throw new Error(`The straw net GUI runs in session ${shared.owner} on a version that cannot close all; /reload that session first`);
39
+ tell("Closing every base on the net…");
40
+ const reply = await postServer(record, "close/all", { by: me() }, STASH_MS * 4);
41
+ if (reply.status === 200) return tell(`Closed ${(reply.body.closed as string[]).join(", ")}`);
42
+ // A session still here to read this was not closed: the server went with its own base, timed out, or is down.
43
+ if (reply.status === 0) return tell("The GUI server did not answer (it may have closed with its own base); check what is still open", "warning");
44
+ tell(reply.body.reason === "No such action." ? `The GUI server cannot close all yet (an older pi-straw-boss in session ${shared.owner}); /reload it first` : String(reply.body.reason ?? `the server answered ${reply.status}`), "error");
45
+ },
34
46
  /** Pops `given`; with no name, the only stash, or the one picked from the list. */
35
47
  async pop(given: string | undefined) {
36
48
  const names = listStashes(guiDir);