pi-straw-boss 0.1.0 → 0.3.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
@@ -1,14 +1,23 @@
1
1
  # pi-straw-boss
2
2
 
3
- Straw Boss coordination for [pi](https://pi.dev) sessions that run in [Herdr](https://herdr.dev): a session **joins** a small net when its work competes or collaborates with other work in the workspace, the first to join a tab becomes its **main agent**, one **tower** per workspace opens on demand, and `boss_broadcast` reaches the rest. No tracking, no scheduler, no new service: the extension is glue over pi-intercom and Herdr metadata.
3
+ **Many pi agents, one project, no collisions.** pi-straw-boss gives the agents you run across [Herdr](https://herdr.dev) tabs a light chain of command: a main agent per tab, a tower per workspace, and a live view of who is doing what.
4
4
 
5
- ## Requirements
5
+ ![The straw net in the browser: three workspaces with their towers, agents, a subagent, and an outpost, with live states](docs/straw-net-gui.png)
6
6
 
7
- - pi, running inside a Herdr pane
8
- - [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer
9
- - Herdr 0.9.4 or newer
7
+ <p align="center"><sub><code>/boss gui</code>: every workspace of the net, live, in your browser. <a href="docs/straw-net-gui.gif">Watch it move</a>.</sub></p>
10
8
 
11
- Outside Herdr the extension does nothing.
9
+ ![The tower's dashboard in the terminal: this net's tabs, agents, and subagents, and the other towers](docs/tower-dashboard.png)
10
+
11
+ ## Why
12
+
13
+ Run a few agents on the same project and they start stepping on each other: two dev servers on one port, two branches editing the same migration, and no agent knows the others exist. pi-straw-boss lets them find each other and coordinate, without a scheduler or a new service.
14
+
15
+ - **Opt-in net.** Each session is asked once whether its work shares resources or needs to collaborate. Only those that say yes join.
16
+ - **A main agent per tab.** The first to join a tab coordinates it; `/boss` takes over.
17
+ - **A tower per workspace.** It opens on demand in its own tab, pinned first, and coordinates the main agents and the other towers. You don't need to talk to it.
18
+ - **A live dashboard** in the tower: who joined, and what they and their subagents are doing.
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.
20
+ - **Broadcast** to a tab, a workspace, every tower, or every session.
12
21
 
13
22
  ## Install
14
23
 
@@ -16,70 +25,33 @@ Outside Herdr the extension does nothing.
16
25
  pi install npm:pi-straw-boss
17
26
  ```
18
27
 
19
- While developing: `pi install ~/projects/pi-straw-boss` loads the working tree, and `/reload` picks up edits.
20
-
21
- ## How it works
22
-
23
- Joining is a choice, never forced.
28
+ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.npmjs.com/package/pi-intercom) 0.17.1 or newer. [pi-herdr-agents](https://github.com/giuseppecrj/pi-herdr-agents) is optional: with it, subagents show up under the session that launched them.
24
29
 
25
- 1. A session that is not in the net gets one short reminder at the start of its first task, asking whether its current work competes with other work in the workspace for resources (ports, databases, files, branches) or needs to collaborate with it.
26
- 2. If yes, it calls `boss_join` (or the human types `/boss`). It can also join later, whenever it discovers the need. The reminder is not repeated, and a session that never joins is left alone: no prompt, no listing, no broadcasts (except scope `all`).
27
- 3. The first session of a tab to join is the tab's **main agent**; later joiners are **members**. `/boss` in a member takes the role over.
28
- 4. Subagents launched by pi-herdr-agents (recognised by `PI_SUBAGENT_ID`) never join. Their parent represents them.
30
+ ## Quick start
29
31
 
30
- | Role | Who | Gets |
31
- |---|---|---|
32
- | main agent | first joiner of a tab, or the `/boss` taker-over | a short prompt: split work by app, brief workers, check results, ask the boss before merging or deploying |
33
- | member | later joiners of a tab | a short prompt naming the main agent and the tower |
34
- | tower | one per workspace, in its own tab labelled `tower` | a prompt for coordinating agents and repairing coordination friction; it never asks the user |
32
+ 1. Open pi in a Herdr tab and give it work. When the work needs to share or coordinate, the agent joins the net. You can also type `/boss`.
33
+ 2. Type `/boss status` to see each tab's main agent and the towers.
34
+ 3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
35
+ 4. Type `/boss gui` from any session to see the whole net in your browser.
35
36
 
36
- The tower is opened on demand by `call_tower` (or `/boss tower`), without taking focus, and answers through intercom. If two towers ever race in one workspace, the live token holder wins, otherwise the lower tab id, and the other closes its own tab. Towers of different workspaces reach each other with `call_tower`; the **head tower** is the tower of the first workspace in `herdr workspace list` order, so there is no election.
37
+ ## Tools and commands
37
38
 
38
- ## Tools
39
-
40
- | Tool | Available to | What it does |
41
- |---|---|---|
42
- | `boss_join` | sessions not yet in the net | Join this tab's net; the first joiner is main. |
43
- | `boss_status` | joined sessions and towers | The main agent of each tab, this workspace's tower, the head tower, and the other towers. |
44
- | `call_tower` | joined sessions and towers | Send a message to this workspace's tower, or another workspace's by id or exact label. Opens the tower tab when none runs. |
45
- | `boss_broadcast` | joined sessions and towers | One message to every other session in a scope. `tab`, `workspace` and `towers` reach only sessions that joined the net; `all` reaches every pi session on the machine, joined or not (for notices such as going to sleep). Default `workspace`. |
46
-
47
- | Command | Effect |
39
+ | Name | What it does |
48
40
  |---|---|
49
- | `/boss` | Join if needed, then become this tab's main agent. |
50
- | `/boss status` | Show `boss_status`. |
51
- | `/boss tower` | Open this workspace's tower if none runs, then focus its tab. |
52
-
53
- ## What it writes
54
-
55
- - Herdr pane token `sb_role` (`main`, `member` or `tower`) and workspace token `tower` (the tower's intercom session id), both refreshed every 4 minutes with a 10 minute TTL and cleared at shutdown.
56
- - A pi session entry `straw-boss` (`asked`, `joined`, `tower`) so a resumed session keeps its place without a second reminder.
57
- - One intercom extension channel per tab, `straw-boss/tab/<hex of the tab id>`, registered only by joined sessions and towers. Tab ids over 24 characters are refused.
41
+ | `boss_join` | Join this tab's net. |
42
+ | `boss_status` | Main agents, this workspace's tower, and the other towers. |
43
+ | `call_tower` | Message this workspace's tower, or another workspace's. |
44
+ | `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
45
+ | `/boss` | Join and become this tab's main agent. |
46
+ | `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
47
+ | `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server. |
58
48
 
59
- The tower tab is labelled `tower` and its Herdr agent is named `tower-<workspace id>` in lowercase (Herdr agent names are unique per server).
49
+ Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
60
50
 
61
- ## From Claude Code or Codex
62
-
63
- Read-only discovery works from any shell in a Herdr pane:
64
-
65
- ```bash
66
- herdr workspace get "$HERDR_WORKSPACE_ID" | jq -r '.result.workspace.tokens.tower'
67
- herdr pane list --workspace "$HERDR_WORKSPACE_ID" | jq -r '.result.panes[] | select(.tokens.sb_role) | [.pane_id, .tokens.sb_role] | @tsv'
68
- node ~/.pi/agent/npm/node_modules/pi-intercom/cli.mjs ask --to "$TOWER_SESSION_ID" --text "<message>"
69
- ```
70
-
71
- Opening a missing tower needs a pi session.
72
-
73
- ## Development
74
-
75
- ```bash
76
- npm install
77
- npm run check # tsc --noEmit
78
- npm test # node --test, against a fake Herdr and a fake intercom broker
79
- ```
51
+ ## Learn more
80
52
 
81
- Prompts are plain markdown in `prompts/`, read at session start, so an edit applies after `/reload`.
53
+ [How it works](docs/how-it-works.md) covers roles, the tower, the dashboard, the browser view, what the extension writes, and how Claude Code or Codex agents can find a tower.
82
54
 
83
55
  ## License
84
56
 
85
- MIT
57
+ MIT. The browser view's icons come from [game-icons.net](https://game-icons.net) (Lorc and Delapouite, [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/)); see [gui/ICONS.md](gui/ICONS.md).
@@ -0,0 +1 @@
1
+ :root{--bg:#060a14;--panel:#0e1628e0;--line:#7a98e033;--text:#dbe6ff;--dim:#8b99ba;--gold:#ffd166;--mono:ui-monospace, "SF Mono", Menlo, Consolas, monospace;--sans:ui-rounded, "Avenir Next", "Trebuchet MS", system-ui, -apple-system, "Segoe UI", sans-serif}*{box-sizing:border-box;margin:0}html{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}body{min-height:100vh;color:var(--text);font:14px/1.4 var(--sans);background:radial-gradient(1100px 640px at 12% -10%, #38e0ff1c, transparent 62%), radial-gradient(900px 600px at 98% 8%, #966eff1c, transparent 60%), linear-gradient(var(--bg), #0a1020);background-attachment:fixed}svg{display:block}[data-mark=working]{--c:#38e0ff}[data-mark=waiting]{--c:#ffb02e}[data-mark=idle]{--c:#7184ad}[data-mark=done]{--c:#3ddc97}[data-mark=error]{--c:#ff5470}[data-mark=unknown]{--c:#4f5c7c}.shell{max-width:1480px;margin:0 auto;padding:18px 22px 40px}.hud{border:1px solid var(--line);background:var(--panel);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);z-index:5;border-radius:18px;align-items:center;gap:24px;padding:12px 18px;display:flex;position:sticky;top:12px}.brand{align-items:center;gap:12px;margin-right:auto;display:flex}.logo{color:#38e0ff;border:1px solid var(--line);background:radial-gradient(circle at 30% 25%,#38e0ff4d,#0a1020e6);border-radius:14px;place-items:center;width:44px;height:44px;display:grid}.logo svg{filter:drop-shadow(0 0 6px #38e0ff);width:28px;height:28px}h1{letter-spacing:.14em;text-transform:uppercase;font-size:20px}.brand p{color:var(--dim);font-size:12px}.stats{gap:10px;display:flex}.stats div{background:#ffffff0a;border:1px solid #ffffff0f;border-radius:12px;min-width:78px;padding:6px 12px}.stats dt{color:var(--dim);letter-spacing:.08em;text-transform:uppercase;font-size:11px}.stats dd{font:600 20px/1.1 var(--mono)}.stats [data-mark] dd{color:var(--c)}.link{color:var(--dim);text-transform:uppercase;letter-spacing:.08em;align-items:center;gap:8px;font-size:12px;display:flex}.link i{background:#4f5c7c;border-radius:50%;width:9px;height:9px}.link[data-state=live] i{background:#3ddc97;animation:2.4s ease-in-out infinite blink}.link[data-state=live]{color:#3ddc97}.link[data-state=reconnecting] i,.link[data-state=connecting] i{background:#ffb02e;animation:1s ease-in-out infinite blink}.link[data-state=offline] i{background:#ff5470}.banner{color:#c9d6f5;background:#7184ad29;border-radius:14px;margin:14px 0 0;padding:10px 16px;font-size:13px}.banner[hidden]{display:none}.banner b{font-family:var(--mono);font-weight:600}.stage{grid-template-columns:minmax(0,1fr) 280px;align-items:start;gap:20px;margin-top:18px;transition:opacity .4s;display:grid}.stage[data-stale=true] .map,body[data-stale=true] .stats{opacity:.4}.stage[data-stale=true] *{animation-play-state:paused!important}.map{border:1px solid var(--line);background:linear-gradient(#82a0ff0d 1px,#0000 1px) 0 0/44px 44px,linear-gradient(90deg,#82a0ff0d 1px,#0000 1px) 0 0/44px 44px,#080d1a8c;border-radius:22px;min-height:420px;padding:26px;transition:opacity .4s;position:relative}.links{pointer-events:none;width:100%;height:100%;position:absolute;inset:0}.wire{fill:none;stroke:#38e0ff4d;stroke-width:1.5px;stroke-dasharray:3 7;stroke-linecap:round}.packet{fill:#38e0ff;opacity:.9}.bases{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));align-items:start;gap:30px;display:grid;position:relative}.empty{color:var(--dim);text-align:center;padding:120px 0;position:relative}.base{border:1px solid var(--line);background:linear-gradient(#16213c,#0c1324);border-radius:18px;padding:16px;position:relative;box-shadow:0 18px 40px #00000059}.base.head{background:linear-gradient(#ffd1661a,#0000 38%),linear-gradient(#16213c,#0c1324)}.base[data-mark=waiting]{box-shadow:0 18px 40px #00000059,inset 0 0 0 999px #ffb02e0b}.base>header{align-items:center;gap:14px;margin-bottom:14px;display:flex}.title{justify-items:start;min-width:0;margin-right:auto;display:grid}.title h2{white-space:nowrap;text-overflow:ellipsis;max-width:100%;font-size:17px;overflow:hidden}.title p{max-width:100%;color:var(--dim);white-space:nowrap;text-overflow:ellipsis;font-size:12px;overflow:hidden}.crown{letter-spacing:.12em;text-transform:uppercase;color:var(--gold);background:#ffd1661f;border-radius:999px;align-items:center;gap:5px;margin-bottom:3px;padding:2px 9px 2px 6px;font-size:10px;display:inline-flex}.crown svg{width:14px;height:14px}.base:not(.head) .crown{display:none}.tower{width:80px;height:80px;color:var(--c);flex:none;place-items:center;display:grid;position:relative}.tower:before{content:"";background:radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--c) 24%, #101a2e), #0b1222 70%);border:1px solid color-mix(in srgb, var(--c) 45%, transparent);border-radius:50%;position:absolute;inset:0}.tower svg{width:48px;height:48px;filter:drop-shadow(0 0 6px color-mix(in srgb, var(--c) 70%, transparent));position:relative}.tower.ghost{opacity:.45}.tower.ghost:before{border-style:dashed}.sweep,.pulse{opacity:0;pointer-events:none;border-radius:50%;position:absolute;inset:0}.sweep{background:conic-gradient(from 0deg, transparent 0 70%, color-mix(in srgb, var(--c) 55%, transparent) 100%)}.pulse{border:2px solid var(--c)}.tower[data-mark=working] .sweep{opacity:1;animation:3.2s linear infinite spin}.tower[data-mark=waiting] .pulse{animation:1.5s ease-out infinite ping}.tower>.badge{position:absolute;bottom:-2px;right:-2px}.tabs{gap:12px;display:grid}.tab{background:#ffffff06;border:1px solid #ffffff0d;border-radius:14px;padding:10px}.tab h3{color:var(--dim);letter-spacing:.12em;text-transform:uppercase;margin-bottom:8px;font-size:11px;font-weight:600}.units,.subs{gap:8px;display:grid}.outposts{gap:8px;margin-top:12px;display:grid}.outposts:empty{display:none}.outposts:before{content:"Outposts";color:var(--dim);letter-spacing:.12em;text-transform:uppercase;font-size:11px}.card{background:#ffffff09;border:1px solid #ffffff0f;border-radius:12px;align-items:center;gap:10px;padding:8px 10px;transition:background-color .4s;display:flex}.unit[data-mark=waiting]>.card{background:#ffb02e1c}.unit[data-mark=error]>.card{background:#ff54701c}.unit[data-mark=working]>.card{background:#38e0ff12}.plate{width:40px;height:40px;color:var(--c);background:radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--c) 26%, #101a2e), #0b1222 72%);border-radius:50%;flex:none;place-items:center;transition:color .4s;display:grid;position:relative}.plate svg{width:22px;height:22px}.ring,.plate:after{pointer-events:none;border-radius:50%;position:absolute;inset:-3px}.ring{opacity:0;background:conic-gradient(from 0deg, transparent 0 55%, var(--c));-webkit-mask:radial-gradient(farthest-side,#0000 calc(100% - 3px),#000 calc(100% - 2px));mask:radial-gradient(farthest-side,#0000 calc(100% - 3px),#000 calc(100% - 2px))}.plate[data-mark=working]>.ring{opacity:1;animation:1.1s linear infinite spin}.plate:after{content:"";border:2px solid var(--c);opacity:0}.plate[data-mark=waiting]:after{animation:1.5s ease-out infinite ping}.badge{width:16px;height:16px;font:700 10px/1 var(--mono);color:#06101e;background:var(--c);border-radius:50%;place-items:center;transition:background-color .4s;display:grid}.plate>.badge{position:absolute;bottom:-4px;right:-4px}[data-mark=waiting]>.badge:after{content:"?"}[data-mark=idle]>.badge:after{content:"z"}[data-mark=done]>.badge:after{content:"✓"}[data-mark=error]>.badge:after{content:"!"}[data-mark=unknown]>.badge:after{content:"·"}[data-mark=working]>.badge:after{content:"";border:2px solid #06101e;border-right-color:#0000;border-radius:50%;width:8px;height:8px;animation:.8s linear infinite spin}[data-mark=waiting]>.badge{animation:1.2s ease-in-out infinite hop}.meta{min-width:0;display:grid}.name{white-space:nowrap;text-overflow:ellipsis;font-weight:600;overflow:hidden}.line{color:var(--dim);align-items:baseline;gap:8px;min-width:0;font-size:12px;display:flex}.role{letter-spacing:.1em;text-transform:uppercase;color:color-mix(in srgb, var(--c) 70%, var(--dim));font-size:10px;font-style:normal}.note{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.unit[data-mark=idle]>.card{opacity:.85}.subs{margin:6px 0 0 22px;padding-left:18px;position:relative}.subs:empty{display:none}.subs:before{content:"";background:repeating-linear-gradient(#7a98e080 0 4px,#0000 4px 8px);width:1px;position:absolute;top:-4px;bottom:22px;left:3px}.subs>.unit>.card:before{content:"";background:repeating-linear-gradient(90deg,#7a98e080 0 4px,#0000 4px 8px);width:15px;height:1px;position:absolute;top:50%;left:-15px}.subs>.unit{position:relative}.subs .plate{width:30px;height:30px}.subs .plate svg{width:17px;height:17px}.subs .name{font-size:13px}.side{gap:16px;display:grid;position:sticky;top:96px}.panel{border:1px solid var(--line);background:var(--panel);border-radius:18px;padding:14px 16px}.panel h2{color:var(--dim);letter-spacing:.14em;text-transform:uppercase;margin-bottom:10px;font-size:11px}.log{gap:7px;max-height:360px;padding:0;list-style:none;display:grid;overflow:hidden}.log:empty:before{content:"Waiting for changes…";color:var(--dim);font-size:12px}.log li{grid-template-columns:auto 1fr;gap:2px 8px;font-size:12px;animation:.4s ease-out both slide;display:grid}.log li:before{content:"";background:var(--c);border-radius:50%;width:8px;height:8px;margin-top:5px}.log time{font:11px var(--mono);color:var(--dim);grid-column:2}.credit{color:var(--dim);padding:0 6px;font-size:11px}.legend{gap:8px;padding:0;font-size:12px;list-style:none;display:grid}.legend li{align-items:center;gap:9px;display:flex}.enter{animation:.5s cubic-bezier(.2,.9,.3,1.15) both pop}.leaving{pointer-events:none;animation:.35s ease-in both out}.flash>.card>.plate,.flash.tower{animation:.6s ease-out flash}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{0%{opacity:.6;transform:scale(1)}to{opacity:0;transform:scale(1.7)}}@keyframes hop{50%{transform:translateY(-3px)}}@keyframes blink{50%{opacity:.35}}@keyframes pop{0%{opacity:0;transform:scale(.92)translateY(8px)}}@keyframes out{to{opacity:0;transform:scale(.94)}}@keyframes flash{40%{transform:scale(1.12)}}@keyframes slide{0%{opacity:0;transform:translate(10px)}}html.paused *,html.paused :before,html.paused :after{animation-play-state:paused!important}@media (width<=1100px){.stage{grid-template-columns:1fr}.side{position:static}.stats{display:none}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}
@@ -0,0 +1 @@
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e={working:`started working`,waiting:`is waiting on a question`,idle:`went idle`,done:`finished`,error:`hit an error`,unknown:`lost its status`};function t(e){let t=new Map;for(let n of e.bases){n.tower&&t.set(`tower:${n.id}`,{name:`${n.label} tower`,mark:n.tower.mark});for(let e of n.tabs.flatMap(e=>e.units))for(let n of[e,...e.subs])t.set(n.id,{name:n.name,mark:n.mark});for(let e of n.outposts)t.set(e.id,{name:e.label,mark:e.mark})}return t}function n(n,r){if(!n)return[];let[i,a]=[t(n),t(r)],o=[];for(let[t,n]of a){let r=i.get(t);r?r.mark!==n.mark&&o.push({text:`${n.name} ${e[n.mark]}`,mark:n.mark}):o.push({text:`${n.name} joined`,mark:n.mark})}for(let[e,t]of i)a.has(e)||o.push({text:`${t.name} left`,mark:`idle`});return o}var r={main:`main`,member:`member`,subagent:`subagent`,outpost:`outpost`},i={working:`working`,waiting:`waiting on a question`,idle:`idle`,done:`finished`,error:`error`,unknown:`no status`},a=e=>{let t=new DOMParser().parseFromString(e.trim(),`text/html`).body.firstElementChild;if(!(t instanceof HTMLElement))throw Error(`bad template`);return document.importNode(t,!0)},o=(e,t)=>{let n=document.getElementById(e);if(!(n instanceof t))throw Error(`#${e} is missing from the page`);return n},s=(e,t,n)=>{let r=e.querySelector(t);r.textContent!==n&&(r.textContent=n)},c=(e,t)=>{if(e.dataset.mark===t)return;let n=e.dataset.mark!==void 0;e.dataset.mark=t,n&&(e.classList.remove(`flash`),e.offsetWidth,e.classList.add(`flash`))},l=(e,t)=>e?e.replace(/^tool:/,`using `):i[t];function u(e,t,n,r){let i=new Map;for(let t of e.children)t.classList.contains(`leaving`)||i.set(t.dataset.key,t);let a=e.firstElementChild;for(let o of t){let t=i.get(o.id);for(t||(t=n(o),t.dataset.key=o.id,t.classList.add(`enter`),setTimeout(()=>t.classList.remove(`enter`),600)),i.delete(o.id);a?.classList.contains(`leaving`);)a=a.nextElementSibling;t===a?a=a.nextElementSibling:e.insertBefore(t,a),r(t,o)}for(let e of i.values())e.classList.add(`leaving`),setTimeout(()=>e.remove(),400)}var d=()=>a(`<div class="unit"><div class="card"><div class="plate"><i class="ring"></i><svg><use href="#i-main"/></svg><b class="badge"></b></div><div class="meta"><span class="name"></span><span class="line"><em class="role"></em><span class="note"></span></span></div></div><div class="subs"></div></div>`);function f(e,t){c(e,t.mark),c(e.querySelector(`.plate`),t.mark),e.querySelector(`use`).setAttribute(`href`,`#i-${r[t.role]}`),s(e,`.name`,t.name),s(e,`.role`,t.role),s(e,`.note`,l(t.note,t.mark)),u(e.querySelector(`.subs`),t.subs??[],d,f)}var p=()=>a(`<section class="base"><header><div class="tower"><i class="sweep"></i><i class="pulse"></i><svg><use href="#i-tower"/></svg><b class="badge"></b></div><div class="title"><span class="crown"><svg><use href="#i-crown"/></svg>head tower</span><h2></h2><p></p></div></header><div class="tabs"></div><div class="outposts"></div></section>`);function m(e,t){c(e,t.mark),e.classList.toggle(`head`,t.head);let n=e.querySelector(`.tower`);n.classList.toggle(`ghost`,!t.tower),c(n,t.tower?.mark??`unknown`),s(e,`h2`,t.label),s(e,`.title p`,t.tower?`control tower · ${i[t.tower.mark]}`:`no tower yet`),u(e.querySelector(`.tabs`),t.tabs,()=>a(`<div class="tab"><h3></h3><div class="units"></div></div>`),(e,t)=>{s(e,`h3`,`tab ${t.label}`),u(e.querySelector(`.units`),t.units,d,f)}),u(e.querySelector(`.outposts`),t.outposts.map(e=>({id:e.id,name:e.label,role:`outpost`,mark:e.mark,note:e.mark===`unknown`?`no agent running`:void 0})),d,f)}function h(e,t,n){let r=[...e.querySelectorAll(`.base:not(.leaving) .tower`)],i=e.querySelector(`.base.head:not(.leaving) .tower`),a=e.getBoundingClientRect(),o=e=>{let t=e.getBoundingClientRect();return[Math.round(t.x+t.width/2-a.x),Math.round(t.y+t.height/2-a.y)]},s=i?r.filter(e=>e!==i).map(e=>{let[[t,n],[r,a]]=[o(i),o(e)],s=Math.round((t+r)/2);return`M${t} ${n}C${s} ${n} ${s} ${a} ${r} ${a}`}):[],c=s.join(`|`);if(t.dataset.sig===c)return;t.dataset.sig=c;let l=(e,t,...n)=>{let r=document.createElementNS(`http://www.w3.org/2000/svg`,e);for(let[e,n]of Object.entries(t))r.setAttribute(e,n);return r.append(...n),r};t.replaceChildren(...s.flatMap(e=>[l(`path`,{class:`wire`,d:e}),...n?[]:[l(`circle`,{class:`packet`,r:`3`},l(`animateMotion`,{dur:`5s`,repeatCount:`indefinite`,path:e}))]]))}function g(){let[e,t,n,r]=[o(`map`,HTMLElement),o(`bases`,HTMLElement),o(`links`,SVGSVGElement),o(`log`,HTMLElement)],i=matchMedia(`(prefers-reduced-motion: reduce)`).matches,c=()=>h(e,n,i);return new ResizeObserver(c).observe(e),{layout:c,show(e){u(t,e.bases,p,m),o(`empty`,HTMLElement).hidden=e.bases.length>0;let n=e.bases.flatMap(e=>e.tabs.flatMap(e=>e.units.flatMap(e=>[e,...e.subs]))),r=e=>String(n.filter(t=>!e||t.mark===e).length);for(let[t,n]of[[`s-bases`,String(e.bases.length)],[`s-agents`,r()],[`s-working`,r(`working`)],[`s-waiting`,r(`waiting`)]])o(t,HTMLElement).textContent=n;requestAnimationFrame(c)},log(e){let t=new Date().toLocaleTimeString([],{hour12:!1});for(let n of e){let e=a(`<li><span></span><time></time></li>`);e.dataset.mark=n.mark,s(e,`span`,n.text),s(e,`time`,t),r.prepend(e)}for(;r.children.length>12;)r.lastElementChild.remove()},pause(e){document.documentElement.classList.toggle(`paused`,e),e?n.pauseAnimations():n.unpauseAnimations()}}}var _=45e3,v=1e3,y=3e4,b=(e,t)=>Math.min(y,v*2**(e-1))*(.8+.4*t);function x(e){try{return JSON.parse(e)}catch{return}}function S(e){let t,n,r,i=0,a={state:`connecting`,attempt:0},o=t=>e.onStatus(a={...a,...t}),s=()=>(e.clearTimeout(n),e.clearTimeout(r),n=r=void 0),c=()=>{let e=t;t=void 0,e?.close()},l=()=>{e.clearTimeout(r),r=e.setTimeout(()=>u(),_),o({state:`live`,attempt:0,lastContact:e.now()})},u=()=>{c(),s();let t=a.attempt+1;if(t>8)return o({state:`offline`,attempt:t});o({state:`reconnecting`,attempt:t}),e.hidden()||(n=e.setTimeout(d,b(t,e.random())))},d=()=>{c(),s(),i=e.now();let n=t=e.open();n.onError(()=>t===n&&u()),n.listen(`message`,r=>{if(t!==n)return;let i=x(r);i&&e.onNet(i),o({problem:i?void 0:`the server sent a snapshot the page cannot read`}),l()}),n.listen(`ping`,()=>t===n&&l()),n.listen(`problem`,e=>{t===n&&(o({problem:x(e)??`the server reported a problem the page cannot read`}),l())}),r=e.setTimeout(()=>u(),_)};return{start(){o({state:`connecting`,attempt:0}),d()},resume(){let n=e.now()-i<2e3||a.lastContact!==void 0&&e.now()-a.lastContact<=2e4;t&&n||(o({attempt:0,state:a.state===`live`?`reconnecting`:a.state}),d())},hide(){e.clearTimeout(n),n=void 0},stop(){c(),s()}}}var C=g(),w=e=>document.getElementById(e),T=e=>e?new Date(e).toLocaleTimeString([],{hour12:!1}):`never`,E,D,O={state:`connecting`,attempt:0};function k(e){D=e,!document.hidden&&(C.log(n(E,e)),C.show(e),E=e,D=void 0)}function A(e){O=e;let t=e.state===`reconnecting`||e.state===`offline`;document.body.dataset.stale=w(`stage`).dataset.stale=String(t),w(`link`).dataset.state=e.state,w(`link-text`).textContent={connecting:`connecting`,live:`live`,reconnecting:`reconnecting`,offline:`offline`}[e.state];let n=w(`banner`);n.hidden=!t&&!e.problem;let r=e.state===`offline`?`Disconnected. Gave up after ${e.attempt-1} tries; it reconnects when you return to this tab.`:e.state===`reconnecting`?`Disconnected from the straw net. Reconnecting${e.attempt?` (try ${e.attempt})`:``}… The map shows the last known state.`:`The server is up but cannot read the net: ${e.problem}`,i=document.createElement(`b`);i.textContent=T(e.lastContact),n.replaceChildren(`${r} Last update `,i,`.`)}function j(){let e=new EventSource(`events`);return{onError:t=>e.onerror=t,listen:(t,n)=>e.addEventListener(t,e=>n(e.data)),close:()=>e.close()}}var M=S({open:j,now:Date.now,setTimeout:(e,t)=>window.setTimeout(e,t),clearTimeout:e=>window.clearTimeout(e),random:Math.random,hidden:()=>document.hidden,onNet:k,onStatus:A}),N=()=>M.resume();document.addEventListener(`visibilitychange`,()=>{if(C.pause(document.hidden),document.hidden)return M.hide();D&&k(D),N()});for(let e of[`focus`,`pageshow`,`online`])window.addEventListener(e,()=>!document.hidden&&N());A(O),M.start();
@@ -0,0 +1,59 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>Straw Net</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230b1220'/%3E%3Cpath d='M16 5l9 7h-4v13h-10V12H7z' fill='%2338e0ff'/%3E%3C/svg%3E">
9
+ <script type="module" crossorigin src="./assets/index-CH3266oL.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-BP87OlaT.css">
11
+ </head>
12
+ <body>
13
+ <!-- Icons: game-icons.net, CC BY 3.0 (Lorc, Delapouite); see gui/ICONS.md -->
14
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
15
+ <symbol id="i-tower" viewBox="0 0 512 512" fill="currentColor"><path d="M352 21v18h39v16h18V39h39V21h-96zm35 52l-40 30h106l-40-30h-26zM69.48 120.5l-8.96 15.6 110.98 63.7 35.6 20.4 33.6-58.7-77 13.1-94.22-54.1zM329 121v30h30v-30h-30zm48 0v30h46v-30h-46zm64 0v30h30v-30h-30zm-103 48l34.5 46h55l34.5-46H338zm39 64v30h46v-30h-46zm0 48v206h46V281h-46zM68.52 329L34.3 375H359v-46H68.52zM25 393v94h30v-64h66v64h238v-94H25zm416 .5V487h46v-65.9l-46-27.6zM160 439h48v18h-48v-18zm96 0h48v18h-48v-18zm-183 2v46h30v-46H73z"/></symbol>
16
+ <symbol id="i-crown" viewBox="0 0 512 512" fill="currentColor"><path d="M408.256 119.46l-37.7 52.165 19.57 44.426 34.8-37.214-16.67-59.375zm86.074 12.513L384.44 249.498 334.01 135.02l-75.162 132.947-86.948-131.78-33.334 114.122L17.922 132.83l39.3 127.6c1.945-.348 3.94-.54 5.98-.54 18.812 0 34.26 15.452 34.26 34.262 0 13.823-8.346 25.822-20.235 31.22l5.337 17.33c12.425 25.466 71.863 45.152 176.582 47.206 110.805 2.174 178.12-17.54 189.854-47.207h-.002l4.357-20.26c-16.836-2.114-30.02-16.612-30.02-33.986 0-18.81 15.45-34.262 34.263-34.262 3.513 0 6.91.54 10.11 1.54l26.622-123.762zm-391.77 2.04l1.22 56.337 25.56 24.89 9.592-32.842-36.37-48.386zm150.585 2.91l-24.483 51.36 28.955 43.885 24.922-44.08-29.395-51.166zm204.453 135.962c-8.712 0-15.575 6.862-15.575 15.572 0 8.71 6.863 15.574 15.575 15.574s15.572-6.863 15.572-15.573-6.86-15.572-15.572-15.572zM63.2 278.58c-8.71 0-15.573 6.864-15.573 15.574s6.862 15.573 15.574 15.573c8.713 0 15.573-6.862 15.573-15.573 0-8.71-6.86-15.574-15.572-15.574zm130.33 17.842c18.812 0 34.26 15.45 34.26 34.262 0 18.81-15.448 34.26-34.26 34.26-18.813 0-34.262-15.45-34.262-34.26s15.45-34.262 34.26-34.262zm131.234 0c18.812 0 34.26 15.45 34.26 34.262 0 18.81-15.448 34.26-34.26 34.26-18.813 0-34.262-15.45-34.262-34.26s15.45-34.262 34.262-34.262zm-131.235 18.69c-8.713 0-15.573 6.86-15.573 15.572 0 8.71 6.86 15.574 15.572 15.574 8.71 0 15.572-6.864 15.572-15.574s-6.86-15.573-15.573-15.573zm131.234 0c-8.712 0-15.573 6.86-15.573 15.572 0 8.71 6.862 15.574 15.574 15.574s15.574-6.864 15.574-15.574-6.862-15.573-15.574-15.573z"/></symbol>
17
+ <symbol id="i-main" viewBox="0 0 512 512" fill="currentColor"><path d="M256 19c-13.8 0-27.9 3.02-41.5 8.48L256 79.44l41.5-51.96C283.9 22.02 269.8 19 256 19zm-59.4 16.96c-18.5 10.27-35.9 24.69-50.9 41.74-10.8 12.21-20.2 25.7-24.8 32.4l-64.8 87.1 59.1 83L95.9 348l71.6 64.2-6.9-27.3 36.1-108.2-40.7 27.1-75.98-106.3 42.28-56.3 97.4 48.7h72.7l97.4-48.7 42.3 56.3-76 106.3-40.8-27.2 1 3.2 35.2 105.1-6.9 27.3 71.5-64.2-19.4-67.8 59.2-83-64.8-87.1c-4.6-6.7-14-20.19-24.8-32.4-15-17.05-32.4-31.47-50.9-41.74L256 110.2zm15.1 256.44l-5 15h98.6l-5-15zm-11.5 34.2l-16.3 49.1h144.2l-16.4-49.1zm-17.3 68.3l12.3 49.2h121.6l12.2-49.2zm33.6 68.4L256 493l39.5-29.7z"/></symbol>
18
+ <symbol id="i-member" viewBox="0 0 512 512" fill="currentColor"><path d="M255.875 19.47c-33.142 0-59.844 26.822-59.844 60.186 0 33.364 26.703 60.156 59.845 60.156 33.142 0 59.875-26.792 59.875-60.156S289.017 19.47 255.875 19.47zm-50.688 120.343c-2.908 1.23-5.658 2.53-8.187 3.937-14.467 8.046-21.47 17.86-21.47 27.094 0 9.234 7.003 19.08 21.47 27.125 14.467 8.044 35.51 13.436 58.875 13.436 23.365 0 44.408-5.392 58.875-13.437 14.467-8.047 21.47-17.892 21.47-27.126 0-9.234-7.003-19.048-21.47-27.094-2.53-1.406-5.28-2.708-8.188-3.938-13.696 11.647-31.392 18.688-50.687 18.688-19.3 0-36.996-7.034-50.688-18.688zm78.875 87.906c-8.948 1.54-18.394 2.374-28.187 2.374-9.315 0-18.316-.758-26.875-2.156 2.69 6.923 4.36 14.186 4.906 21.656 2.456 33.554-17.04 69.573-58.47 93.594l-.155.093-.155.095c-20.062 10.653-30.28 24.056-30.28 36.97 0 12.9 10.28 26.46 30.343 37.217 20.062 10.76 48.86 17.844 80.75 17.844s60.687-7.085 80.75-17.844c20.062-10.758 30.343-24.318 30.343-37.218 0-13.127-10.773-26.656-31.655-37.406l-.22-.125-.186-.094c-40.344-23.394-58.705-59.676-55.908-93.22.626-7.497 2.31-14.813 5-21.78zM128.845 395.655c-5.592 3.72-10.256 7.61-13.875 11.53-6.9 7.48-9.94 14.64-9.94 21.845 0 7.206 3.04 14.397 9.94 21.876 6.898 7.48 17.6 14.852 31.28 21.125 27.36 12.547 66.42 20.69 109.625 20.69 43.206 0 82.295-8.143 109.656-20.69 13.682-6.27 24.352-13.644 31.25-21.124 6.9-7.48 9.97-14.67 9.97-21.875 0-7.204-3.07-14.363-9.97-21.842-3.597-3.902-8.238-7.767-13.78-11.47-5.638 15.6-19.584 28.706-37.5 38.313-23.533 12.62-54.947 20.095-89.563 20.095-34.615 0-66.06-7.474-89.593-20.094-17.94-9.62-31.887-22.747-37.5-38.374z"/></symbol>
19
+ <symbol id="i-subagent" viewBox="0 0 512 512" fill="currentColor"><path d="M407 24.98v14.04h16V25zM88.99 25v14.03H105V25zM487 57H344.9v15.96H487zm-320 0H25v15.98h142zm256 33.93h-16.1v27.97l13.7.1h2.4zm-318 .1H88.97v28.07H105zm272 45.87l-.1 32.1h-78.5l-16-32h-52.8l-16 32.1-78.6-.1v-32H55.03l-12.04 48L199 214.4V208c.1-31.4 25.7-56.9 57.1-57 31.3 0 57 25.6 56.9 57 0 4.1.1 6.4.1 6.4L469 185.1 457 137zM256.1 169c-21.6 0-39 17.4-39.1 39.1.1 21.5 17.5 38.9 39.1 38.9s39-17.4 38.9-39c.1-21.5-17.3-39-38.9-39zm-.2 14c13.8 0 25 11.3 25.1 25 0 13.7-11.3 25.1-25 25-13.7 0-25.1-11.3-25-24.9 0-13.8 11.3-25 24.9-25.1zm-54.7 40.5L215 279h82l13.9-55.4c-6.8 23.9-28.8 41.4-54.8 41.4-26.1 0-48.1-17.6-54.9-41.5zm-35.8 4.2L60.35 321.1l83.85 107.7c5-5 11.3-8.8 18.2-11.1L100.2 318l87.9-73-3.4-13.4zm181.3.2l-19.4 3.8-3.3 13.4 87.8 73-62.2 99.6c7 2.3 13.2 6.2 18.3 11.3l83.8-107.8zM176 433.6c-15 0-26.9 11.9-26.9 26.7 0 14.9 11.9 26.7 27 26.8 14.9-.1 26.9-12 26.9-26.8-.1-14.8-11.9-26.7-27-26.7zm159.9 0c-14.9.1-27 11.9-26.9 26.8 0 14.7 11.9 26.6 27 26.6 15 0 27-11.8 26.9-26.7.1-14.8-11.9-26.7-27-26.7z"/></symbol>
20
+ <symbol id="i-outpost" viewBox="0 0 512 512" fill="currentColor"><path d="M361.155 91.245l-18 .193.42 38.98c-45.773 13.285-108.533 19.738-166.474 23.573 35.097 96.284 99.357 173.77 157.845 257.13 20.718-19.655 51.11-31.983 83.46-36.01-20.8-18.109-36.634-27.966-58.833-70.438 31.27 37.085 52.579 48.467 77.623 62.006 3.263-13.094 8.938-24.638 18.721-32.674 8.667-7.12 20.026-10.654 33.53-10.344-46.874-59.763-101.67-117.054-127.83-189.435l-.462-42.98zM163.25 102.92l-17.998.244s.25 18.34.56 36.97c.156 9.316.325 18.703.489 25.929.06 2.636.117 4.58.174 6.542-34.378 83.733-69.154 160.993-123.92 233.442 33.635-1.387 66.326-1.203 98.552-.041 22.263-62.617 23.346-134.855 35.627-202.006 11.417 68.562 10.566 139.445 33.483 205.83 42.962 3.082 85.69 7.198 129.35 10.926-55.67-79.151-118.213-155.037-155.118-249.365-.05-1.782-.1-3.396-.152-5.737-.162-7.156-.333-16.523-.488-25.82-.31-18.594-.559-36.914-.559-36.914z"/></symbol>
21
+ </defs></svg>
22
+ <div class="shell">
23
+ <header class="hud">
24
+ <div class="brand">
25
+ <span class="logo"><svg><use href="#i-tower"/></svg></span>
26
+ <div><h1>Straw Net</h1><p>read-only live view</p></div>
27
+ </div>
28
+ <dl class="stats" aria-label="Net totals">
29
+ <div><dt>Bases</dt><dd id="s-bases">0</dd></div>
30
+ <div><dt>Agents</dt><dd id="s-agents">0</dd></div>
31
+ <div data-mark="working"><dt>Working</dt><dd id="s-working">0</dd></div>
32
+ <div data-mark="waiting"><dt>Waiting</dt><dd id="s-waiting">0</dd></div>
33
+ </dl>
34
+ <div class="link" id="link" data-state="connecting"><i></i><span id="link-text">connecting</span></div>
35
+ </header>
36
+ <div class="banner" id="banner" role="status" hidden></div>
37
+ <div class="stage" id="stage">
38
+ <main class="map" id="map">
39
+ <svg class="links" id="links" aria-hidden="true"></svg>
40
+ <div class="bases" id="bases"></div>
41
+ <p class="empty" id="empty" hidden>No workspaces yet. Open a pi session in Herdr and the net appears here.</p>
42
+ </main>
43
+ <aside class="side">
44
+ <section class="panel"><h2>Net log</h2><ol class="log" id="log"></ol></section>
45
+ <section class="panel"><h2>States</h2>
46
+ <ul class="legend">
47
+ <li data-mark="working"><b class="badge"></b>working</li>
48
+ <li data-mark="waiting"><b class="badge"></b>waiting on a question</li>
49
+ <li data-mark="idle"><b class="badge"></b>idle</li>
50
+ <li data-mark="done"><b class="badge"></b>finished</li>
51
+ <li data-mark="error"><b class="badge"></b>error</li>
52
+ </ul>
53
+ </section>
54
+ <p class="credit">Icons from game-icons.net by Lorc and Delapouite, CC BY 3.0.</p>
55
+ </aside>
56
+ </div>
57
+ </div>
58
+ </body>
59
+ </html>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-straw-boss",
3
- "version": "0.1.0",
4
- "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand, and boss_broadcast reaches the rest",
3
+ "version": "0.3.1",
4
+ "description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "wayne930242",
@@ -21,6 +21,7 @@
21
21
  ],
22
22
  "files": [
23
23
  "src",
24
+ "dist/gui",
24
25
  "prompts",
25
26
  "README.md",
26
27
  "LICENSE"
@@ -31,17 +32,24 @@
31
32
  ]
32
33
  },
33
34
  "scripts": {
34
- "check": "tsc --noEmit",
35
+ "check": "tsc --noEmit && tsc --noEmit -p gui",
36
+ "gui:build": "vite build gui",
37
+ "gui:dev": "vite gui",
38
+ "prepack": "npm run gui:build",
39
+ "pretest": "npm run gui:build",
35
40
  "test": "node --test tests/*.test.ts"
36
41
  },
37
42
  "peerDependencies": {
38
43
  "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*",
39
45
  "typebox": "*"
40
46
  },
41
47
  "devDependencies": {
42
48
  "@earendil-works/pi-coding-agent": "1.3.0",
49
+ "@earendil-works/pi-tui": "1.3.0",
43
50
  "@types/node": "26.6.5",
44
51
  "typebox": "1.3.27",
45
- "typescript": "7.0.2"
52
+ "typescript": "7.0.2",
53
+ "vite": "8.3.4"
46
54
  }
47
55
  }
@@ -0,0 +1,181 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
6
+
7
+ // The tower's monitoring screen: a widget above the editor, never focused, redrawn only while something is working.
8
+ //
9
+ // Data sources, all read-only:
10
+ // - pi-intercom listSessions(): every live session, its status (idle, thinking, tool:<name>), and its Herdr location.
11
+ // presence_update, session_joined, and session_left events on the tower's channel only trigger an early re-read.
12
+ // - `herdr pane list`: per pane the `sb_role` token (main, member), the pi session file, and Herdr's agent_status
13
+ // (blocked means the agent waits on a question); `herdr workspace list`: workspace status, `tower` token, worktree.
14
+ // - pi-herdr-agents subagents: a child's activity file lives at <sessions dir>/artifacts/<parent session id>/
15
+ // subagent-activity/<child id>.json, and its pane's session file name starts with `<timestamp>_<child id>`.
16
+ // The parent pane's own session file names the artifacts directory, so children group under the session that launched them.
17
+
18
+ export type Mark = "working" | "waiting" | "idle" | "done" | "error" | "unknown";
19
+ /** One line of the tree. `net` rows are tab (0) > session (1) > subagent (2); `towers` rows are flat. */
20
+ export interface Row { group: "net" | "towers"; depth: number; mark: Mark; text: string; kind?: string; note?: string; workspace?: string }
21
+ export interface Snapshot { title: string; mark: Mark; head: boolean; rows: Row[] }
22
+ export type Tone = "accent" | "warning" | "muted" | "success" | "dim" | "error" | "border" | "title";
23
+ export type Paint = (tone: Tone, text: string) => string;
24
+ export interface Sources { run: HerdrRun; sessions(): Promise<Session[]>; workspaceId: string; now?: number }
25
+
26
+ const KEY = "straw-boss-dashboard";
27
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
+ // One glyph and one theme color per state; a working row shows the animated spinner instead of a fixed glyph.
29
+ const GLYPH: Record<Exclude<Mark, "working">, string> = { waiting: "?", idle: "○", done: "✓", error: "✗", unknown: "·" };
30
+ const TONE: Record<Mark, Tone> = { working: "accent", waiting: "warning", idle: "dim", done: "success", error: "error", unknown: "muted" };
31
+ const HERDR_MARK: Record<string, Mark> = { working: "working", blocked: "waiting", idle: "idle", done: "done" };
32
+ const RECENT_MS = 10 * 60_000;
33
+ const MAX_ROWS = 18;
34
+ const MAX_WIDTH = 76;
35
+ const MAX_CHILDREN = 8;
36
+
37
+ export const markOf = (status?: string): Mark => HERDR_MARK[status ?? ""] ?? "unknown";
38
+ /** A session is waiting when its pane is blocked on a question, else working or idle by its intercom status, else by Herdr's own. */
39
+ export function sessionMark(s: Session, pane?: Pane): Mark {
40
+ const busy = s.status ? s.status !== "idle" : undefined;
41
+ return pane?.agent_status === "blocked" ? "waiting" : busy === undefined ? markOf(pane?.agent_status) : busy ? "working" : "idle";
42
+ }
43
+ const pad = (text: string, width: number) => text + " ".repeat(Math.max(0, width - visibleWidth(text)));
44
+
45
+ /** The pure half: a snapshot in, a framed tree out. One shared spinner frame animates every working row. */
46
+ export function render({ title, mark, head, rows }: Snapshot, frame: number, width: number, paint: Paint): string[] {
47
+ const total = Math.min(width, MAX_WIDTH), inner = Math.max(8, total - 4);
48
+ const glyph = (m: Mark) => paint(TONE[m], m === "working" ? SPINNER[frame % SPINNER.length]! : GLYPH[m as Exclude<Mark, "working">]);
49
+ const shown = rows.slice(0, MAX_ROWS);
50
+ // A row is the last child when the next row at its depth or above is shallower (or there is none).
51
+ 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
+ const ancestor = (i: number, depth: number) => shown.findLastIndex((r, k) => k < i && r.group === shown[i]!.group && r.depth === depth);
53
+ 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);
55
+ const [w1, w2] = [Math.max(...cells.map(visibleWidth), 0), Math.max(...shown.map((r) => r.kind?.length ?? 0))];
56
+ const line = (i: number) => `${pad(cells[i]!, w1)} ${paint("muted", (shown[i]!.kind ?? "").padEnd(w2))} ${paint("dim", shown[i]!.note ?? "")}`.trimEnd();
57
+ const section = (group: Row["group"], name: string, empty?: string) => {
58
+ const at = shown.flatMap((r, i) => (r.group === group ? [i] : []));
59
+ return at.length || empty ? [paint("muted", name), ...(at.length ? at.map(line) : [paint("dim", ` ${empty}`)])] : [];
60
+ };
61
+ const body = [...section("net", "this net", "nothing has joined yet"), ...section("towers", "other towers"), ...(rows.length > MAX_ROWS ? [paint("muted", `… ${rows.length - MAX_ROWS} more`)] : [])];
62
+ const [name, tag] = [` ${glyph(mark)} ${paint("title", `tower · ${title}`)} `, head ? ` ${paint("accent", "head tower")} ` : ""];
63
+ const dashes = "─".repeat(Math.max(1, total - 4 - visibleWidth(name) - visibleWidth(tag)));
64
+ const frameLine = (text: string) => paint("border", "│ ") + pad(truncateToWidth(text, inner), inner) + paint("border", " │");
65
+ return [paint("border", "╭─") + name + paint("border", dashes) + tag + paint("border", "─╮"), ...body.map(frameLine), paint("border", `╰${"─".repeat(total - 2)}╯`)].map((l) => truncateToWidth(l, width));
66
+ }
67
+
68
+ function readJson(path: string): Record<string, unknown> {
69
+ try {
70
+ return JSON.parse(readFileSync(path, "utf8"));
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ /** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. */
77
+ export function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
78
+ const file = parent?.agent_session?.value;
79
+ if (!file) return [];
80
+ const dir = join(dirname(file), "artifacts", basename(file, ".jsonl").split("_").pop()!);
81
+ let files: string[] = [];
82
+ try {
83
+ files = readdirSync(join(dir, "subagent-activity"));
84
+ } catch {} // no subagents were ever launched from this session
85
+ return files
86
+ .map((name) => {
87
+ const id = name.replace(/\.json$/, "");
88
+ const path = join(dir, "subagent-activity", name);
89
+ return { id, path, pane: all.find((p) => p.agent_session?.value.includes(`_${id}`)), age: now - statSync(path).mtimeMs };
90
+ })
91
+ .filter((c) => c.pane || c.age < RECENT_MS)
92
+ .map(({ id, path, pane }) => {
93
+ const activity = readJson(path);
94
+ // A child whose pane is gone, or back at a shell prompt, has finished.
95
+ const mark: Mark = pane?.agent === "pi" ? markOf(pane.agent_status) : "done";
96
+ let script: string | undefined;
97
+ try {
98
+ script = readdirSync(join(dir, "subagent-scripts")).find((f) => f.endsWith(`-${id}.sh`));
99
+ } catch {} // the launch script is only a fallback source for the name
100
+ const name = pane?.label ?? script?.slice(0, -`-${id}.sh`.length) ?? id.slice(0, 8);
101
+ const doing = activity.toolActive ? String(activity.toolName) : String(activity.phase ?? "");
102
+ return { group: "net" as const, depth: 2, mark, text: name, kind: "subagent", workspace: pane?.workspace_id, note: { working: doing, waiting: "waiting on a question", done: "finished" }[mark as "working"] };
103
+ })
104
+ .sort((a, b) => Number(a.mark === "done") - Number(b.mark === "done") || a.text.localeCompare(b.text))
105
+ .slice(0, MAX_CHILDREN);
106
+ }
107
+
108
+ /**
109
+ * One reading of the world as rows: this tower's own net in detail (each tab's joined sessions with their subagents, then its
110
+ * sub workspaces), and one summary line for every other tower. Other workspaces' sessions, and sessions that did not join, are not listed.
111
+ */
112
+ export async function collect({ run, sessions, workspaceId, now = Date.now() }: Sources): Promise<Snapshot> {
113
+ const [live, ps, all] = [await sessions(), await panes(run), await workspaces(run)];
114
+ const head = headOf(all);
115
+ const mine = all.find((w) => w.workspace_id === workspaceId);
116
+ const sessionRow = (s: Session, role: string): Row => {
117
+ const mark = sessionMark(s, ps.find((p) => p.pane_id === s.herdrLocation?.paneId));
118
+ return { group: "net", depth: 1, mark, text: s.name ?? s.id, kind: role, note: { working: s.status, waiting: "waiting on a question" }[mark as "working"] };
119
+ };
120
+ const joined = live.filter((s) => s.herdrLocation?.workspace?.id === workspaceId).flatMap((s) => {
121
+ const pane = ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
122
+ const role = pane?.tokens?.sb_role;
123
+ return role === "main" || role === "member" ? [{ s, pane, role, tab: s.herdrLocation!.tab! }] : [];
124
+ });
125
+ const rows: Row[] = [];
126
+ for (const group of Object.values(Object.groupBy(joined, (j) => j.tab.id))) {
127
+ rows.push({ group: "net", depth: 0, mark: "unknown", text: `tab ${group![0]!.tab.label}` });
128
+ for (const { s, pane, role } of group!.sort((a, b) => Number(b.role === "main") - Number(a.role === "main"))) rows.push(sessionRow(s, role), ...subagents(pane, ps, now));
129
+ }
130
+ // Sub workspaces are linked worktrees: they hang under this tower like subagents, and their sessions never join. One that
131
+ // already shows as a launched subagent (pi-herdr-agents runs worktree children there) is not listed a second time.
132
+ const claimed = new Set(rows.map((r) => r.workspace));
133
+ for (const sub of all.filter((x) => isSub(x) && mine && parentOf(all, x) === mine && !claimed.has(x.workspace_id))) rows.push({ group: "net", depth: 0, mark: markOf(sub.agent_status), text: sub.label, kind: "sub workspace" });
134
+ for (const w of all.filter((x) => x !== mine && !isSub(x) && live.some((s) => s.id === x.tokens?.tower))) rows.push({ group: "towers", depth: 0, mark: markOf(w.agent_status), text: w.label, kind: w === head ? "head tower" : "tower" });
135
+ // The title shows what needs attention most: a waiting row, then a working one, else the workspace's own status.
136
+ const attention = (["waiting", "working"] as const).find((m) => rows.some((r) => r.mark === m));
137
+ return { title: mine?.label ?? workspaceId, mark: attention ?? markOf(mine?.agent_status), head: Boolean(mine) && mine === head, rows };
138
+ }
139
+
140
+ export interface Dashboard { poke(): void; stop(): void }
141
+ export interface Timing { frameMs?: number; pollMs?: number; minGapMs?: number }
142
+
143
+ /**
144
+ * Shows the dashboard in a tower's TUI. The spinner timer (8 fps) runs only while a row is working; the data poll (3 s)
145
+ * runs while the widget exists. stop() clears both timers and the widget, and pi disposing the widget does the same.
146
+ */
147
+ export function startDashboard({ ui, collect: read, frameMs = 125, pollMs = 3000, minGapMs = 500 }: { ui: ExtensionContext["ui"]; collect(): Promise<Snapshot> } & Timing): Dashboard {
148
+ let live: Dashboard | undefined;
149
+ ui.setWidget(KEY, (tui, theme) => {
150
+ let snap: Snapshot = { title: "", mark: "unknown", head: false, rows: [] }, frame = 0, busy = false, stopped = false, last = 0;
151
+ let spinner: ReturnType<typeof setInterval> | undefined;
152
+ const refresh = async () => {
153
+ if (busy || stopped) return;
154
+ busy = true;
155
+ try {
156
+ snap = await read();
157
+ } catch (error) {
158
+ snap = { ...snap, mark: "error", rows: [{ group: "net", depth: 0, mark: "error", text: `dashboard unavailable: ${(error as Error).message}` }] };
159
+ }
160
+ busy = false;
161
+ last = Date.now();
162
+ if (stopped) return;
163
+ const working = snap.mark === "working" || snap.rows.some((r) => r.mark === "working");
164
+ if (working && !spinner) spinner = setInterval(() => (frame++, tui.requestRender()), frameMs).unref();
165
+ if (!working && spinner) spinner = void clearInterval(spinner);
166
+ tui.requestRender();
167
+ };
168
+ const poll = setInterval(() => void refresh(), pollMs).unref();
169
+ const stop = () => {
170
+ stopped = true;
171
+ clearInterval(poll);
172
+ clearInterval(spinner);
173
+ spinner = undefined;
174
+ };
175
+ live = { poke: () => void (Date.now() - last >= minGapMs && refresh()), stop };
176
+ void refresh();
177
+ const paint: Paint = (tone, text) => (tone === "title" ? theme.bold(theme.fg("accent", text)) : theme.fg(tone, text));
178
+ return { render: (width) => render(snap, frame, width, paint), invalidate() {}, dispose: stop };
179
+ });
180
+ return { poke: () => live?.poke(), stop: () => (ui.setWidget(KEY, undefined), live?.stop(), (live = undefined), undefined) };
181
+ }
package/src/gui.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { createReadStream, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { createServer, type IncomingMessage, request, type ServerResponse } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { extname, join, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import type { Net } from "./net.ts";
9
+
10
+ // The browser view's server: serves the built page and one live stream of the net, read-only, on 127.0.0.1 only.
11
+ // Every URL starts with a random token (/<token>/index.html, /<token>/events) and a request without it, or with another Host
12
+ // header, is refused, so other local pages cannot read the net. The stream sends the full snapshot on connect and on every
13
+ // change, plus a `ping` event every 15 s so a client can tell a silent drop from a quiet net. It polls only while someone listens.
14
+
15
+ export const BUNDLE = fileURLToPath(new URL("../dist/gui", import.meta.url));
16
+ const TYPES: Record<string, string> = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json" };
17
+ const HEADERS = {
18
+ "cache-control": "no-store",
19
+ "referrer-policy": "no-referrer",
20
+ "x-content-type-options": "nosniff",
21
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
22
+ };
23
+
24
+ export interface Gui { url: string; port: number; token: string; stop(): Promise<void> }
25
+ export interface GuiOptions { read(): Promise<Net>; dir?: string; port?: number; token?: string; pollMs?: number; pingMs?: number }
26
+
27
+ /** Starts the server. A preferred `port` (and `token`) is reused when free, so a restarted server keeps the URL an open page already has. */
28
+ export async function startGui({ read, dir = BUNDLE, port = 0, token = randomBytes(16).toString("hex"), pollMs = 2000, pingMs = 15_000 }: GuiOptions): Promise<Gui> {
29
+ try {
30
+ if (!statSync(join(dir, "index.html")).isFile()) throw new Error();
31
+ } catch {
32
+ throw new Error(`The GUI bundle is missing (${dir}); build it with npm run gui:build`);
33
+ }
34
+ const clients = new Set<ServerResponse>();
35
+ let last: string | undefined, problem: string | undefined, busy = false;
36
+ let poll: ReturnType<typeof setInterval> | undefined, beat: ReturnType<typeof setInterval> | undefined;
37
+ const push = (data: string, event?: string) => clients.forEach((c) => c.write(`${event ? `event: ${event}\n` : ""}data: ${data}\n\n`));
38
+ const tick = async () => {
39
+ if (busy) return;
40
+ busy = true;
41
+ try {
42
+ const json = JSON.stringify(await read());
43
+ if (json !== last || problem) push((last = json));
44
+ problem = undefined;
45
+ } catch (error) {
46
+ // The stream stays up and says why the net is unreadable, once per distinct reason.
47
+ const message = (error as Error).message;
48
+ if (message !== problem) push(JSON.stringify(message), "problem");
49
+ problem = message;
50
+ }
51
+ busy = false;
52
+ };
53
+ const listen = (at: number) => new Promise<void>((done, fail) => {
54
+ server.once("error", fail);
55
+ server.listen(at, "127.0.0.1", () => (server.off("error", fail), done()));
56
+ });
57
+ const allowed = (req: IncomingMessage, given: string | undefined) => {
58
+ const [a, b] = [Buffer.from(given ?? ""), Buffer.from(token)];
59
+ return req.method === "GET" && req.headers.host === `127.0.0.1:${(server.address() as AddressInfo).port}` && a.length === b.length && timingSafeEqual(a, b);
60
+ };
61
+ const events = (req: IncomingMessage, res: ServerResponse) => {
62
+ res.writeHead(200, { ...HEADERS, "content-type": "text/event-stream", connection: "keep-alive" });
63
+ clients.add(res);
64
+ if (last) res.write(`data: ${last}\n\n`);
65
+ if (clients.size === 1) {
66
+ poll = setInterval(() => void tick(), pollMs).unref();
67
+ beat = setInterval(() => push("", "ping"), pingMs).unref();
68
+ void tick();
69
+ }
70
+ req.on("close", () => {
71
+ clients.delete(res);
72
+ if (!clients.size) (clearInterval(poll), clearInterval(beat));
73
+ });
74
+ };
75
+ const server = createServer((req, res) => {
76
+ const pathname = new URL(req.url ?? "/", "http://local").pathname;
77
+ const [, given, ...path] = pathname.split("/");
78
+ const fail = (code: number) => res.writeHead(code, { ...HEADERS, "content-type": "text/plain" }).end(code === 403 ? "forbidden" : "not found");
79
+ if (!allowed(req, given)) return void fail(403);
80
+ // The page loads its assets and the stream relative to its own URL, so that URL must end with a slash.
81
+ if (path.length === 0) return void res.writeHead(308, { ...HEADERS, location: `${pathname}/` }).end();
82
+ if (path.join("/") === "events") return events(req, res);
83
+ let file: string;
84
+ try {
85
+ file = resolve(dir, decodeURIComponent(path.join("/")) || "index.html");
86
+ if (!file.startsWith(resolve(dir) + sep) || !statSync(file).isFile()) throw new Error();
87
+ } catch {
88
+ return void fail(404);
89
+ }
90
+ res.writeHead(200, { ...HEADERS, "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
91
+ createReadStream(file).pipe(res);
92
+ });
93
+ try {
94
+ await listen(port);
95
+ } catch (error) {
96
+ if (!port || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
97
+ await listen(0);
98
+ }
99
+ const bound = (server.address() as AddressInfo).port;
100
+ return {
101
+ url: `http://127.0.0.1:${bound}/${token}/`, port: bound, token,
102
+ stop: () => new Promise<void>((done) => {
103
+ clearInterval(poll);
104
+ clearInterval(beat);
105
+ clients.forEach((c) => c.end());
106
+ server.close(() => done());
107
+ server.closeAllConnections();
108
+ }),
109
+ };
110
+ }
111
+
112
+ // One server per machine. gui.json in the state directory records the running server (pid, port, token, owner session), and
113
+ // gui.lock makes the check-and-start atomic, so a second /boss gui, from this session or another, opens the running server
114
+ // instead of starting one. A dead server's port and token are reused, so a page left open reconnects to its successor.
115
+ export interface GuiRecord { pid: number; port: number; token: string; owner: string }
116
+ export interface Shared { url: string; owner: string; mine?: Gui }
117
+ const urlOf = (r: GuiRecord) => `http://127.0.0.1:${r.port}/${r.token}/`;
118
+
119
+ export function readRecord(dir: string): GuiRecord | undefined {
120
+ try {
121
+ return JSON.parse(readFileSync(join(dir, "gui.json"), "utf8")) as GuiRecord;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /** Whether a recorded server answers its own page. */
128
+ export const answers = (r: GuiRecord) =>
129
+ new Promise<boolean>((done) => {
130
+ const req = request({ host: "127.0.0.1", port: r.port, path: `/${r.token}/`, headers: { host: `127.0.0.1:${r.port}` }, agent: false, timeout: 1000 }, (res) => {
131
+ res.resume();
132
+ done(res.statusCode === 200);
133
+ });
134
+ req.on("error", () => done(false)).on("timeout", () => (req.destroy(), done(false)));
135
+ req.end();
136
+ });
137
+
138
+ export async function sharedGui(dir: string, owner: string, start: (prefer: { port?: number; token?: string }) => Promise<Gui>, sleep: (ms: number) => Promise<void>): Promise<Shared> {
139
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
140
+ const lock = join(dir, "gui.lock");
141
+ for (let tries = 0; ; tries++) {
142
+ try {
143
+ writeFileSync(lock, String(process.pid), { flag: "wx" });
144
+ break;
145
+ } catch (error) {
146
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
147
+ // A lock older than 10 s was left by a session that died while starting the server.
148
+ const age = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
149
+ if (age > 10_000) rmSync(lock, { force: true });
150
+ else if (tries >= 50) throw new Error("Another session is starting the straw net GUI; try again");
151
+ else await sleep(200);
152
+ }
153
+ }
154
+ try {
155
+ const record = readRecord(dir);
156
+ if (record && (await answers(record))) return { url: urlOf(record), owner: record.owner };
157
+ const gui = await start(record ? { port: record.port, token: record.token } : {});
158
+ writeFileSync(join(dir, "gui.json"), JSON.stringify({ pid: process.pid, port: gui.port, token: gui.token, owner }), { mode: 0o600 });
159
+ return { url: gui.url, owner, mine: gui };
160
+ } finally {
161
+ rmSync(lock, { force: true });
162
+ }
163
+ }
164
+
165
+ /** Opens the default browser; rejects when the platform command fails, so the caller can show the URL instead. */
166
+ export const openBrowser = (url: string) =>
167
+ new Promise<void>((done, fail) => {
168
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
169
+ execFile(cmd!, args as string[], (error) => (error ? fail(error) : done()));
170
+ });
package/src/herdr.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createConnection } from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
 
3
6
  export type HerdrRun = (args: string[], timeoutMs?: number) => Promise<string>;
7
+ /** One request on Herdr's socket API; rejects with the error Herdr answered. */
8
+ export type HerdrApi = (method: string, params: object) => Promise<void>;
4
9
  export type Sleep = (ms: number) => Promise<void>;
5
10
  export interface Place { paneId: string; tabId: string; workspaceId: string }
6
- export interface Workspace { workspace_id: string; label: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string } }
7
- export interface Pane { pane_id: string; tab_id: string; agent?: string; tokens?: Record<string, string> }
8
- export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean> }
11
+ export interface Workspace { workspace_id: string; label: string; agent_status?: string; tokens?: Record<string, string>; worktree?: { checkout_path?: string; is_linked_worktree?: boolean; repo_key?: string } }
12
+ export interface Pane { pane_id: string; tab_id: string; workspace_id?: string; agent?: string; agent_status?: string; agent_session?: { value: string }; label?: string; tokens?: Record<string, string> }
13
+ export interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
14
+ /** A live pi-intercom session; `status` is idle, thinking, or tool:<name>. */
15
+ export interface Session { id: string; name?: string; status?: string; herdrLocation?: Location }
16
+ export interface TowerEnv { run: HerdrRun; sleep: Sleep; live(sessionId: string): Promise<boolean>; moveFirst(tabId: string): Promise<void> }
9
17
 
10
18
  const TTL_MS = 10 * 60_000;
11
19
  const START_MS = 30_000;
@@ -36,12 +44,34 @@ export async function place(run: HerdrRun): Promise<Place> {
36
44
  return { paneId: pane.pane_id, tabId: pane.tab_id, workspaceId: pane.workspace_id };
37
45
  }
38
46
 
47
+ /** A sub workspace is a linked git worktree that Herdr opened as its own workspace: it is treated like a subagent of its parent. */
48
+ export const isSub = (w: Workspace) => w.worktree?.is_linked_worktree === true;
49
+ export const parentOf = (all: Workspace[], w: Workspace) => all.find((p) => !isSub(p) && p.worktree?.repo_key !== undefined && p.worktree.repo_key === w.worktree?.repo_key);
50
+ /** The head tower's workspace: the first one in list order that is not a sub workspace. */
51
+ export const headOf = (all: Workspace[]) => all.find((w) => !isSub(w));
52
+
39
53
  export const workspaces = async (run: HerdrRun): Promise<Workspace[]> => (await result(run, ["workspace", "list"])).workspaces;
40
54
 
41
55
  export async function panes(run: HerdrRun, workspace?: string): Promise<Pane[]> {
42
56
  return (await result(run, ["pane", "list", ...(workspace ? ["--workspace", workspace] : [])])).panes;
43
57
  }
44
58
 
59
+ export const herdrApi = (socketPath = process.env.HERDR_SOCKET_PATH || join(homedir(), ".config", "herdr", "herdr.sock")): HerdrApi => (method, params) =>
60
+ new Promise((resolve, reject) => {
61
+ const socket = createConnection(socketPath).setTimeout(5000);
62
+ let reply = "";
63
+ const end = (error?: Error) => (socket.destroy(), error ? reject(error) : resolve());
64
+ socket.on("connect", () => socket.write(`${JSON.stringify({ id: "straw-boss", method, params })}\n`));
65
+ socket.on("data", (chunk) => {
66
+ reply += chunk;
67
+ if (!reply.includes("\n")) return;
68
+ const { error } = JSON.parse(reply.split("\n")[0]!) as { error?: { code?: string; message?: string } };
69
+ end(error && new Error(`herdr ${method} failed: ${error.code ?? ""} ${error.message ?? ""}`.trim()));
70
+ });
71
+ socket.on("timeout", () => end(new Error(`herdr ${method} timed out`)));
72
+ socket.on("error", (error) => end(new Error(`herdr ${method} failed: ${error.message}`)));
73
+ });
74
+
45
75
  /** Writes display-and-discovery tokens (null clears one). Herdr takes the positional id before the options. */
46
76
  export function report(run: HerdrRun, kind: "pane" | "workspace", id: string, tokens: Record<string, string | null>) {
47
77
  const flags = Object.entries(tokens).flatMap(([name, value]) => (value === null ? ["--clear-token", name] : ["--token", `${name}=${value}`]));
@@ -67,7 +97,7 @@ export function ensureTower(env: TowerEnv, workspace: Workspace, cwd: string) {
67
97
  return pending;
68
98
  }
69
99
 
70
- async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: string) {
100
+ async function open({ run, sleep, live, moveFirst }: TowerEnv, workspace: Workspace, cwd: string) {
71
101
  const ws = workspace.workspace_id;
72
102
  const held = async () => {
73
103
  const id = (await workspaces(run)).find((w) => w.workspace_id === ws)?.tokens?.tower;
@@ -77,18 +107,22 @@ async function open({ run, sleep, live }: TowerEnv, workspace: Workspace, cwd: s
77
107
  if (current) return { id: current, opened: false };
78
108
 
79
109
  // A leftover tower tab is reused: a pane already running pi only needs its token awaited, a shell prompt gets pi started.
110
+ // The tab is told apart by the `sb_role=tower` pane token that only Straw Boss writes; a tab merely labelled "tower"
111
+ // (a pi-herdr-agents subagent can be named so) is someone else's.
80
112
  let paneId: string;
81
113
  let running = false;
82
- const tab = (await result(run, ["tab", "list", "--workspace", ws])).tabs.find((t: { label: string }) => t.label === "tower");
83
- if (tab) {
84
- const root = (await panes(run, ws)).find((p) => p.tab_id === tab.tab_id);
85
- if (!root) throw new Error(`Tower tab ${tab.tab_id} has no pane`);
86
- paneId = root.pane_id;
87
- running = root.agent === "pi";
114
+ const marked = (await panes(run, ws)).find((p) => p.tokens?.sb_role === "tower");
115
+ if (marked) {
116
+ paneId = marked.pane_id;
117
+ running = marked.agent === "pi";
118
+ await moveFirst(marked.tab_id);
88
119
  } else {
89
120
  const dir = workspace.worktree?.checkout_path ?? cwd;
90
121
  const created = await result(run, ["tab", "create", "--workspace", ws, "--label", "tower", "--cwd", dir, "--no-focus", "--env", "STRAW_BOSS_ROLE=tower"]);
91
122
  paneId = created.root_pane.pane_id;
123
+ // Marked at once, so a tab left without pi is recognised (while the marker lasts) and the label stays display only.
124
+ await report(run, "pane", paneId, { sb_role: "tower" });
125
+ await moveFirst(created.tab.tab_id);
92
126
  }
93
127
  if (!running) {
94
128
  // Herdr agent names are unique across workspaces and lowercase only, so the name carries the lowercased workspace id.
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { Type, type Static, type TSchema } from "typebox";
6
- import { ensureTower, herdrRun, type HerdrRun, type Pane, panes, place, type Place, report, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
7
+ import { collect, startDashboard } from "./dashboard.ts";
8
+ import { answers, type Gui, openBrowser, readRecord, sharedGui, startGui } from "./gui.ts";
9
+ import { collectNet } from "./net.ts";
10
+ import { ensureTower, headOf, herdrApi, type HerdrApi, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
7
11
 
8
12
  // pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
9
13
  const REGISTER = "intercom:extension-register";
@@ -18,8 +22,6 @@ const CONNECT_MS = 15_000;
18
22
  const SEND_MS = 30_000;
19
23
  const SETTLE_MS = 2000;
20
24
 
21
- interface Location { status: string; workspace?: { id: string; label: string }; tab?: { id: string; label: string }; paneId?: string }
22
- interface Session { id: string; name?: string; herdrLocation?: Location }
23
25
  interface Channel {
24
26
  snapshot(): { connected: boolean; supported: boolean; owner?: { sessionId: string }; state?: { payload: unknown } };
25
27
  publish(payload: unknown, options?: { audience?: "owner" | "capable" }): void;
@@ -31,7 +33,7 @@ interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
31
33
  type Role = "tower" | "main" | "member";
32
34
  type Scope = "tab" | "workspace" | "towers" | "all";
33
35
  type Member = ReturnType<typeof netMembers>[number];
34
- export interface Deps { run?: HerdrRun; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string }
36
+ export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string }
35
37
 
36
38
  /** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
37
39
  function netMembers(sessions: Session[], ps: Pane[]) {
@@ -43,13 +45,17 @@ function netMembers(sessions: Session[], ps: Pane[]) {
43
45
  }
44
46
  const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
45
47
 
46
- export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)) }: Deps = {}) {
48
+ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir }: Deps = {}) {
47
49
  return function strawBoss(pi: ExtensionAPI): void {
48
50
  // Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
49
51
  if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
50
52
  let isTower = env.STRAW_BOSS_ROLE === "tower";
51
53
  let asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
54
+ // Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
55
+ let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
52
56
  let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
57
+ let gui: Gui | undefined, viewer: Channel | undefined, opening: Promise<string> | undefined;
58
+ const guiDir = stateDir ?? join(env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "straw-boss");
53
59
  let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
54
60
  let queue: Promise<void> = Promise.resolve();
55
61
  const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
@@ -93,6 +99,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
93
99
  const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
94
100
  if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
95
101
  else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
102
+ if (["presence_update", "session_joined", "session_left"].includes(event.type)) dashboard?.poke();
96
103
  };
97
104
  const refresh = async () => {
98
105
  if (!joined) return;
@@ -118,12 +125,13 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
118
125
  };
119
126
  const sync = () => (queue = queue.then(refresh));
120
127
  // A session that has just started connects to the broker a moment after session_start.
121
- const connected = async () => {
122
- if (await waitFor(sleep, () => c.snapshot().connected && c.snapshot().supported, CONNECT_MS)) return;
123
- throw new Error(c.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
128
+ const connected = async (channel = c) => {
129
+ if (await waitFor(sleep, () => channel.snapshot().connected && channel.snapshot().supported, CONNECT_MS)) return;
130
+ throw new Error(channel.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
124
131
  };
125
132
  const joinNet = async (restored = false) => {
126
133
  if (joined) return;
134
+ if (barred) throw new Error(barred);
127
135
  at = await place(run);
128
136
  connect(true);
129
137
  await connected();
@@ -158,13 +166,17 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
158
166
  const openTower = async (query?: string) => {
159
167
  const all = await workspaces(run);
160
168
  const found = all.filter((w) => (query ? w.workspace_id === query || w.label === query : w.workspace_id === at.workspaceId));
161
- const [target] = found;
162
169
  if (found.length !== 1) {
163
170
  throw new Error(`${found.length ? "Several workspaces match" : "No workspace matches"} "${query}". Workspaces: ${all.map((w) => `${w.workspace_id} (${w.label})`).join(", ")}`);
164
171
  }
172
+ // A sub workspace never has a tower: its parent workspace's tower serves it.
173
+ const asked = found[0]!;
174
+ const target = isSub(asked) ? parentOf(all, asked) : asked;
175
+ if (!target) throw new Error(`${asked.label} is a sub workspace with no open parent workspace, so it has no tower; open the parent workspace first`);
165
176
  if (isTower && target.workspace_id === at.workspaceId) throw new Error("This session is the tower of that workspace");
166
177
  const live = async (id: string) => (await c.listSessions()).some((s) => s.id === id);
167
- return { target, ...(await ensureTower({ run, sleep, live }, target, process.cwd())) };
178
+ 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"));
179
+ return { target, routed: target !== asked ? asked : undefined, ...(await ensureTower({ run, sleep, live, moveFirst }, target, process.cwd())) };
168
180
  };
169
181
  const status = async () => {
170
182
  const sessions = await c.listSessions();
@@ -172,7 +184,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
172
184
  const mine = all.find((w) => w.workspace_id === at.workspaceId);
173
185
  const towerOf = (w?: Workspace) => {
174
186
  const s = sessions.find((x) => x.id === w?.tokens?.tower);
175
- return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === all[0] };
187
+ return s && w && { workspace: w.label, id: s.id, name: nameOf(s), head: w === headOf(all) };
176
188
  };
177
189
  const own = netMembers(sessions, ps).filter((m) => m.workspace.id === at.workspaceId && m.role !== "tower");
178
190
  const tabs = Object.values(Object.groupBy(own, (m) => m.tab.id)).map((group = []) => ({
@@ -184,7 +196,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
184
196
  return {
185
197
  me: { id: me(), role: role() },
186
198
  workspace: { id: at.workspaceId, label: mine?.label },
187
- head: all[0]?.workspace_id,
199
+ head: headOf(all)?.workspace_id,
188
200
  tower: towerOf(mine) ?? null,
189
201
  tabs,
190
202
  towers: all.filter((w) => w !== mine).flatMap((w) => towerOf(w) ?? []),
@@ -214,7 +226,8 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
214
226
  const tower = await openTower(workspace);
215
227
  const result = await send(tower.id, message);
216
228
  if (result.status !== "sent") throw new Error(`The tower of ${tower.target.label} did not take the message: ${result.status} ${result.code ?? ""} ${result.detail ?? ""}`.trim());
217
- const text = `Sent to the tower of ${tower.target.label}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
229
+ const via = tower.routed ? ` (${tower.routed.label} is a sub workspace)` : "";
230
+ const text = `Sent to the tower of ${tower.target.label}${via}${tower.opened ? " (opened it)" : ""}. It replies through intercom.`;
218
231
  return { text, details: { workspace: tower.target.workspace_id, towerId: tower.id, opened: tower.opened, status: result.status } };
219
232
  });
220
233
  tool("boss_broadcast", "Send one message to every other session in a scope. tab, workspace, and towers reach only sessions that joined the net; all reaches every pi session on this machine (for notices such as the boss going to sleep).",
@@ -243,14 +256,29 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
243
256
  return;
244
257
  }
245
258
  holdsTower = true;
246
- info = { workspace: ws?.label ?? at.workspaceId, head: all[0]?.label ?? "" };
259
+ info = { workspace: ws?.label ?? at.workspaceId, head: headOf(all)?.label ?? "" };
247
260
  addNetTools();
248
261
  keepFresh();
249
262
  await publish();
263
+ // The dashboard lives exactly as long as this session holds the tower role.
264
+ if (hasUI) dashboard = startDashboard({ ui: ui!, collect: () => collect({ run, sessions: () => c.listSessions(), workspaceId: at.workspaceId }) });
250
265
  };
251
266
 
252
- pi.on("session_start", (_event, ctx) => {
267
+ // A session in a sub workspace (a linked git worktree) acts like a subagent: it never joins. Failing to tell bars it too.
268
+ const barReason = async () => {
269
+ try {
270
+ const { workspaceId } = await place(run);
271
+ const all = await workspaces(run);
272
+ const sub = all.find((w) => w.workspace_id === workspaceId);
273
+ return sub && isSub(sub) ? { text: `${sub.label} is a sub workspace, so this session cannot join the net; the main agent of its parent workspace represents it`, level: "info" as const } : undefined;
274
+ } catch (error) {
275
+ return { text: `Straw Boss could not tell whether this is a sub workspace: ${(error as Error).message}`, level: "error" as const };
276
+ }
277
+ };
278
+
279
+ pi.on("session_start", async (_event, ctx) => {
253
280
  ui = ctx.ui;
281
+ hasUI = ctx.hasUI;
254
282
  prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
255
283
  saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
256
284
  asked = Boolean(saved.asked);
@@ -260,12 +288,18 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
260
288
  if (isTower) {
261
289
  starting = startTower();
262
290
  } else {
263
- tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
264
- await joinNet();
265
- const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
266
- return { text, details: { role: role() } };
267
- });
268
- if (saved.joined) starting = joinNet(true);
291
+ const bar = await barReason();
292
+ barred = bar?.text;
293
+ if (bar) {
294
+ tell(bar.text, bar.level);
295
+ } else {
296
+ tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
297
+ await joinNet();
298
+ const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
299
+ return { text, details: { role: role() } };
300
+ });
301
+ if (saved.joined) starting = joinNet(true);
302
+ }
269
303
  }
270
304
  starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
271
305
  });
@@ -273,7 +307,7 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
273
307
  pi.on("before_agent_start", (event) => {
274
308
  const current = role();
275
309
  // A session outside the net is asked once, at its first run, whether it should join.
276
- const first = !current && !asked;
310
+ const first = !current && !asked && !barred;
277
311
  if (first) {
278
312
  asked = true;
279
313
  pi.appendEntry(ENTRY, { asked: true });
@@ -286,6 +320,10 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
286
320
  });
287
321
 
288
322
  pi.on("session_shutdown", async () => {
323
+ await gui?.stop();
324
+ gui = undefined;
325
+ dashboard?.stop();
326
+ dashboard = undefined;
289
327
  clearInterval(timer);
290
328
  timer = undefined;
291
329
  const held = holdsTower;
@@ -295,11 +333,45 @@ export function createStrawBoss({ run = herdrRun, env = process.env, sleep = (ms
295
333
  if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
296
334
  });
297
335
 
336
+ // /boss gui is a read-only viewer: any session may open it, and it joins nothing and writes no token. A session outside the net
337
+ // reads the sessions through a channel of its own that never takes part in a tab (no owner, no role).
338
+ const sessions = async () => {
339
+ if (!c && !viewer) {
340
+ const namespace = `straw-boss/view/${Buffer.from(env.HERDR_PANE_ID!).toString("hex")}`;
341
+ pi.events.emit(REGISTER, { namespace, ownerEligible: false, onReady: (channel: Channel) => (viewer = channel), onEvent: () => {} });
342
+ if (!viewer) throw new Error("pi-intercom is not loaded (Straw Boss needs pi-intercom >= 0.17.1)");
343
+ await connected(viewer);
344
+ }
345
+ return (c ?? viewer!).listSessions();
346
+ };
347
+ const guiCommand = async (stop: boolean) => {
348
+ if (stop) {
349
+ const running = gui;
350
+ gui = undefined;
351
+ if (running) return (await running.stop(), tell("Straw net GUI stopped"));
352
+ const record = readRecord(guiDir);
353
+ if (record && (await answers(record))) return tell(`The straw net GUI runs in session ${record.owner}; stop it there with /boss gui stop`);
354
+ return tell("The straw net GUI is not running");
355
+ }
356
+ // One server per machine: a second /boss gui, here or in another session, opens the one that runs. Two calls in flight
357
+ // in this session share one start.
358
+ opening ??= (async () => {
359
+ if (gui) return gui.url;
360
+ const shared = await sharedGui(guiDir, `${me() || "unknown"} (pane ${env.HERDR_PANE_ID})`, (prefer) => startGui({ read: () => collectNet({ run, sessions }), dir: bundle, ...prefer }), sleep);
361
+ gui = shared.mine;
362
+ return shared.url;
363
+ })().finally(() => (opening = undefined));
364
+ const url = await opening;
365
+ tell(`Straw net GUI: ${url}`);
366
+ await openUrl(url).catch((error: Error) => tell(`Could not open a browser (${error.message}); open the URL above yourself`, "warning"));
367
+ };
368
+
298
369
  pi.registerCommand("boss", {
299
- description: "Join this tab's Straw Boss net and become its main agent. /boss status shows the coordinators; /boss tower opens and focuses the workspace tower.",
370
+ description: "Join this tab's Straw Boss net and become its main agent. /boss status shows the coordinators; /boss tower opens and focuses the workspace tower; /boss gui opens the net in the browser (/boss gui stop closes it).",
300
371
  handler: async (args, ctx) => {
301
372
  ui = ctx.ui;
302
373
  try {
374
+ if (/^gui( stop)?$/.test(args.trim())) return await guiCommand(args.trim() === "gui stop");
303
375
  if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net. /boss joins it.");
304
376
  if (isTower) throw new Error("This session is the tower, not a main agent");
305
377
  if (args.trim() !== "tower") return tell(await takeover());
package/src/net.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { type Mark, markOf, type Row, sessionMark, subagents } from "./dashboard.ts";
2
+ import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
3
+
4
+ // The whole straw net as one JSON-able snapshot for the browser view: every workspace as a base with its tower, tabs, agents,
5
+ // and subagents (linked to their launcher the way the tower dashboard links them), and sub workspaces as outposts.
6
+
7
+ export interface Unit { id: string; name: string; role: "main" | "member" | "subagent"; mark: Mark; note?: string; subs: Unit[] }
8
+ export interface Tab { id: string; label: string; units: Unit[] }
9
+ export interface Outpost { id: string; label: string; mark: Mark }
10
+ export interface Base { id: string; label: string; mark: Mark; head: boolean; tower: { name: string; mark: Mark } | null; tabs: Tab[]; outposts: Outpost[] }
11
+ export interface Net { bases: Base[] }
12
+ export interface NetInput { sessions: Session[]; panes: Pane[]; workspaces: Workspace[]; children(pane: Pane | undefined): Row[] }
13
+
14
+ const joinedRole = (token?: string): "main" | "member" | undefined => (token === "main" || token === "member" ? token : undefined);
15
+
16
+ /**
17
+ * The pure half: sessions, panes, and workspaces in, the net out. Sessions that did not join (no main or member token) are left out,
18
+ * and so are workspaces with no part in the net (no live tower, nobody joined): a base is a workspace the net reaches.
19
+ */
20
+ export function buildNet({ sessions, panes: ps, workspaces: all, children }: NetInput): Net {
21
+ const paneOf = (s: Session) => ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
22
+ const note = (mark: Mark, status?: string) => ({ working: status, waiting: "waiting on a question" })[mark as "working"];
23
+ const joined = sessions.flatMap((s) => {
24
+ const pane = paneOf(s), role = joinedRole(pane?.tokens?.sb_role);
25
+ return role ? [{ s, pane, role, base: s.herdrLocation?.workspace?.id, tab: s.herdrLocation?.tab }] : [];
26
+ });
27
+ const rowsOf = new Map(joined.map(({ s, pane }) => [s.id, children(pane)]));
28
+ // A sub workspace that already shows as a launched subagent is not an outpost as well.
29
+ const claimed = new Set([...rowsOf.values()].flat().map((r) => r.workspace));
30
+ const bases = all.filter((w) => !isSub(w)).flatMap((w): Base[] => {
31
+ const mine = joined.filter((j) => j.base === w.workspace_id && j.tab);
32
+ const tabs = Object.values(Object.groupBy(mine, (j) => j.tab!.id)).map((group = []): Tab => ({
33
+ id: group[0]!.tab!.id,
34
+ label: group[0]!.tab!.label,
35
+ units: group.toSorted((a, b) => Number(b.role === "main") - Number(a.role === "main")).map(({ s, pane, role }): Unit => {
36
+ const mark = sessionMark(s, pane);
37
+ const subs = (rowsOf.get(s.id) ?? []).map((r): Unit => ({ id: `${s.id}/${r.text}`, name: r.text, role: "subagent", mark: r.mark, note: r.note, subs: [] }));
38
+ return { id: s.id, name: s.name ?? s.id, role, mark, note: note(mark, s.status), subs };
39
+ }),
40
+ }));
41
+ const outposts = all.filter((x) => isSub(x) && parentOf(all, x) === w && !claimed.has(x.workspace_id)).map((x): Outpost => ({ id: x.workspace_id, label: x.label, mark: markOf(x.agent_status) }));
42
+ const tower = sessions.find((s) => s.id === w.tokens?.tower);
43
+ const marks = [...tabs.flatMap((t) => t.units.flatMap((u) => [u.mark, ...u.subs.map((c) => c.mark)])), ...outposts.map((o) => o.mark)];
44
+ // A base shows what needs attention most: a waiting unit, then a working one, else the workspace's own status.
45
+ const mark = (["waiting", "working"] as const).find((m) => marks.includes(m)) ?? markOf(w.agent_status);
46
+ if (!tower && !tabs.length) return [];
47
+ return [{ id: w.workspace_id, label: w.label, mark, head: w === headOf(all), tower: tower ? { name: tower.name ?? tower.id, mark: sessionMark(tower, paneOf(tower)) } : null, tabs, outposts }];
48
+ });
49
+ return { bases };
50
+ }
51
+
52
+ /** One reading of Herdr and intercom as a net. */
53
+ export async function collectNet({ run, sessions, now = Date.now() }: { run: HerdrRun; sessions(): Promise<Session[]>; now?: number }): Promise<Net> {
54
+ const [live, ps, all] = [await sessions(), await panes(run), await workspaces(run)];
55
+ return buildNet({ sessions: live, panes: ps, workspaces: all, children: (pane) => subagents(pane, ps, now) });
56
+ }