pi-straw-boss 0.2.0 → 0.3.2
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 +11 -17
- package/dist/gui/assets/index-BP87OlaT.css +1 -0
- package/dist/gui/assets/index-CH3266oL.js +1 -0
- package/dist/gui/index.html +59 -0
- package/package.json +11 -4
- package/src/balance.d.mts +11 -0
- package/src/balance.mjs +178 -0
- package/src/balance.ts +35 -0
- package/src/dashboard.ts +8 -5
- package/src/gui.ts +170 -0
- package/src/index.ts +47 -6
- package/src/net.ts +56 -0
package/README.md
CHANGED
|
@@ -2,21 +2,11 @@
|
|
|
2
2
|
|
|
3
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
│ ├─ tab api-main │
|
|
11
|
-
│ │ ├─ ○ api-main main │
|
|
12
|
-
│ │ │ └─ ⠏ psb-sleeper subagent bash │
|
|
13
|
-
│ │ └─ ○ docs-member member │
|
|
14
|
-
│ └─ tab other │
|
|
15
|
-
│ └─ ? ops-main main waiting on a question │
|
|
16
|
-
│ other towers │
|
|
17
|
-
│ ✓ Beta tower │
|
|
18
|
-
╰──────────────────────────────────────────────────────────────────────────╯
|
|
19
|
-
```
|
|
5
|
+

|
|
6
|
+
|
|
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>
|
|
8
|
+
|
|
9
|
+

|
|
20
10
|
|
|
21
11
|
## Why
|
|
22
12
|
|
|
@@ -26,7 +16,9 @@ Run a few agents on the same project and they start stepping on each other: two
|
|
|
26
16
|
- **A main agent per tab.** The first to join a tab coordinates it; `/boss` takes over.
|
|
27
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.
|
|
28
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.
|
|
29
20
|
- **Broadcast** to a tab, a workspace, every tower, or every session.
|
|
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.
|
|
30
22
|
|
|
31
23
|
## Install
|
|
32
24
|
|
|
@@ -41,6 +33,7 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
|
|
|
41
33
|
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`.
|
|
42
34
|
2. Type `/boss status` to see each tab's main agent and the towers.
|
|
43
35
|
3. Type `/boss tower` to open the workspace's tower and watch its dashboard. Agents open it themselves when they call it.
|
|
36
|
+
4. Type `/boss gui` from any session to see the whole net in your browser.
|
|
44
37
|
|
|
45
38
|
## Tools and commands
|
|
46
39
|
|
|
@@ -52,13 +45,14 @@ You need pi running inside Herdr 0.9.4 or newer, and [pi-intercom](https://www.n
|
|
|
52
45
|
| `boss_broadcast` | Message a tab, a workspace, all towers, or every session. |
|
|
53
46
|
| `/boss` | Join and become this tab's main agent. |
|
|
54
47
|
| `/boss status`, `/boss tower` | Show the status; open and focus the tower. |
|
|
48
|
+
| `/boss gui`, `/boss gui stop` | Open the net in the browser (any session, joins nothing); stop its server. |
|
|
55
49
|
|
|
56
50
|
Subagents and sessions in Herdr worktree workspaces never join: the session that launched them speaks for them. Outside Herdr the extension does nothing.
|
|
57
51
|
|
|
58
52
|
## Learn more
|
|
59
53
|
|
|
60
|
-
[How it works](docs/how-it-works.md) covers roles, the tower, the dashboard, what the extension writes, and how Claude Code or Codex agents can find a tower.
|
|
54
|
+
[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.
|
|
61
55
|
|
|
62
56
|
## License
|
|
63
57
|
|
|
64
|
-
MIT
|
|
58
|
+
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -21,17 +21,23 @@
|
|
|
21
21
|
],
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
|
+
"dist/gui",
|
|
24
25
|
"prompts",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
28
29
|
"pi": {
|
|
29
30
|
"extensions": [
|
|
30
|
-
"./src/index.ts"
|
|
31
|
+
"./src/index.ts",
|
|
32
|
+
"./src/balance.ts"
|
|
31
33
|
]
|
|
32
34
|
},
|
|
33
35
|
"scripts": {
|
|
34
|
-
"check": "tsc --noEmit",
|
|
36
|
+
"check": "tsc --noEmit && tsc --noEmit -p gui",
|
|
37
|
+
"gui:build": "vite build gui",
|
|
38
|
+
"gui:dev": "vite gui",
|
|
39
|
+
"prepack": "npm run gui:build",
|
|
40
|
+
"pretest": "npm run gui:build",
|
|
35
41
|
"test": "node --test tests/*.test.ts"
|
|
36
42
|
},
|
|
37
43
|
"peerDependencies": {
|
|
@@ -44,6 +50,7 @@
|
|
|
44
50
|
"@earendil-works/pi-tui": "1.3.0",
|
|
45
51
|
"@types/node": "26.6.5",
|
|
46
52
|
"typebox": "1.3.27",
|
|
47
|
-
"typescript": "7.0.2"
|
|
53
|
+
"typescript": "7.0.2",
|
|
54
|
+
"vite": "8.3.4"
|
|
48
55
|
}
|
|
49
56
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Rect { x: number; y: number; width: number; height: number }
|
|
2
|
+
export interface Split { id: string; direction: "right" | "down"; ratio: number; rect: Rect }
|
|
3
|
+
export interface Pane { pane_id: string; rect: Rect }
|
|
4
|
+
export interface Layout { panes: Pane[]; splits: Split[]; zoomed?: boolean }
|
|
5
|
+
export interface Resize { pane: string; direction: "left" | "right" | "up" | "down"; amount: number }
|
|
6
|
+
export type Run = (args: string[]) => Promise<string>;
|
|
7
|
+
|
|
8
|
+
export function planBalance(layout: Layout): Resize[];
|
|
9
|
+
export const herdr: Run;
|
|
10
|
+
export function balanceTab(paneId: string, options?: { run?: Run; lockRoot?: string }): Promise<void>;
|
|
11
|
+
export function balanceAfterClose(paneId: string, tabId: string | undefined, options?: { run?: Run; lockRoot?: string; pollMs?: number; waitMs?: number }): Promise<void>;
|
package/src/balance.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Equalises the panes of one Herdr tab. The extension (balance.ts) runs it detached: `balance.mjs now <pane>` when its pi
|
|
2
|
+
// starts, and `balance.mjs after-close <pane> <tab>` when its pi quits, because the pane closes only after the pi process is gone.
|
|
3
|
+
// Plain JavaScript on purpose: Node will not strip types from a .ts file inside node_modules, where this package is installed.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { mkdir, rmdir, stat } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
/** @typedef {{ x: number, y: number, width: number, height: number }} Rect */
|
|
11
|
+
/** @typedef {{ id: string, direction: "right" | "down", ratio: number, rect: Rect }} Split */
|
|
12
|
+
/** @typedef {{ pane_id: string, rect: Rect }} Pane */
|
|
13
|
+
/** @typedef {{ panes: Pane[], splits: Split[], zoomed?: boolean }} Layout */
|
|
14
|
+
/** @typedef {{ pane: string, direction: "left" | "right" | "up" | "down", amount: number }} Resize */
|
|
15
|
+
/** @typedef {(args: string[]) => Promise<string>} Run */
|
|
16
|
+
|
|
17
|
+
const TOLERANCE = 0.01;
|
|
18
|
+
// Each pass applies one resize and rereads the layout; a tab never needs more passes than this.
|
|
19
|
+
const MAX_PASSES = 32;
|
|
20
|
+
const LOCK_ROOT = join(tmpdir(), "pi-straw-boss-pane-balance");
|
|
21
|
+
const LOCK_WAIT_MS = 15_000;
|
|
22
|
+
const LOCK_STALE_MS = 30_000;
|
|
23
|
+
// pi-herdr-agents closes a worker's pane once its lead has taken the result.
|
|
24
|
+
const CLOSE_WAIT_MS = 30_000;
|
|
25
|
+
const POLL_MS = 250;
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
|
|
29
|
+
// Herdr reports its split tree flat: `split_<depth>_<path>`, where the path spells the
|
|
30
|
+
// first (0) or second (1) child taken from the root, and the root's path is `root`.
|
|
31
|
+
const splitPath = (id) => {
|
|
32
|
+
const path = id.split("_").slice(2).join("_");
|
|
33
|
+
return path === "root" ? "" : path;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The resizes that give every leaf an equal share along each split's axis, outermost first.
|
|
38
|
+
* @param {Layout} layout
|
|
39
|
+
* @returns {Resize[]}
|
|
40
|
+
*/
|
|
41
|
+
export function planBalance(layout) {
|
|
42
|
+
const splits = new Map(layout.splits.map((split) => [splitPath(split.id), split]));
|
|
43
|
+
/** @type {Map<string, Pane[]>} */
|
|
44
|
+
const members = new Map([["", layout.panes]]);
|
|
45
|
+
const sides = (path) => {
|
|
46
|
+
const split = splits.get(path);
|
|
47
|
+
const horizontal = split.direction === "right";
|
|
48
|
+
const boundary = horizontal
|
|
49
|
+
? split.rect.x + split.rect.width * split.ratio
|
|
50
|
+
: split.rect.y + split.rect.height * split.ratio;
|
|
51
|
+
const first = [];
|
|
52
|
+
const second = [];
|
|
53
|
+
for (const pane of members.get(path) ?? []) {
|
|
54
|
+
const centre = horizontal ? pane.rect.x + pane.rect.width / 2 : pane.rect.y + pane.rect.height / 2;
|
|
55
|
+
(centre < boundary ? first : second).push(pane);
|
|
56
|
+
}
|
|
57
|
+
return [first, second];
|
|
58
|
+
};
|
|
59
|
+
for (const path of [...splits.keys()].sort((a, b) => a.length - b.length)) {
|
|
60
|
+
const [first, second] = sides(path);
|
|
61
|
+
members.set(`${path}0`, first);
|
|
62
|
+
members.set(`${path}1`, second);
|
|
63
|
+
}
|
|
64
|
+
const weight = (path, axis) => {
|
|
65
|
+
const split = splits.get(path);
|
|
66
|
+
if (!split) return 1;
|
|
67
|
+
const a = weight(`${path}0`, axis);
|
|
68
|
+
const b = weight(`${path}1`, axis);
|
|
69
|
+
return split.direction === axis ? a + b : Math.max(a, b);
|
|
70
|
+
};
|
|
71
|
+
/** @type {Resize[]} */
|
|
72
|
+
const resizes = [];
|
|
73
|
+
for (const [path, split] of [...splits].sort(([a], [b]) => a.length - b.length)) {
|
|
74
|
+
const first = weight(`${path}0`, split.direction);
|
|
75
|
+
const target = first / (first + weight(`${path}1`, split.direction));
|
|
76
|
+
const delta = target - split.ratio;
|
|
77
|
+
if (Math.abs(delta) < TOLERANCE) continue;
|
|
78
|
+
const horizontal = split.direction === "right";
|
|
79
|
+
const edge = (pane) => (horizontal ? pane.rect.x + pane.rect.width : pane.rect.y + pane.rect.height);
|
|
80
|
+
const start = (pane) => (horizontal ? pane.rect.x : pane.rect.y);
|
|
81
|
+
// Herdr moves the named edge of the pane: grow the first side from its far edge,
|
|
82
|
+
// or grow the second side from its near edge.
|
|
83
|
+
const amount = Number(Math.abs(delta).toFixed(4));
|
|
84
|
+
if (delta > 0) {
|
|
85
|
+
const pane = members.get(`${path}0`).reduce((best, pane) => (edge(pane) > edge(best) ? pane : best));
|
|
86
|
+
resizes.push({ pane: pane.pane_id, direction: horizontal ? "right" : "down", amount });
|
|
87
|
+
} else {
|
|
88
|
+
const pane = members.get(`${path}1`).reduce((best, pane) => (start(pane) < start(best) ? pane : best));
|
|
89
|
+
resizes.push({ pane: pane.pane_id, direction: horizontal ? "left" : "up", amount });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return resizes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @type {Run} */
|
|
96
|
+
export const herdr = (args) =>
|
|
97
|
+
new Promise((resolve, reject) => {
|
|
98
|
+
execFile(process.env.HERDR_BIN_PATH || "herdr", args, { encoding: "utf8" }, (error, stdout, stderr) =>
|
|
99
|
+
error ? reject(Object.assign(error, { stderr })) : resolve(stdout));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const readLayout = async (paneId, run) =>
|
|
103
|
+
/** @type {Layout & { tab_id: string }} */ (JSON.parse(await run(["pane", "layout", "--pane", paneId])).result.layout);
|
|
104
|
+
|
|
105
|
+
/** Holds a per-tab directory lock so concurrent Pi sessions take turns resizing one tab. */
|
|
106
|
+
async function withTabLock(tabId, lockRoot, work) {
|
|
107
|
+
const lock = join(lockRoot, tabId.replace(/[^\w.-]/g, "_"));
|
|
108
|
+
await mkdir(lockRoot, { recursive: true });
|
|
109
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
110
|
+
for (;;) {
|
|
111
|
+
try {
|
|
112
|
+
await mkdir(lock);
|
|
113
|
+
break;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST") throw error;
|
|
116
|
+
const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
117
|
+
if (age > LOCK_STALE_MS) {
|
|
118
|
+
await rmdir(lock).catch(() => {});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (Date.now() > deadline) return;
|
|
122
|
+
await sleep(100);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await work();
|
|
127
|
+
} finally {
|
|
128
|
+
await rmdir(lock).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Equalises the tab holding `paneId`. Every resize is planned from a freshly read layout,
|
|
134
|
+
* so a pane that opens or closes mid-way is accounted for instead of overshot.
|
|
135
|
+
* @param {string} paneId
|
|
136
|
+
* @param {{ run?: Run, lockRoot?: string }} [options]
|
|
137
|
+
*/
|
|
138
|
+
export async function balanceTab(paneId, { run = herdr, lockRoot = LOCK_ROOT } = {}) {
|
|
139
|
+
const { tab_id: tabId } = await readLayout(paneId, run);
|
|
140
|
+
await withTabLock(tabId, lockRoot, async () => {
|
|
141
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
142
|
+
const layout = await readLayout(paneId, run);
|
|
143
|
+
if (layout.zoomed) return;
|
|
144
|
+
const [resize] = planBalance(layout);
|
|
145
|
+
if (!resize) return;
|
|
146
|
+
await run(["pane", "resize", "--pane", resize.pane, "--direction", resize.direction, "--amount", String(resize.amount)]);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether Herdr reports the pane gone; it says so on stderr. Any other failure counts as open. */
|
|
152
|
+
const paneGone = (paneId, run) =>
|
|
153
|
+
run(["pane", "get", paneId]).then(() => false, (error) => String(error?.stderr ?? "").includes("pane_not_found"));
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Waits for `paneId` to close, then equalises `tabId`, the tab the quitting Pi was in.
|
|
157
|
+
* A pane that outlives its Pi, such as a shell the user keeps, changes no layout and is left alone.
|
|
158
|
+
* @param {string} paneId
|
|
159
|
+
* @param {string | undefined} tabId
|
|
160
|
+
* @param {{ run?: Run, lockRoot?: string, pollMs?: number, waitMs?: number }} [options]
|
|
161
|
+
*/
|
|
162
|
+
export async function balanceAfterClose(paneId, tabId, { run = herdr, lockRoot = LOCK_ROOT, pollMs = POLL_MS, waitMs = CLOSE_WAIT_MS } = {}) {
|
|
163
|
+
if (!tabId) return;
|
|
164
|
+
const deadline = Date.now() + waitMs;
|
|
165
|
+
while (!(await paneGone(paneId, run))) {
|
|
166
|
+
if (Date.now() > deadline) return;
|
|
167
|
+
await sleep(pollMs);
|
|
168
|
+
}
|
|
169
|
+
const panes = /** @type {{ pane_id: string, tab_id: string }[]} */ (JSON.parse(await run(["pane", "list"])).result.panes);
|
|
170
|
+
const anchor = panes.find((pane) => pane.tab_id === tabId);
|
|
171
|
+
if (anchor) await balanceTab(anchor.pane_id, { run, lockRoot });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[3]) {
|
|
175
|
+
const [, , mode, pane, tab] = process.argv;
|
|
176
|
+
if (mode === "now") await balanceTab(pane).catch(() => {});
|
|
177
|
+
if (mode === "after-close") await balanceAfterClose(pane, tab).catch(() => {});
|
|
178
|
+
}
|
package/src/balance.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
// Pane balance: when a pi starts in a Herdr tab, or quits out of one, the tab's panes are made equal again, so a tab that
|
|
6
|
+
// pi-herdr-agents keeps splitting for workers does not end up as a chain of halves. Every pi does this, joined or not.
|
|
7
|
+
// The work runs in balance.mjs as a detached process: a quitting pi exits before Herdr closes its pane, and a fresh process
|
|
8
|
+
// always runs the installed version, where an imported .mjs would stay cached across /reload.
|
|
9
|
+
|
|
10
|
+
const HELPER = fileURLToPath(new URL("./balance.mjs", import.meta.url));
|
|
11
|
+
|
|
12
|
+
/** Runs the helper outside this process: `now <pane>` or `after-close <pane> <tab>`. */
|
|
13
|
+
export function runHelper(args: string[], launch = spawn): void {
|
|
14
|
+
const child = launch(process.execPath, [HELPER, ...args], { detached: true, stdio: "ignore" });
|
|
15
|
+
child.on("error", () => {});
|
|
16
|
+
child.unref();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Survives /reload, which loads extensions again in the same process.
|
|
20
|
+
const STARTED = Symbol.for("pi-straw-boss.pane-balance.started");
|
|
21
|
+
|
|
22
|
+
/** Equalises a Herdr tab only when a pi starts in it or quits out of it. */
|
|
23
|
+
export default function paneBalance(pi: ExtensionAPI, helper = runHelper, env = process.env): void {
|
|
24
|
+
const paneId = env.HERDR_PANE_ID;
|
|
25
|
+
if (!paneId) return;
|
|
26
|
+
// Balance on load: session_start waits for every other extension's handler, about ten seconds later.
|
|
27
|
+
const host = globalThis as { [STARTED]?: boolean };
|
|
28
|
+
if (!host[STARTED]) {
|
|
29
|
+
host[STARTED] = true;
|
|
30
|
+
helper(["now", paneId]);
|
|
31
|
+
}
|
|
32
|
+
pi.on("session_shutdown", (event) => {
|
|
33
|
+
if (event.reason === "quit") helper(["after-close", paneId, env.HERDR_TAB_ID ?? ""]);
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/dashboard.ts
CHANGED
|
@@ -34,7 +34,12 @@ const MAX_ROWS = 18;
|
|
|
34
34
|
const MAX_WIDTH = 76;
|
|
35
35
|
const MAX_CHILDREN = 8;
|
|
36
36
|
|
|
37
|
-
const markOf = (status?: string): Mark => HERDR_MARK[status ?? ""] ?? "unknown";
|
|
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
|
+
}
|
|
38
43
|
const pad = (text: string, width: number) => text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
39
44
|
|
|
40
45
|
/** The pure half: a snapshot in, a framed tree out. One shared spinner frame animates every working row. */
|
|
@@ -69,7 +74,7 @@ function readJson(path: string): Record<string, unknown> {
|
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/** The subagents a pi session launched: live ones, plus those that finished in the last 10 minutes. */
|
|
72
|
-
function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
|
|
77
|
+
export function subagents(parent: Pane | undefined, all: Pane[], now: number): Row[] {
|
|
73
78
|
const file = parent?.agent_session?.value;
|
|
74
79
|
if (!file) return [];
|
|
75
80
|
const dir = join(dirname(file), "artifacts", basename(file, ".jsonl").split("_").pop()!);
|
|
@@ -109,9 +114,7 @@ export async function collect({ run, sessions, workspaceId, now = Date.now() }:
|
|
|
109
114
|
const head = headOf(all);
|
|
110
115
|
const mine = all.find((w) => w.workspace_id === workspaceId);
|
|
111
116
|
const sessionRow = (s: Session, role: string): Row => {
|
|
112
|
-
const
|
|
113
|
-
const busy = s.status ? s.status !== "idle" : undefined;
|
|
114
|
-
const mark: Mark = pane?.agent_status === "blocked" ? "waiting" : busy === undefined ? markOf(pane?.agent_status) : busy ? "working" : "idle";
|
|
117
|
+
const mark = sessionMark(s, ps.find((p) => p.pane_id === s.herdrLocation?.paneId));
|
|
115
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"] };
|
|
116
119
|
};
|
|
117
120
|
const joined = live.filter((s) => s.herdrLocation?.workspace?.id === workspaceId).flatMap((s) => {
|
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/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
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";
|
|
7
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";
|
|
8
11
|
|
|
9
12
|
// pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
|
|
@@ -30,7 +33,7 @@ interface Saved { asked?: boolean; joined?: boolean; tower?: boolean }
|
|
|
30
33
|
type Role = "tower" | "main" | "member";
|
|
31
34
|
type Scope = "tab" | "workspace" | "towers" | "all";
|
|
32
35
|
type Member = ReturnType<typeof netMembers>[number];
|
|
33
|
-
export interface Deps { run?: HerdrRun; api?: HerdrApi; 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 }
|
|
34
37
|
|
|
35
38
|
/** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
|
|
36
39
|
function netMembers(sessions: Session[], ps: Pane[]) {
|
|
@@ -42,7 +45,7 @@ function netMembers(sessions: Session[], ps: Pane[]) {
|
|
|
42
45
|
}
|
|
43
46
|
const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
|
|
44
47
|
|
|
45
|
-
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)) }: 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 = {}) {
|
|
46
49
|
return function strawBoss(pi: ExtensionAPI): void {
|
|
47
50
|
// Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
|
|
48
51
|
if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
|
|
@@ -51,6 +54,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
51
54
|
// Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
|
|
52
55
|
let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
|
|
53
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");
|
|
54
59
|
let c!: Channel, at!: Place, ui: ExtensionContext["ui"] | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
55
60
|
let queue: Promise<void> = Promise.resolve();
|
|
56
61
|
const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
|
|
@@ -120,9 +125,9 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
120
125
|
};
|
|
121
126
|
const sync = () => (queue = queue.then(refresh));
|
|
122
127
|
// A session that has just started connects to the broker a moment after session_start.
|
|
123
|
-
const connected = async () => {
|
|
124
|
-
if (await waitFor(sleep, () =>
|
|
125
|
-
throw new Error(
|
|
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");
|
|
126
131
|
};
|
|
127
132
|
const joinNet = async (restored = false) => {
|
|
128
133
|
if (joined) return;
|
|
@@ -315,6 +320,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
315
320
|
});
|
|
316
321
|
|
|
317
322
|
pi.on("session_shutdown", async () => {
|
|
323
|
+
await gui?.stop();
|
|
324
|
+
gui = undefined;
|
|
318
325
|
dashboard?.stop();
|
|
319
326
|
dashboard = undefined;
|
|
320
327
|
clearInterval(timer);
|
|
@@ -326,11 +333,45 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
326
333
|
if (held) await report(run, "workspace", at.workspaceId, { tower: null }).catch(() => {});
|
|
327
334
|
});
|
|
328
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
|
+
|
|
329
369
|
pi.registerCommand("boss", {
|
|
330
|
-
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).",
|
|
331
371
|
handler: async (args, ctx) => {
|
|
332
372
|
ui = ctx.ui;
|
|
333
373
|
try {
|
|
374
|
+
if (/^gui( stop)?$/.test(args.trim())) return await guiCommand(args.trim() === "gui stop");
|
|
334
375
|
if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net. /boss joins it.");
|
|
335
376
|
if (isTower) throw new Error("This session is the tower, not a main agent");
|
|
336
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
|
+
}
|