pi-straw-boss 0.6.9 → 0.6.10

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
@@ -19,7 +19,7 @@ Run a few agents on the same project and they start stepping on each other: two
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.
21
21
  - **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.
22
- - **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 one key.
22
+ - **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.
23
23
 
24
24
  ## Install
25
25
 
@@ -36,6 +36,8 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
36
36
  3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
37
37
  4. Type `/boss gui` from any session to see the whole net in your browser.
38
38
 
39
+ The tower only routes and coordinates, so it can run a lighter model: set `towerModel` in `~/.pi/agent/straw-boss/config.json` ([how it works](docs/how-it-works.md#how-it-works)).
40
+
39
41
  ## Tools and commands
40
42
 
41
43
  | Name | What it does |
@@ -55,10 +57,14 @@ Subagents and sessions in Herdr worktree workspaces never join: the session that
55
57
 
56
58
  An optional Herdr plugin in [`herdr-plugin/`](herdr-plugin) turns the sidebar's Agents list into a net view.
57
59
  It lists the head tower's workspace first, in each workspace the tower, then each tab's main agent and members, and every subagent right under the session that launched it, even in a worktree workspace.
58
- One key switches to it, and the same key switches back to Herdr's own view.
59
- pi-straw-boss writes the sort key (`sb_order`) and a role label (`sb_mark`) the view and your sidebar rows can use.
60
+ The view is on whenever the Herdr server starts, and one key switches it off and on again.
61
+ pi-straw-boss writes the sort key (`sb_order`), a role label (`sb_mark`) and, on subagents, a tree branch (`sb_indent`) the view and your sidebar rows can use.
60
62
  It needs Herdr 0.9.3 or newer and node.
61
63
 
64
+ <p align="center"><img src="docs/sidebar-net-view.png" width="308" alt="Herdr's sidebar in the net view, one row per agent: each workspace's tower with the radio tower glyph first, main agents marked with a star and members with a person, and every subagent indented under the session that launched it"></p>
65
+
66
+ <p align="center"><sub>The net view with each agent's role mark. <a href="docs/how-it-works.md#sidebar-net-view">How it works</a> has the sidebar rows that show them.</sub></p>
67
+
62
68
  Link it from a checkout, then bind a key in Herdr's `config.toml`:
63
69
 
64
70
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
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/dashboard.ts CHANGED
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
- import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
5
+ import { ARROW, headOf, type HerdrRun, isSub, MARK, type Pane, panes, parentOf, type Session, TOWER_LABEL, workspaces, type Workspace } from "./herdr.ts";
6
6
 
7
7
  // The tower's monitoring screen: a widget above the editor, never focused, redrawn only while something is working.
8
8
  //
@@ -19,7 +19,7 @@ export type Mark = "working" | "waiting" | "idle" | "done" | "error" | "unknown"
19
19
  /** One line of the tree. `net` rows are tab (0) > session (1) > subagent (2); `towers` rows are flat. */
20
20
  export interface Row { group: "net" | "towers"; depth: number; mark: Mark; text: string; kind?: string; note?: string; workspace?: string; id?: string; file?: string; pane?: string }
21
21
  export interface Snapshot { title: string; mark: Mark; head: boolean; rows: Row[]; gui?: string }
22
- export type Tone = "accent" | "warning" | "muted" | "success" | "dim" | "error" | "border" | "title";
22
+ export type Tone = "accent" | "warning" | "muted" | "success" | "dim" | "error" | "border" | "title" | "tower" | "main" | "member";
23
23
  export type Paint = (tone: Tone, text: string) => string;
24
24
  export interface Sources { run: HerdrRun; sessions(): Promise<Session[]>; workspaceId: string; now?: number; gui?(): Promise<string | undefined> }
25
25
 
@@ -29,6 +29,8 @@ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇",
29
29
  const GLYPH: Record<Exclude<Mark, "working">, string> = { waiting: "?", idle: "○", done: "✓", error: "✗", unknown: "·" };
30
30
  const TONE: Record<Mark, Tone> = { working: "accent", waiting: "warning", idle: "dim", done: "success", error: "error", unknown: "muted" };
31
31
  const HERDR_MARK: Record<string, Mark> = { working: "working", blocked: "waiting", idle: "idle", done: "done" };
32
+ // A row's role mark goes before its name, the same glyph as Herdr's sidebar shows (src/herdr.ts); a tab or sub workspace has none.
33
+ const ROLE: Record<string, [Tone, string]> = { main: ["main", MARK.main], member: ["member", MARK.member], subagent: ["dim", ARROW], tower: ["tower", TOWER_LABEL], "head tower": ["tower", TOWER_LABEL] };
32
34
  const RECENT_MS = 10 * 60_000;
33
35
  const MAX_ROWS = 18;
34
36
  const MAX_WIDTH = 76;
@@ -51,7 +53,8 @@ export function render({ title, mark, head, rows, gui }: Snapshot, frame: number
51
53
  const last = (i: number) => i < 0 || shown.slice(i + 1).find((r) => r.group === shown[i]!.group && r.depth <= shown[i]!.depth)?.depth !== shown[i]!.depth;
52
54
  const ancestor = (i: number, depth: number) => shown.findLastIndex((r, k) => k < i && r.group === shown[i]!.group && r.depth === depth);
53
55
  const prefix = (i: number) => (shown[i]!.group === "net" ? Array.from({ length: shown[i]!.depth }, (_, d) => (last(ancestor(i, d)) ? " " : "│ ")).join("") + (last(i) ? "└─ " : "├─ ") : "");
54
- const cells = shown.map((r, i) => paint("border", prefix(i)) + (r.mark === "unknown" && !r.kind ? "" : `${glyph(r.mark)} `) + r.text);
56
+ const role = (kind?: string) => (kind && ROLE[kind] ? `${paint(...ROLE[kind]!)} ` : "");
57
+ const cells = shown.map((r, i) => paint("border", prefix(i)) + (r.mark === "unknown" && !r.kind ? "" : `${glyph(r.mark)} `) + role(r.kind) + r.text);
55
58
  const [w1, w2] = [Math.max(...cells.map(visibleWidth), 0), Math.max(...shown.map((r) => r.kind?.length ?? 0))];
56
59
  const line = (i: number) => `${pad(cells[i]!, w1)} ${paint("muted", (shown[i]!.kind ?? "").padEnd(w2))} ${paint("dim", shown[i]!.note ?? "")}`.trimEnd();
57
60
  const section = (group: Row["group"], name: string, empty?: string) => {
@@ -61,7 +64,7 @@ export function render({ title, mark, head, rows, gui }: Snapshot, frame: number
61
64
  // What this pane is, and where the browser view lives, come before the tree.
62
65
  const intro = [paint("dim", "Control tower: main agents talk here (call_tower)."), gui ? `${paint("muted", "GUI")} ${paint("accent", gui)}` : paint("dim", "GUI not running; /boss gui opens it"), ""];
63
66
  const body = [...intro, ...section("net", "this net", "nothing has joined yet"), ...section("towers", "other towers"), ...(rows.length > MAX_ROWS ? [paint("muted", `… ${rows.length - MAX_ROWS} more`)] : [])];
64
- const [name, tag] = [` ${glyph(mark)} ${paint("title", `tower · ${title}`)} `, head ? ` ${paint("accent", "head tower")} ` : ""];
67
+ const [name, tag] = [` ${glyph(mark)} ${role("tower")}${paint("title", title)} `, head ? ` ${paint("accent", "head tower")} ` : ""];
65
68
  const dashes = "─".repeat(Math.max(1, total - 4 - visibleWidth(name) - visibleWidth(tag)));
66
69
  const frameLine = (text: string) => paint("border", "│ ") + pad(truncateToWidth(text, inner), inner) + paint("border", " │");
67
70
  return [paint("border", "╭─") + name + paint("border", dashes) + tag + paint("border", "─╮"), ...body.map(frameLine), paint("border", `╰${"─".repeat(total - 2)}╯`)].map((l) => truncateToWidth(l, width));
@@ -181,7 +184,9 @@ export function startDashboard({ ui, collect: read, frameMs = 125, pollMs = 3000
181
184
  };
182
185
  live = { poke: () => void (Date.now() - last >= minGapMs && refresh()), stop };
183
186
  void refresh();
184
- const paint: Paint = (tone, text) => (tone === "title" ? theme.bold(theme.fg("accent", text)) : theme.fg(tone, text));
187
+ // Every color is a theme role. The title and the role marks are bold: the tower in warning, a main agent in accent, a member in success.
188
+ const strong: Partial<Record<Tone, "accent" | "warning" | "success">> = { title: "accent", tower: "warning", main: "accent", member: "success" };
189
+ const paint: Paint = (tone, text) => (strong[tone] ? theme.bold(theme.fg(strong[tone], text)) : theme.fg(tone as Exclude<Tone, "title" | "tower" | "main" | "member">, text));
185
190
  return { render: (width) => render(snap, frame, width, paint), invalidate() {}, dispose: stop };
186
191
  });
187
192
  return { poke: () => live?.poke(), stop: () => (ui.setWidget(KEY, undefined), live?.stop(), (live = undefined), undefined) };
package/src/herdr.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createConnection } from "node:net";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
@@ -24,7 +25,7 @@ export interface HerdrTab { tab_id: string; workspace_id: string; label?: string
24
25
  export interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
25
26
  /** A live pi-intercom session; `status` is idle, thinking, or tool:<name>. */
26
27
  export interface Session { id: string; name?: string; status?: string; herdrLocation?: Location }
27
- export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean>; moveFirst(tabId: string): Promise<void> }
28
+ export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean>; moveFirst(tabId: string): Promise<void>; model?: string }
28
29
 
29
30
  const TTL_MS = 10 * 60_000;
30
31
  const START_MS = 30_000;
@@ -50,8 +51,18 @@ async function result(run: HerdrRun, args: string[], timeoutMs?: number) {
50
51
  }
51
52
  }
52
53
 
53
- // The tower tab is labelled with the Nerd Font radio tower glyph (md-radio_tower, U+F043B) alone.
54
+ /** The user's `towerModel` from `config.json` in the state directory, read at each tower start; anything unreadable means pi's default model. */
55
+ export function towerModel(stateDir: string): string | undefined {
56
+ let model: unknown;
57
+ try { model = (JSON.parse(readFileSync(join(stateDir, "config.json"), "utf8")) as { towerModel?: unknown }).towerModel; } catch { /* pi's default */ }
58
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
59
+ }
60
+
61
+ // The tower tab is labelled with the Nerd Font radio tower glyph (md-radio_tower, U+F043B) alone. The role marks (the `sb_mark` token, and the
62
+ // dashboard's rows) are Font Awesome's star (U+F005) for a main agent and person (U+F007) for a member, and `[sub]` for a subagent.
54
63
  export const TOWER_LABEL = "\u{f043b}";
64
+ export const MARK = { main: "\uf005", member: "\uf007" } as const;
65
+ export const ARROW = "[sub]";
55
66
 
56
67
  export async function place(run: HerdrRun): Promise<Place> {
57
68
  const pane = (await result(run, ["pane", "current"])).pane;
@@ -123,7 +134,7 @@ export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
123
134
  return pending;
124
135
  }
125
136
 
126
- async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Workspace, cwd: string) {
137
+ async function open({ run, sleep, live, moveFirst, model }: TowerEnv, workspace: Workspace, cwd: string) {
127
138
  const ws = workspace.workspace_id;
128
139
  const held = async () => {
129
140
  const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
@@ -152,7 +163,7 @@ async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Worksp
152
163
  }
153
164
  if (!running) {
154
165
  // Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
155
- const start = ["agent", "start", `tower-${ws.toLowerCase()}`, "--kind", "pi", "--pane", paneId, "--timeout", String(START_MS), "--", "--name", `tower · ${workspace.label}`];
166
+ const start = ["agent", "start", `tower-${ws.toLowerCase()}`, "--kind", "pi", "--pane", paneId, "--timeout", String(START_MS), "--", "--name", `tower · ${workspace.label}`, ...(model ? ["--model", model] : [])];
156
167
  // 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.
157
168
  for (let attempt = 1; ; attempt++) {
158
169
  try {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import { netSource } from "./net.ts";
12
12
  import { clearOrder, markKids, netTokens } from "./order.ts";
13
13
  import { copyText, pairingKeys, showLines } from "./pairing.ts";
14
14
  import { type Run, switchRemote, tailscale } from "./remote.ts";
15
- import { ensureTower, headOf, herdrApi, type HerdrApi, herdrCall, type HerdrCall, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
15
+ import { ensureTower, headOf, towerModel, herdrApi, type HerdrApi, herdrCall, type HerdrCall, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
16
16
 
17
17
  // pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
18
18
  const REGISTER = "intercom:extension-register";
@@ -242,7 +242,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
242
242
  if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
243
243
  const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
244
244
  const moveFirst = (tabId: string) => api("tab.move", { tab_id: tabId, insert_index: 0 }).catch((error: Error) => tell(`Straw Boss: could not move the tower tab first: ${error.message}`, "warning"));
245
- return { target, routed: target !== asked ? asked : undefined, ...(await ensureTower({ run, sleep, live, moveFirst }, target, process.cwd())) };
245
+ return { target, routed: target !== asked ? asked : undefined, ...(await ensureTower({ run, sleep, live, moveFirst, model: towerModel(guiDir) }, target, process.cwd())) };
246
246
  };
247
247
  const status = async () => {
248
248
  const sessions = await c.listSessions();
@@ -403,7 +403,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
403
403
  const held = holdsTower;
404
404
  holdsTower = false;
405
405
  if (!role() || !at) return;
406
- await report(run, "pane", at.paneId, { sb_role: null, sb_order: null, sb_mark: null }).catch(() => {});
406
+ await report(run, "pane", at.paneId, { sb_role: null, sb_order: null, sb_mark: null, sb_indent: null }).catch(() => {});
407
407
  await clearOrder(run, [...kids]);
408
408
  if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
409
409
  });
package/src/order.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  import { subagents } from "./dashboard.ts";
2
- import { type HerdrRun, type HerdrTab, isSub, type Pane, panes, type Place, report, tabs, workspaces, type Workspace } from "./herdr.ts";
2
+ import { ARROW, type HerdrRun, type HerdrTab, isSub, MARK, type Pane, panes, type Place, report, tabs, workspaces, type Workspace } from "./herdr.ts";
3
3
 
4
- // Two display tokens for the sidebar plugin and for Herdr's own Agents rows, written next to `sb_role`:
4
+ // Three display tokens for the sidebar plugin and for Herdr's own Agents rows, written next to `sb_role`:
5
5
  // - `sb_order`: a sort key that lays the net out as the head tower's workspace first, then the other workspaces in Herdr's order, in each the tower
6
6
  // and then the tabs in tab order, in each tab the main agent and then the members, and every subagent right after the session that launched it,
7
7
  // its own subagents after it in turn. Segments are fixed-width and `.`-separated, so plain string order is the numeric order of every segment,
8
8
  // and a subagent's key is its parent's key plus one more segment, which sorts after the parent and before the parent's next sibling.
9
9
  // - `sb_mark`: a short label that says what a net member is, a star alone for a main agent, a person (U+F007) alone for a member, and `[sub]` and the launcher's name for a subagent.
10
10
  // A tower has none: its tab already carries the radio tower glyph.
11
+ // - `sb_indent`: a tree branch on a subagent alone, like Herdr's Spaces panel draws a worktree: `├─` while a later subagent of the same launcher is marked, `└─` on the last. A row put before the state icon shows it as the
12
+ // indent under the launcher. A tower, a main agent and a member carry none.
11
13
  // A session writes them for itself and for the subagents it launched, which never join and write nothing, on the same 4 minute refresh as `sb_role`.
12
14
 
13
- /** The glyphs sit in Hack Nerd Font and MesloLGS NF: Font Awesome's star (U+F005), the middle dot, and the `[subagent]` tag. */
14
- export const MARK = { main: "\uf005", member: "\uf007" } as const;
15
- export const ARROW = "[sub]";
15
+ /** What `sb_indent` holds on a subagent: the branch to it from its launcher, and `last` for the youngest marked sibling. */
16
+ export const INDENT = { more: "\u251c\u2500", last: "\u2514\u2500" } as const;
16
17
  // The dashboard shows eight subagents per session, but every live one is keyed: one left out would sort outside its launcher's span. Three digits count them.
17
18
  const MAX_KIDS = 999;
18
19
  const NAME_MAX = 24;
@@ -38,26 +39,26 @@ export function orderKey(role: "tower" | "main" | "member", pane: Pane, ps: Pane
38
39
  */
39
40
  export function descendants(parent: Pane | undefined, key: string, name: string, ps: Pane[], now: number, seen = new Set<string>(parent ? [parent.pane_id] : [])): Kid[] {
40
41
  if (!parent) return [];
41
- return subagents(parent, ps, now, MAX_KIDS).flatMap((row, i) => {
42
+ return subagents(parent, ps, now, MAX_KIDS).flatMap((row, i, rows) => {
42
43
  if (!row.pane || seen.has(row.pane)) return [];
43
44
  seen.add(row.pane);
44
- const order = `${key}.${part(i)}`;
45
- return [{ pane: row.pane, order, mark: `${ARROW} ${short(name)}` }, ...descendants(ps.find((p) => p.pane_id === row.pane), order, row.text, ps, now, seen)];
45
+ const order = `${key}.${part(i)}`, indent = rows.slice(i + 1).some((r) => r.pane && !seen.has(r.pane)) ? INDENT.more : INDENT.last;
46
+ return [{ pane: row.pane, order, mark: `${ARROW} ${short(name)}`, indent }, ...descendants(ps.find((p) => p.pane_id === row.pane), order, row.text, ps, now, seen)];
46
47
  });
47
48
  }
48
49
 
49
- /** The subagent panes a session marks: each gets `sb_order` and `sb_mark`. */
50
- export interface Kid { pane: string; order: string; mark: string }
50
+ /** The subagent panes a session marks: each gets `sb_order`, `sb_mark` and `sb_indent`. */
51
+ export interface Kid { pane: string; order: string; mark: string; indent: string }
51
52
 
52
53
  /**
53
- * What a session publishes besides its role: its own two tokens (null clears them when Herdr cannot place the session, and a tower has no
54
- * mark) and those of the subagents it launched. Reads Herdr only.
54
+ * What a session publishes besides its role: its own tokens (null clears them when Herdr cannot place the session, a tower has no mark, and none
55
+ * has an indent, which clears the one a pane keeps from when it ran a subagent) and those of the subagents it launched. Reads Herdr only.
55
56
  */
56
57
  export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "main" | "member", name: string, now = Date.now()): Promise<{ own: Record<string, string | null>; kids: Kid[]; joined: Set<string> }> {
57
58
  const [ps, all, tabList] = [await panes(run), await workspaces(run), await tabs(run, place.workspaceId)];
58
59
  const pane = ps.find((p) => p.pane_id === place.paneId), key = pane && orderKey(role, pane, ps, all, tabList);
59
60
  const joined = new Set(ps.filter((p) => p.tokens?.sb_role).map((p) => p.pane_id));
60
- return { own: { sb_order: key ?? null, sb_mark: role === "tower" ? null : MARK[role] }, kids: key ? descendants(pane, key, name, ps, now) : [], joined };
61
+ return { own: { sb_order: key ?? null, sb_mark: role === "tower" ? null : MARK[role], sb_indent: null }, kids: key ? descendants(pane, key, name, ps, now) : [], joined };
61
62
  }
62
63
 
63
64
  /**
@@ -65,11 +66,11 @@ export async function netTokens(run: HerdrRun, place: Place, role: "tower" | "ma
65
66
  * (a shell where a subagent ran can start a pi that writes its own). Returns the panes now marked. A failed write waits for the next refresh.
66
67
  */
67
68
  export async function markKids(run: HerdrRun, kids: Kid[], before: Set<string>, joined = new Set<string>()): Promise<Set<string>> {
68
- for (const kid of kids) await report(run, "pane", kid.pane, { sb_order: kid.order, sb_mark: kid.mark }).catch(() => {});
69
+ for (const kid of kids) await report(run, "pane", kid.pane, { sb_order: kid.order, sb_mark: kid.mark, sb_indent: kid.indent }).catch(() => {});
69
70
  const marked = new Set(kids.map((k) => k.pane));
70
71
  await clearOrder(run, [...before].filter((id) => !marked.has(id) && !joined.has(id)));
71
72
  return marked;
72
73
  }
73
74
 
74
- /** Removes both tokens from panes, ignoring a pane that has already closed. */
75
- export const clearOrder = (run: HerdrRun, paneIds: string[]) => Promise.all(paneIds.map((id) => report(run, "pane", id, { sb_order: null, sb_mark: null }).catch(() => {})));
75
+ /** Removes the three tokens from panes, ignoring a pane that has already closed. */
76
+ export const clearOrder = (run: HerdrRun, paneIds: string[]) => Promise.all(paneIds.map((id) => report(run, "pane", id, { sb_order: null, sb_mark: null, sb_indent: null }).catch(() => {})));