pi-straw-boss 0.3.2 → 0.4.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/src/net.ts CHANGED
@@ -1,23 +1,34 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, join, resolve, sep } from "node:path";
1
3
  import { type Mark, markOf, type Row, sessionMark, subagents } from "./dashboard.ts";
2
4
  import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
3
5
 
4
6
  // The whole straw net as one JSON-able snapshot for the browser view: every workspace as a base with its tower, tabs, agents,
5
7
  // and subagents (linked to their launcher the way the tower dashboard links them), and sub workspaces as outposts.
6
8
 
7
- export interface Unit { id: string; name: string; role: "main" | "member" | "subagent"; mark: Mark; note?: string; subs: Unit[] }
9
+ /** `key` names the unit in routes and the API; `control` says a live pi pane backs it. `outside` units are pi panes the net does not hold (D1). */
10
+ export interface Unit { id: string; key: string; control: boolean; name: string; role: "main" | "member" | "subagent" | "outside"; mark: Mark; note?: string; subs: Unit[] }
8
11
  export interface Tab { id: string; label: string; units: Unit[] }
9
12
  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[] }
13
+ export interface Base { id: string; label: string; mark: Mark; head: boolean; tower: { name: string; mark: Mark; key: string; control: boolean } | null; tabs: Tab[]; outposts: Outpost[]; outside: Unit[] }
11
14
  export interface Net { bases: Base[] }
12
- export interface NetInput { sessions: Session[]; panes: Pane[]; workspaces: Workspace[]; children(pane: Pane | undefined): Row[] }
15
+ /** What the server may read for a key: the registry entry is the allow list, and paths never come from the browser. */
16
+ export interface Ref { key: string; base: string; name: string; role: string; file?: string; pane?: string }
17
+ export interface Found extends Ref { live: boolean; successor?: string }
18
+ export interface NetInput { sessions: Session[]; panes: Pane[]; workspaces: Workspace[]; children(pane: Pane | undefined): Row[]; sessionDir?: string }
13
19
 
14
20
  const joinedRole = (token?: string): "main" | "member" | undefined => (token === "main" || token === "member" ? token : undefined);
21
+ const RECENT_MS = 10 * 60_000;
15
22
 
16
23
  /**
17
24
  * 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.
25
+ * and so are workspaces with no part in the net (no live tower, nobody joined): a base is a workspace the net reaches. Every unit
26
+ * that has a key is also written to `refs`, the server's allow list, with a file only from pi's `sessionDir`. A base's `outside` lists its other pi panes (D1).
19
27
  */
20
- export function buildNet({ sessions, panes: ps, workspaces: all, children }: NetInput): Net {
28
+ export function buildNet({ sessions, panes: ps, workspaces: all, children, sessionDir = join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sessions") }: NetInput, refs = new Map<string, Ref>()): Net {
29
+ // Readable: a .jsonl under pi's sessions directory, named for its intercom session (during /new the pane names the next file first).
30
+ const readable = (file?: string, id?: string) => (file?.endsWith(".jsonl") && resolve(file).startsWith(resolve(sessionDir) + sep) && (!id || basename(file).endsWith(`_${id}.jsonl`)) ? file : undefined);
31
+ const piFile = (pane: Pane | undefined, id: string) => readable(pane?.agent === "pi" ? pane.agent_session?.value : undefined, id);
21
32
  const paneOf = (s: Session) => ps.find((p) => p.pane_id === s.herdrLocation?.paneId);
22
33
  const note = (mark: Mark, status?: string) => ({ working: status, waiting: "waiting on a question" })[mark as "working"];
23
34
  const joined = sessions.flatMap((s) => {
@@ -27,6 +38,8 @@ export function buildNet({ sessions, panes: ps, workspaces: all, children }: Net
27
38
  const rowsOf = new Map(joined.map(({ s, pane }) => [s.id, children(pane)]));
28
39
  // A sub workspace that already shows as a launched subagent is not an outpost as well.
29
40
  const claimed = new Set([...rowsOf.values()].flat().map((r) => r.workspace));
41
+ const held = new Set([...joined.map((j) => j.pane?.pane_id), ...[...rowsOf.values()].flat().map((r) => r.pane)]);
42
+ const keep = (r: Ref) => (refs.set(r.key, r), r.key);
30
43
  const bases = all.filter((w) => !isSub(w)).flatMap((w): Base[] => {
31
44
  const mine = joined.filter((j) => j.base === w.workspace_id && j.tab);
32
45
  const tabs = Object.values(Object.groupBy(mine, (j) => j.tab!.id)).map((group = []): Tab => ({
@@ -34,23 +47,61 @@ export function buildNet({ sessions, panes: ps, workspaces: all, children }: Net
34
47
  label: group[0]!.tab!.label,
35
48
  units: group.toSorted((a, b) => Number(b.role === "main") - Number(a.role === "main")).map(({ s, pane, role }): Unit => {
36
49
  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 };
50
+ const subs = (rowsOf.get(s.id) ?? []).map((r): Unit => {
51
+ const key = keep({ key: r.id ?? `${s.id}/${r.text}`, base: w.workspace_id, name: r.text, role: "subagent", file: readable(r.file), pane: r.pane });
52
+ return { id: key, key, control: Boolean(r.pane), name: r.text, role: "subagent", mark: r.mark, note: r.note, subs: [] };
53
+ });
54
+ const key = keep({ key: s.id, base: w.workspace_id, name: s.name ?? s.id, role, file: piFile(pane, s.id), pane: pane?.pane_id });
55
+ return { id: key, key, control: pane?.agent === "pi", name: s.name ?? s.id, role, mark, note: note(mark, s.status), subs };
39
56
  }),
40
57
  }));
41
58
  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);
59
+ const tower = sessions.find((s) => s.id === w.tokens?.tower), towerPane = tower && paneOf(tower);
60
+ const outside = ps.filter((p) => p.workspace_id === w.workspace_id && p.agent === "pi" && p.pane_id !== towerPane?.pane_id && !held.has(p.pane_id)).flatMap((p): Unit[] => {
61
+ const s = sessions.find((x) => x.herdrLocation?.paneId === p.pane_id), file = p.agent_session?.value;
62
+ const key = s?.id ?? (file && basename(file, ".jsonl").split("_").pop());
63
+ if (!key) return [];
64
+ const name = s?.name ?? p.label ?? key.slice(0, 8), mark = s ? sessionMark(s, p) : markOf(p.agent_status);
65
+ keep({ key, base: w.workspace_id, name, role: "outside", file: readable(file, s?.id), pane: p.pane_id });
66
+ return [{ id: key, key, control: true, name, role: "outside", mark, note: note(mark, s?.status), subs: [] }];
67
+ });
43
68
  const marks = [...tabs.flatMap((t) => t.units.flatMap((u) => [u.mark, ...u.subs.map((c) => c.mark)])), ...outposts.map((o) => o.mark)];
44
69
  // A base shows what needs attention most: a waiting unit, then a working one, else the workspace's own status.
45
70
  const mark = (["waiting", "working"] as const).find((m) => marks.includes(m)) ?? markOf(w.agent_status);
46
71
  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 }];
72
+ const named = tower && keep({ key: tower.id, base: w.workspace_id, name: tower.name ?? tower.id, role: "tower", file: piFile(towerPane, tower.id), pane: towerPane?.pane_id });
73
+ return [{ id: w.workspace_id, label: w.label, mark, head: w === headOf(all), tower: tower && named ? { name: tower.name ?? tower.id, mark: sessionMark(tower, towerPane), key: named, control: towerPane?.agent === "pi" } : null, tabs, outposts, outside }];
48
74
  });
49
75
  return { bases };
50
76
  }
51
77
 
52
78
  /** 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> {
79
+ export async function collectNet({ run, sessions, now = Date.now(), refs, sessionDir }: { run: HerdrRun; sessions(): Promise<Session[]>; now?: number; refs?: Map<string, Ref>; sessionDir?: string }): Promise<Net> {
54
80
  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) });
81
+ return buildNet({ sessions: live, panes: ps, workspaces: all, children: (pane) => subagents(pane, ps, now), sessionDir }, refs);
82
+ }
83
+
84
+ /**
85
+ * The net as the server reads it: each read also refreshes the registry. A key that left the net (the pane closed, or moved to
86
+ * another session) stays findable for ten minutes, the window finished subagents already use, with the pane's new session as its successor.
87
+ */
88
+ export function netSource(env: { run: HerdrRun; sessions(): Promise<Session[]>; sessionDir?: string }) {
89
+ let refs = new Map<string, Ref>();
90
+ const gone = new Map<string, { ref: Ref; at: number }>();
91
+ return {
92
+ async read(): Promise<Net> {
93
+ const next = new Map<string, Ref>(), at = Date.now();
94
+ const net = await collectNet({ ...env, refs: next });
95
+ for (const [key, ref] of refs) if (!next.has(key)) gone.set(key, { ref, at });
96
+ for (const [key, old] of gone) if (next.has(key) || at - old.at > RECENT_MS) gone.delete(key);
97
+ refs = next;
98
+ return net;
99
+ },
100
+ lookup(key: string): Found | undefined {
101
+ const live = refs.get(key), old = gone.get(key)?.ref;
102
+ if (live) return { ...live, live: true };
103
+ return old && { ...old, live: false, successor: old.pane ? [...refs.values()].find((r) => r.pane === old.pane)?.key : undefined };
104
+ },
105
+ workspace: (id: string): Ref[] => [...refs.values()].filter((r) => r.base === id),
106
+ };
56
107
  }
package/src/watch.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { type readers as makeReaders, PAGE, type Reader } from "./history.ts";
3
+ import { lastLine } from "./items.ts";
4
+ import type { Found, Ref } from "./net.ts";
5
+
6
+ // What a page watching one session or one workspace receives on the stream, and the paged API behind it. Everything is read
7
+ // through the registry (net.lookup): a key it does not know, or that has no file yet, never reaches the file system.
8
+
9
+ export interface Hub { lookup(key: string): Found | undefined; workspace(id: string): Ref[]; readers: ReturnType<typeof makeReaders> }
10
+ export type Send = (event: string, data: unknown) => void;
11
+ /** `refresh` runs after every net reading: it retries what could not attach yet and reports changes the file events missed. */
12
+ export interface Watch { refresh(): Promise<void>; stop(): void }
13
+
14
+ /**
15
+ * The history of one session: the newest page at once, then every change the reader reports; a state change when the key leaves the net.
16
+ * A key whose file changes re-attaches (the page resets); a first page that fails releases the reader, and the next refresh tries again.
17
+ */
18
+ export function watchSession(hub: Hub, key: string, send: Send): Watch {
19
+ let reader: Reader | undefined, file: string | undefined, unsub: (() => void) | undefined, signature: string | undefined, stopped = false, attaching = false;
20
+ const meta = (f?: Found) => ({ key, state: f ? (f.live ? "live" : "recent") : "unknown", successor: f?.successor, base: f?.base, name: f?.name, role: f?.role });
21
+ const detach = () => (unsub?.(), file && hub.readers.release(file), (reader = file = unsub = undefined));
22
+ async function refresh() {
23
+ if (stopped || attaching) return;
24
+ const found = hub.lookup(key), sig = JSON.stringify(meta(found));
25
+ if (reader && found?.file && found.file !== file) detach();
26
+ if (!reader && found?.file) {
27
+ attaching = true;
28
+ try {
29
+ file = found.file;
30
+ reader = hub.readers.acquire(file);
31
+ const queued: unknown[] = [];
32
+ let ready = false;
33
+ unsub = reader.subscribe((change) => (ready ? send("history", { key, ...change }) : queued.push({ key, ...change })));
34
+ const { items, before } = await reader.page();
35
+ if (stopped || file !== found.file) return;
36
+ send("history", { ...meta(found), reset: true, items, before, header: reader.header(), unreadable: reader.unreadable() });
37
+ ready = true;
38
+ queued.forEach((change) => send("history", change));
39
+ signature = sig;
40
+ } catch {
41
+ detach();
42
+ } finally {
43
+ attaching = false;
44
+ }
45
+ } else if (sig !== signature) {
46
+ // Unknown, not written yet, or changed state: say so, with no items (the page keeps what it has unless this is the first message).
47
+ send("history", { ...meta(found), reset: signature === undefined, items: [] });
48
+ signature = sig;
49
+ }
50
+ }
51
+ return {
52
+ refresh,
53
+ stop() {
54
+ stopped = true;
55
+ detach();
56
+ },
57
+ };
58
+ }
59
+
60
+ /** The last assistant line of every session of a workspace, resent for a session only when its file changed. */
61
+ export function watchWorkspace(hub: Hub, id: string, send: Send): Watch {
62
+ const seen = new Map<string, string>();
63
+ return {
64
+ async refresh() {
65
+ const out: Record<string, string> = {};
66
+ const read = async (r: Ref & { file: string }) => {
67
+ const s = await stat(r.file).catch(() => undefined), sig = s ? `${s.size}:${s.mtimeMs}` : "";
68
+ if (seen.get(r.key) !== sig) out[r.key] = s ? await lastLine(r.file) : "";
69
+ seen.set(r.key, sig);
70
+ };
71
+ await Promise.all(hub.workspace(id).flatMap((r) => (r.file ? [read(r as Ref & { file: string })] : [])));
72
+ if (Object.keys(out).length) send("cards", out);
73
+ },
74
+ stop() {},
75
+ };
76
+ }
77
+
78
+ /** GET /api/history/<key>?before=<entry id>&limit=<n> and GET /api/entry/<key>/<entry id>. */
79
+ export async function api(hub: Hub, [kind, key, id]: string[], query: URLSearchParams): Promise<{ status: number; body: unknown }> {
80
+ const found = key ? hub.lookup(key) : undefined;
81
+ if (!found?.file || !["history", "entry"].includes(kind ?? "")) return { status: 404, body: { error: "not found" } };
82
+ const reader = hub.readers.acquire(found.file);
83
+ try {
84
+ if (kind === "history") return { status: 200, body: await reader.page(query.get("before") ?? undefined, Math.min(300, Math.max(1, Number(query.get("limit")) || PAGE))) };
85
+ const item = id ? await reader.entry(id) : undefined;
86
+ return item ? { status: 200, body: item } : { status: 404, body: { error: "not found" } };
87
+ } finally {
88
+ hub.readers.release(found.file);
89
+ }
90
+ }
@@ -1 +0,0 @@
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}}
@@ -1 +0,0 @@
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();
@@ -1 +0,0 @@
1
- Straw Boss: before you start, decide whether your current work competes with other work in this Herdr workspace for resources (ports, databases, files, branches) or needs to collaborate with it. If so, call `boss_join` now; you become the coordinator of this tab, or a member if another session already is. If not, ignore this and do not mention it. You can still call `boss_join` later if the need appears.