pi-straw-boss 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dialogs.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { type Pending, type Relayed, SEVERAL } from "./ask.ts";
4
- import { type Ack, type AnswerMessage, type Bridge, type Dialogs, guiNamespace, type LocalHub, REGISTER, type ToHub } from "./bridge.ts";
4
+ import { type Ack, type AnswerMessage, type Bridge, type Dialogs, type FormMessage, type FormReply, guiNamespace, type LocalHub, REGISTER, type ToHub } from "./bridge.ts";
5
+ import { createCaps, type Doc, docsOf, type Fields, fieldsOk, type FormResult, type Shelf } from "./documents.ts";
5
6
  import type { Session } from "./herdr.ts";
6
7
  import { canonical } from "./history.ts";
7
8
  import type { Ref } from "./net.ts";
@@ -12,9 +13,10 @@ import { checkProbe, type Editor } from "./probe.ts";
12
13
  // bridge's pane, decides whether the page may answer it, and carries an answer to that bridge. A list counts only from the session intercom
13
14
  // places on that pane, for the unit whose session file it names. The page may answer a dialog only when it is the session's one open dialog,
14
15
  // with no answer on its way, belonging to the file's one open call (never a secret_drop), with a digest that matches its content.
15
- // The same binding, for every bridge whether it holds a dialog or not, tells where to ask what a session's editor would submit (`probe`).
16
+ // The same binding, for every bridge whether it holds a dialog or not, tells where to ask what a session's editor would submit (`probe`), and
17
+ // gives the unit the documents its bridge lists, each by a capability (documents.ts), and carries a form answer for one to that bridge.
16
18
 
17
- const ACK_MS = 5000, PROBE_MS = 1000, KEEP_MS = 600_000, KEEP = 256, MAX_TEXT = 8192, MAX_NOTE = 2048, MAX_ALL = 12_288;
19
+ const ACK_MS = 5000, PROBE_MS = 1000, FORM_MS = 5000, KEEP_MS = 600_000, KEEP = 256, MAX_TEXT = 8192, MAX_NOTE = 2048, MAX_ALL = 12_288;
18
20
  export const ANSWER_ID = /^[A-Za-z0-9_-]{16,64}$/, CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
19
21
  interface Channel { snapshot(): { connected: boolean; owner?: { sessionId: string; epoch: string } }; publish(payload: unknown, options?: { audience?: "owner" | "capable"; ownerOnly?: boolean }): void }
20
22
  type Event = { type: string; fromSessionId?: string; sessionId?: string; owner?: { sessionId: string; epoch: string }; payload?: unknown };
@@ -25,6 +27,11 @@ export type Hub = ReturnType<typeof createHub>;
25
27
 
26
28
  const str = (v: unknown): v is string => typeof v === "string";
27
29
  const opt = (v: unknown) => v === undefined || str(v);
30
+ /** Waits up to `ms` for the reply filed under `k`; undefined when none came (or the hub stopped). */
31
+ const wait = <T>(map: Map<string, (x?: T) => void>, k: string, ms: number) => new Promise<T | undefined>((resolve) => {
32
+ const timer = setTimeout(() => settle(), ms), settle = (x?: T) => (clearTimeout(timer), map.delete(k), resolve(x));
33
+ map.set(k, settle);
34
+ });
28
35
  /** A relayed dialog, checked so a malformed message cannot break the net reading. */
29
36
  const valid = (d: Dialog) => str(d?.owner) && str(d.id) && d.id.length <= 64 && /^[0-9a-f]{64}$/.test(String(d.digest)) && opt(d.toolCallId) &&
30
37
  (d.select === undefined || (str(d.select?.title) && Array.isArray(d.select.choices) && d.select.choices.every((c) => str(c?.label) && opt(c.note)) && opt(d.select.copy))) &&
@@ -57,6 +64,8 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
57
64
  let channel: Channel | undefined, epoch: string | undefined, active = false, change = () => {};
58
65
  let bound = new Map<string, { from: string; b: Dialogs }>(), targets = new Map<string, Target>(), reach = new Map<string, { from: string; bridgeId: string }>();
59
66
  const bridges = new Map<string, Dialogs>(), waiting = new Map<string, (ack?: Ack) => void>(), done = new Map<string, { key: string; at: number; sent: Sent }>(), probes = new Map<string, (e?: Editor) => void>();
67
+ const caps = createCaps(), forms = new Map<string, (r?: FormReply) => void>();
68
+ let shelves = new Map<string, Shelf>();
60
69
  const owner = () => channel?.snapshot().owner?.sessionId === me;
61
70
  const sync = () => {
62
71
  local.sync();
@@ -65,9 +74,11 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
65
74
  const receive = (from: string, msg: ToHub) => {
66
75
  if (!active || msg?.version !== 1 || !str(msg.bridgeId)) return;
67
76
  const closed = msg.type === "dialogs" && Array.isArray(msg.closed) && msg.closed.length <= 16 && msg.closed.every((c) => str(c?.id) && str(c.by) && opt(c.answerId));
68
- if (closed && str(msg.pane) && (msg.file === null || str(msg.file)) && Array.isArray(msg.dialogs) && msg.dialogs.every(valid)) (bridges.set(from, msg), change());
77
+ const docs = msg.type === "dialogs" && (msg.documents === undefined ? [] : docsOf({ version: 1, documents: msg.documents })); // documents out of shape go alone, as if cut
78
+ if (closed && str(msg.pane) && (msg.file === null || str(msg.file)) && Array.isArray(msg.dialogs) && msg.dialogs.every(valid)) (bridges.set(from, docs ? { ...msg, documents: docs } : { ...msg, documents: [], docsCut: true }), change());
69
79
  if (msg.type === "ack") waiting.get([from, msg.bridgeId, msg.id, msg.answerId].join("\n"))?.(msg);
70
80
  if (msg.type === "probe-result") probes.get([from, msg.bridgeId, msg.probeId].join("\n"))?.(checkProbe({ ...msg }));
81
+ if (msg.type === "form-result" && typeof msg.accepted === "boolean" && opt(msg.reason) && (msg.reason?.length ?? 0) <= 512) forms.get([from, msg.bridgeId, msg.id, msg.answerId].join("\n"))?.(msg);
71
82
  };
72
83
  const onEvent = (e: Event) => {
73
84
  if (e.type === "owner" && e.owner?.sessionId === me && e.owner.epoch !== epoch && (epoch = e.owner.epoch) && active) sync();
@@ -90,7 +101,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
90
101
  const hub: LocalHub & {
91
102
  readonly running: boolean; readonly me: string; start(onChange: () => void): void; stop(): void; relayed(sessions: Session[], refs: Map<string, Ref>): Relayed; target(key: string): Target | undefined;
92
103
  replay(answerId: string, key: string): Sent | undefined; ended(key: string, dialog: string, answerId: string): Sent | undefined; send(key: string, t: Target, a: Omit<AnswerMessage, "version" | "type" | "to">): Promise<Sent>;
93
- probe(key: string): Promise<Editor | undefined>;
104
+ probe(key: string): Promise<Editor | undefined>; doc(cap: string): { key: string; doc: Doc } | undefined; form(cap: string, answerId: string, fields: Fields): Promise<FormResult | undefined>;
94
105
  } = {
95
106
  receive,
96
107
  get running() { return active; },
@@ -108,19 +119,23 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
108
119
  },
109
120
  stop() {
110
121
  active = false;
111
- [bridges, bound, targets, reach].forEach((m) => m.clear());
112
- [...waiting.values(), ...probes.values()].forEach((w) => w());
122
+ [bridges, bound, targets, reach, shelves].forEach((m) => m.clear());
123
+ caps.clear(); // a capability lives as long as its server
124
+ [...waiting.values(), ...probes.values(), ...forms.values()].forEach((w) => w());
113
125
  },
114
126
  /** Binds each relayed list to a unit, from this net reading's sessions and registry. */
115
127
  relayed(sessions, refs) {
116
128
  [bound, targets, reach] = [new Map(), new Map(), new Map()];
129
+ const shelved: { key: string; from: string; bridgeId: string; docs: Doc[] }[] = [], cut = new Set<string>();
117
130
  for (const [from, b] of bridges) {
118
131
  const file = sessions.find((s) => s.id === from)?.herdrLocation?.paneId === b.pane ? canonical(b.file ?? undefined) : undefined;
119
132
  const ref = file && [...refs.values()].find((r) => r.pane === b.pane && r.file === file);
120
- if (ref) reach.set(ref.key, { from, bridgeId: b.bridgeId });
133
+ if (ref) (reach.set(ref.key, { from, bridgeId: b.bridgeId }), shelved.push({ key: ref.key, from, bridgeId: b.bridgeId, docs: b.documents ?? [] }), b.docsCut && cut.add(ref.key));
121
134
  if (ref && (b.dialogs.length || b.closed.length)) bound.set(ref.key, { from, b });
122
135
  }
123
- return { dialogsOf: (key) => bound.get(key)?.b.dialogs, merge };
136
+ const cards = caps.bind(shelved, new Set([...bridges].flatMap(([from, b]) => (b.documents ?? []).map((d) => `${from}\n${b.bridgeId}\n${d.id}`))));
137
+ shelves = new Map([...cards].flatMap(([key, docs]): [string, Shelf][] => (docs.length || cut.has(key) ? [[key, { ...(docs.length ? { docs } : {}), ...(cut.has(key) ? { docsCut: true as const } : {}) }]] : [])));
138
+ return { dialogsOf: (key) => bound.get(key)?.b.dialogs, merge, docsOf: (key) => shelves.get(key) };
124
139
  },
125
140
  /** How a dialog that is no longer open here ended: `accepted` for the answer that won it, else `stale` with who closed it. */
126
141
  ended(key, dialog, answerId) {
@@ -135,10 +150,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
135
150
  /** Publishes the answer to its bridge (or calls this session's) and waits up to 5 s for the bridge's ack. */
136
151
  async send(key, t, a) {
137
152
  const msg: AnswerMessage = { version: 1, type: "answer", to: t.bridgeId, ...a }, k = [t.from, t.bridgeId, a.id, a.answerId].join("\n");
138
- const ack = new Promise<Ack | undefined>((resolve) => {
139
- const timer = setTimeout(() => settle(), ACK_MS), settle = (x?: Ack) => (clearTimeout(timer), waiting.delete(k), resolve(x));
140
- waiting.set(k, settle);
141
- });
153
+ const ack = wait(waiting, k, ACK_MS);
142
154
  try {
143
155
  if (t.from === me) local.deliver(msg, (x) => receive(me, x));
144
156
  else if (owner()) channel!.publish(msg, { audience: "capable", ownerOnly: true });
@@ -159,11 +171,7 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
159
171
  const t = reach.get(key);
160
172
  if (!t || !active) return undefined;
161
173
  if (t.from === me) return checkProbe({ ...local.probe() });
162
- const probeId = randomUUID(), k = [t.from, t.bridgeId, probeId].join("\n");
163
- const got = new Promise<Editor | undefined>((resolve) => {
164
- const timer = setTimeout(() => settle(), PROBE_MS), settle = (e?: Editor) => (clearTimeout(timer), probes.delete(k), resolve(e));
165
- probes.set(k, settle);
166
- });
174
+ const probeId = randomUUID(), k = [t.from, t.bridgeId, probeId].join("\n"), got = wait(probes, k, PROBE_MS);
167
175
  try {
168
176
  if (!owner()) throw new Error("not the owner");
169
177
  channel!.publish({ version: 1, type: "probe", to: t.bridgeId, probeId }, { audience: "capable", ownerOnly: true });
@@ -172,6 +180,27 @@ export function createHub(pi: ExtensionAPI, me: string, local: Bridge) {
172
180
  }
173
181
  return got;
174
182
  },
183
+ /** A listed document by its capability, as its bridge lists it now (a list that changed since the reading counts, a bridge that left does not). */
184
+ doc(cap) {
185
+ const at = caps.find(cap), b = at && bridges.get(at.from), doc = at && b?.bridgeId === at.bridgeId ? b.documents?.find((d) => d.id === at.id) : undefined;
186
+ return doc && { key: at!.key, doc };
187
+ },
188
+ /** A form answer for the document `cap`, to its bridge (or this session's), waiting up to 5 s for the owner's result. One answerId is sent once (docs.ts spends its nonce first). */
189
+ async form(cap, answerId, fields) {
190
+ const at = caps.find(cap), k = at && [at.from, at.bridgeId, at.id, answerId].join("\n");
191
+ if (!ANSWER_ID.test(answerId) || !fieldsOk(fields)) return { accepted: false, reason: "invalid" };
192
+ if (!at || !k || !active || !hub.doc(cap)) return { accepted: false, reason: "closed" };
193
+ const msg: FormMessage = { version: 1, type: "form", to: at.bridgeId, id: at.id, answerId, fields }, got = wait(forms, k, FORM_MS);
194
+ try {
195
+ if (at.from === me) local.form(msg, (r) => receive(me, r));
196
+ else if (owner()) channel!.publish(msg, { audience: "capable", ownerOnly: true });
197
+ else throw new Error("not the owner");
198
+ } catch {
199
+ forms.get(k)?.(); // unreachable: not confirmed
200
+ }
201
+ const r = await got;
202
+ return r && { accepted: r.accepted, ...(str(r.reason) ? { reason: r.reason } : {}) };
203
+ },
175
204
  };
176
205
  return hub;
177
206
  }
package/src/docs.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createReadStream, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { pipeline } from "node:stream";
5
+ import { dirname, extname, resolve, sep } from "node:path";
6
+ import type { Hub } from "./dialogs.ts";
7
+ import type { Fields } from "./documents.ts";
8
+
9
+ // Serving the documents a session shows (documents.ts) to the page, by capability: the hub's current binding is the allow list, and paths never
10
+ // come from the browser. GET /<t>/doc/<cap>/ is an HTML document, rewritten so its pi-answer forms submit here; GET /<t>/doc/<cap>/<path> an
11
+ // image, stylesheet, or font under its own directory; GET /<t>/doc/<cap>/answer/<nonce>?<fields> takes one form answer to the owner;
12
+ // GET /<t>/api/doc/<cap> a text document as JSON, which the page renders. Every /doc/ response carries a CSP `sandbox` without scripts, so a
13
+ // document (or an SVG opened on its own) runs no script and has an opaque origin: it cannot read the API or the cookie. The sources name the
14
+ // server's origin, because WebKit does not match 'self' in a sandboxed document; Chrome loads fonts there in CORS mode with Origin: null,
15
+ // so fonts allow any origin. Answers are never logged. A remote request reaches /doc/ with the Tailscale identity and the capability only:
16
+ // Chrome withholds the SameSite=Strict cookie from a sandboxed frame's requests (remote.ts).
17
+
18
+ const SIBLING: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".css": "text/css; charset=utf-8", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf" };
19
+ const MAX_DOC = 2 * 1024 * 1024, MAX_FILE = 8 * 1024 * 1024, NONCE_MS = 30 * 60_000, MAX_NONCES = 256, MAX_QUERY = 16 * 1024, MAX_ANSWER = 12 * 1024, CAP = /^[0-9a-f]{32}$/;
20
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/; // tab, newline, and CR (a textarea's line break) stay, as Chawan sends them
21
+ export const docCsp = (origin: string) => `sandbox allow-forms allow-popups allow-popups-to-escape-sandbox; default-src 'none'; img-src ${origin} data:; style-src ${origin} 'unsafe-inline'; font-src ${origin} data:; form-action ${origin}; base-uri 'none'; frame-ancestors ${origin}`;
22
+ const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
23
+
24
+ /** The document as served: other <base> tags dropped, links opening in a new tab, each `cgi-bin:pi-answer` form posting (GET) to this page's nonce,
25
+ * and what could undo that dropped: a referrer policy of its own (the URL holds the token) and the buttons' formaction, formmethod, formtarget.
26
+ * Regexes over HTML text are a best effort (an entity or a quoted `>` gets past them); the sandbox and the CSP are what hold. */
27
+ export function rewrite(html: string, nonce: string) {
28
+ const forms = html.replace(/<base\b[^>]*>|<meta\b[^>]*\bname\s*=\s*["']?referrer\b[^>]*>/gi, "").replace(/<[a-z][^>]*>/gi, (tag) => tag.replace(/\s(?:referrerpolicy|formaction|formmethod|formtarget)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, "")).replace(/<form\b[^>]*>/gi, (tag) => (/\saction\s*=\s*(?:"\s*cgi-bin:pi-answer\s*"|'\s*cgi-bin:pi-answer\s*'|cgi-bin:pi-answer(?=[\s>/]))/i.test(tag) ? tag.replace(/\s(?:action|method|target)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, "").replace(/^<form/i, `<form action="answer/${nonce}" method="get" target="_self"`) : tag));
29
+ const at = /<head\b[^>]*>/i.exec(forms) ?? /<html\b[^>]*>/i.exec(forms) ?? /<!doctype[^>]*>/i.exec(forms), cut = at ? at.index + at[0].length : 0;
30
+ return `${forms.slice(0, cut)}<base target="_blank">${forms.slice(cut)}`;
31
+ }
32
+ /** A form's fields as the answer route takes them: at most 64, the query at most 16 KB and the answer 12 KB, no control characters. */
33
+ export function fieldsOf(query: string): Fields | string {
34
+ if (query.length > MAX_QUERY) return "The answers are longer than 16 KB.";
35
+ const fields = [...new URLSearchParams(query)];
36
+ if (fields.length > 64) return "The form has more than 64 fields.";
37
+ if (fields.some(([name, value]) => CONTROL.test(name) || CONTROL.test(value))) return "The answers hold control characters.";
38
+ return Buffer.byteLength(JSON.stringify(fields)) > MAX_ANSWER ? "The answers are longer than 12 KB together." : fields;
39
+ }
40
+
41
+ export function createDocs(hub: Pick<Hub, "doc" | "form">, name: (key: string) => string) {
42
+ const nonces = new Map<string, { cap: string; until: number }>();
43
+ /** A file the document may use: the listed one itself, or one under its directory, as its real path, within `max` bytes. */
44
+ const file = (path: string, max: number, under?: string) => {
45
+ try {
46
+ const real = realpathSync(path), root = under && realpathSync(under);
47
+ return (root ? real.startsWith(root + sep) : real === path) && statSync(real).isFile() && statSync(real).size <= max ? real : undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ };
52
+ return {
53
+ /** `parts` is the path after doc/; `origin` the page's own origin, which the document's policy names. */
54
+ async serve(req: IncomingMessage, res: ServerResponse, [cap = "", ...rest]: string[], origin: string) {
55
+ const h = req.headers, base = { "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", "content-security-policy": docCsp(origin) };
56
+ const page = (status: number, title: string, text: string) => res.writeHead(status, { ...base, "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><style>body{margin:0;padding:24px;background:#0b1222;color:#dbe6ff;font:15px/1.5 system-ui,sans-serif}h1{margin:0 0 6px;font-size:17px;color:#ffd27a}p{margin:0;color:#8b99ba}</style><h1>${escape(title)}</h1><p>${escape(text)}</p>`);
57
+ const found = req.method === "GET" && CAP.test(cap) ? hub.doc(cap) : undefined;
58
+ if (!found) return void page(404, "Not found", "This document is not open in the net any more.");
59
+ const { doc, key } = found, dest = h["sec-fetch-dest"], site = h["sec-fetch-site"];
60
+ if (!rest.length) return void res.writeHead(308, { ...base, location: `${cap}/` }).end();
61
+ if (rest.length === 1 && rest[0] === "") {
62
+ // The document itself: a navigation of the page's frame or tab (Sec-Fetch-*, when the browser sends them), never another site's.
63
+ const real = doc.kind === "html" ? file(doc.path, MAX_DOC) : undefined;
64
+ if (!real || (dest && dest !== "iframe" && dest !== "document")) return void page(404, "Not found", "This document cannot be shown here.");
65
+ // From the document's own frame (WebKit's Back after an answer, a link to itself), never with a fresh nonce: a form is never sent twice.
66
+ if (site && site !== "same-origin" && site !== "none") return void page(409, "Reload the document", "Use Reload on its tile, or open it again, to see it and send its form again.");
67
+ const nonce = randomBytes(16).toString("hex"), now = Date.now();
68
+ for (const [n, v] of nonces) if (v.until < now || nonces.size >= MAX_NONCES) nonces.delete(n);
69
+ nonces.set(nonce, { cap, until: now + NONCE_MS });
70
+ // Back and Forward show this copy again (its nonce is spent once sent); a reload fetches a fresh one.
71
+ return void res.writeHead(200, { ...base, "cache-control": "private, no-cache", "content-type": "text/html; charset=utf-8" }).end(rewrite(readFileSync(real, "utf8"), nonce));
72
+ }
73
+ if (rest[0] === "answer" && rest.length === 2) {
74
+ const n = nonces.get(rest[1]!), url = req.url ?? "", query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
75
+ if ((h["sec-fetch-mode"] && h["sec-fetch-mode"] !== "navigate") || (dest && dest !== "iframe" && dest !== "document")) return void page(403, "Not sent", "A form answer comes only from the document's own form.");
76
+ nonces.delete(rest[1]!); // one answer per served page, whatever happens next
77
+ if (!n || n.cap !== cap) return void page(409, "Already sent", "This page's answer was already sent. Reload the document to send again.");
78
+ if (n.until < Date.now()) return void page(409, "Expired", "This page was served more than 30 minutes ago. Reload the document to send again.");
79
+ const fields = fieldsOf(query);
80
+ if (typeof fields === "string") return void page(400, "Not sent", fields);
81
+ const r = await hub.form(cap, rest[1]!, fields);
82
+ if (!r) return void page(504, "Not confirmed", "Check the terminal: the session did not confirm the answer, so it may or may not have arrived.");
83
+ if (r.accepted) return void page(200, `Sent to ${name(key)}`, "The agent has your answers.");
84
+ return void page(409, "Not sent", r.reason === "closed" ? "The document is no longer open in its session." : r.reason === "empty" ? "The form has no fields to send." : `The session refused it (${r.reason ?? "no reason"}).`);
85
+ }
86
+ let rel: string;
87
+ try {
88
+ rel = rest.map(decodeURIComponent).join("/");
89
+ } catch {
90
+ return void page(404, "Not found", "No such file.");
91
+ }
92
+ const type = SIBLING[extname(rel).toLowerCase()], real = doc.kind === "html" && type && !rel.includes("\0") ? file(resolve(dirname(doc.path), rel), MAX_FILE, dirname(doc.path)) : undefined;
93
+ if (!real) return void page(404, "Not found", "No such file beside the document.");
94
+ res.writeHead(200, { ...base, "content-type": type!, ...(type!.startsWith("font/") ? { "access-control-allow-origin": "*" } : {}) });
95
+ pipeline(createReadStream(real), res, () => {}); // a file that went meanwhile, or a reader that left, closes both ends
96
+ },
97
+ /** GET /<t>/api/doc/<cap>: a text document for the page's renderer. */
98
+ text(cap: string): { status: number; body: unknown } {
99
+ const found = CAP.test(cap) ? hub.doc(cap) : undefined, real = found?.doc.kind === "text" ? file(found.doc.path, MAX_DOC) : undefined;
100
+ return real ? { status: 200, body: { kind: "text", display: found!.doc.display, revision: found!.doc.revision, text: readFileSync(real, "utf8") } } : { status: 404, body: { error: "not_found", reason: "This document is not open in the net any more." } };
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,76 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { Bus } from "./owners.ts";
3
+
4
+ // Document contract v1 (pi-herdr-doc-review ≥ 0.4.0, its README's "Browser viewers"): the package that shows a session's documents (its
5
+ // owner) publishes the full list on pi's event bus at every change and delivers form answers itself, exactly as a Chawan submission. The
6
+ // bridge (bridge.ts) relays the list with its dialogs; the hub (dialogs.ts) binds it to the unit with the dialog binding rule and gives each
7
+ // document a capability, 128 random bits per server; the page gets the capability and never a path; the server serves by it (docs.ts).
8
+
9
+ export const LIST = "doc-review:documents", REQUEST = "doc-review:documents-request", FORM = "doc-review:form", RESULT = "doc-review:form-result";
10
+ const MAX_DOCS = 32, FORM_MS = 4000;
11
+ export const MAX_FIELDS = 64;
12
+ export interface Doc { id: string; path: string; kind: "html" | "text"; display: string; reason: string; revision: number; onScreen: boolean }
13
+ /** A document as the page sees it: its capability instead of its path and owner id. */
14
+ export interface DocCard { cap: string; display: string; kind: "html" | "text"; reason: string; revision: number; onScreen: boolean }
15
+ /** A unit's documents in the snapshot; `docsCut` when its list was too long to relay (they stay in the terminal). */
16
+ export interface Shelf { docs?: DocCard[]; docsCut?: true }
17
+ export interface FormResult { accepted: boolean; reason?: string }
18
+ export type Fields = [string, string][];
19
+
20
+ const str = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max;
21
+ /** One entry; its display and reason are cut to 1024 and 2048 characters (show_doc's reason has no limit), anything else out of shape refuses it. */
22
+ const docOf = (d: Record<string, unknown> | undefined): Doc | undefined => {
23
+ if (!str(d?.id, 64) || !str(d.path, 4096) || !d.path.startsWith("/") || (d.kind !== "html" && d.kind !== "text") || typeof d.display !== "string" || typeof d.reason !== "string" || !Number.isSafeInteger(d.revision) || (d.revision as number) < 1 || typeof d.onScreen !== "boolean") return;
24
+ return { id: d.id, path: d.path, kind: d.kind, display: d.display.slice(0, 1024), reason: d.reason.slice(0, 2048), revision: d.revision as number, onScreen: d.onScreen };
25
+ };
26
+ /** A contract-v1 list, or undefined (another version or shape: a relay never guesses at one). Ids are unique. */
27
+ export function docsOf(raw: unknown): Doc[] | undefined {
28
+ const p = raw as { version?: unknown; documents?: unknown };
29
+ if (p?.version !== 1 || !Array.isArray(p.documents) || p.documents.length > MAX_DOCS) return;
30
+ const docs = p.documents.map(docOf);
31
+ return docs.every(Boolean) && new Set(docs.map((d) => d!.id)).size === docs.length ? (docs as Doc[]) : undefined;
32
+ }
33
+ export const fieldsOk = (f: unknown): f is Fields => Array.isArray(f) && f.length <= MAX_FIELDS && f.every((x) => Array.isArray(x) && x.length === 2 && x.every((s) => typeof s === "string"));
34
+
35
+ /** The owner's list as it changes. A v1 list out of shape counts as none, so an older list never stays; another version is ignored. A viewer that starts late asks for the list once. */
36
+ export function listenDocs(bus: Bus, list: (docs: Doc[]) => void): () => void {
37
+ const off = bus.on(LIST, (raw) => (raw as { version?: unknown })?.version === 1 && list(docsOf(raw) ?? []));
38
+ bus.emit(REQUEST, { version: 1 });
39
+ return off;
40
+ }
41
+ /** Hands one form answer to the owner and reports its result once (nothing when the owner stays silent for 4 s). */
42
+ export function submitForm(bus: Bus, id: string, answerId: string, fields: Fields, done: (r: FormResult) => void) {
43
+ let finished = false;
44
+ const finish = (r?: FormResult) => (finished || ((finished = true), off(), clearTimeout(timer), r && done(r)));
45
+ const off = bus.on(RESULT, (raw) => {
46
+ const r = raw as { version?: unknown; id?: unknown; answerId?: unknown; accepted?: unknown; reason?: unknown };
47
+ if (r?.version === 1 && r.id === id && r.answerId === answerId && typeof r.accepted === "boolean") finish({ accepted: r.accepted, ...(str(r.reason, 512) ? { reason: r.reason } : {}) });
48
+ });
49
+ const timer = setTimeout(finish, FORM_MS);
50
+ timer.unref?.();
51
+ bus.emit(FORM, { version: 1, id, answerId, fields });
52
+ }
53
+
54
+ /** The hub's capabilities: one per (session, bridge, document) while its bridge lists that document, new for every server (`clear`). A list cut
55
+ * for size lists nothing meanwhile, so its documents get new capabilities when they fit again. */
56
+ export function createCaps() {
57
+ let caps = new Map<string, string>(), held = new Map<string, { key: string; from: string; bridgeId: string; id: string }>();
58
+ return {
59
+ /** One reading: each bound unit's lists by key; `listed` holds `from\nbridgeId\nid` of every relayed document, and a capability not in it goes. */
60
+ bind(units: { key: string; from: string; bridgeId: string; docs: Doc[] }[], listed: Set<string>): Map<string, DocCard[]> {
61
+ const cards = new Map<string, DocCard[]>();
62
+ for (const k of caps.keys()) if (!listed.has(k)) caps.delete(k);
63
+ held = new Map();
64
+ for (const { key, from, bridgeId, docs } of units)
65
+ cards.set(key, docs.map(({ id, kind, display, reason, revision, onScreen }) => {
66
+ const k = `${from}\n${bridgeId}\n${id}`, cap = caps.get(k) ?? randomBytes(16).toString("hex");
67
+ caps.set(k, cap);
68
+ held.set(cap, { key, from, bridgeId, id });
69
+ return { cap, display, kind, reason, revision, onScreen };
70
+ }));
71
+ return cards;
72
+ },
73
+ find: (cap: string) => held.get(cap),
74
+ clear: () => ((caps = new Map()), (held = new Map())),
75
+ };
76
+ }
package/src/gui.ts CHANGED
@@ -9,6 +9,7 @@ import { waitsForSecret } from "./ask.ts";
9
9
  import { createControl, type Reply } from "./control.ts";
10
10
  import type { HerdrCall } from "./herdr.ts";
11
11
  import type { Hub } from "./dialogs.ts";
12
+ import { createDocs } from "./docs.ts";
12
13
  import { canonical, readers } from "./history.ts";
13
14
  import { createMirrors } from "./mirror.ts";
14
15
  import type { Found, Net, Ref } from "./net.ts";
@@ -32,7 +33,8 @@ const HEADERS = {
32
33
  "cache-control": "no-store",
33
34
  "referrer-policy": "no-referrer",
34
35
  "x-content-type-options": "nosniff",
35
- "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
36
+ "cross-origin-opener-policy": "same-origin", // a page a document's link opens cannot steer this tab
37
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; base-uri 'self'; frame-ancestors 'none'",
36
38
  };
37
39
 
38
40
  export interface Gui { url: string; port: number; token: string; csrf: string; access: Access; stop(): Promise<void> }
@@ -52,7 +54,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, port = 0, t
52
54
  // Turning remote access off ends the streams of remote pages; their next request finds the cookie void.
53
55
  const clients = new Map<ServerResponse, Watch | undefined>(), remotes = new Set<ServerResponse>(), access = createAccess(token, () => remotes.forEach((c) => c.end()));
54
56
  const pair = (code = access.pair(), host = access.remote?.host, url = `https://${host}/${token}/pair/${code}`) => (code && host ? { url, rows: qr(url).map((row) => row.map(Number).join("")) } : undefined);
55
- const mirrors = createMirrors({ call, lookup: (key) => net?.lookup(key), secret: waitsForSecret, canonical });
57
+ const mirrors = createMirrors({ call, lookup: (key) => net?.lookup(key), secret: waitsForSecret, canonical }), docs = hub && createDocs(hub, (key) => net?.lookup(key)?.name ?? key);
56
58
  const control = createControl({ lookup: (key) => net?.lookup(key), call, csrf, canonical, pair, mirrors, secret: waitsForSecret, answers: hub && { replay: hub.replay, ended: hub.ended, send: hub.send, probe: hub.probe, target: async (key) => (await tick(), hub.target(key)) } });
57
59
  let last: string | undefined, problem: string | undefined, inflight: Promise<void> | undefined;
58
60
  let poll: ReturnType<typeof setInterval> | undefined, beat: ReturnType<typeof setInterval> | undefined;
@@ -110,6 +112,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, port = 0, t
110
112
  if ("status" in who) return void (who.status === 403 ? fail(403) : res.writeHead(401, { ...HEADERS, "content-type": "text/plain" }).end("This device is not paired: scan a new QR code from /boss gui remote."));
111
113
  // A pairing code was just exchanged: the cookie is set, and the page reloads itself, so the cookie also rides on a same-site navigation.
112
114
  if ("cookie" in who) return void res.writeHead(200, { ...HEADERS, "content-type": TYPES[".html"], "set-cookie": cookieFor(token, who.cookie) }).end(`<!doctype html><meta http-equiv="refresh" content="0;url=/${token}/"><p>Paired. Opening the straw net…</p>`);
115
+ if (path[0] === "doc") return void (docs ? docs.serve(req, res, path.slice(1), who.origin).catch(() => res.destroy()) : fail(404)); // the documents sessions show (docs.ts)
113
116
  if (path[0] === "api") {
114
117
  let parts: string[];
115
118
  try {
@@ -118,7 +121,7 @@ export async function startGui({ read, net, call, hub, dir = BUNDLE, port = 0, t
118
121
  return void fail(404);
119
122
  }
120
123
  if (parts[0] === "sessions" || parts[0] === "remote") return void control(req, who.origin, parts).then(answer, () => answer({ status: 500, body: { error: "internal", reason: "The request could not be handled." } }));
121
- if (req.method === "GET") return void api(files, parts, url.searchParams).then(answer, () => fail(404));
124
+ if (req.method === "GET") return void (parts[0] === "doc" && docs ? answer(docs.text(parts[1] ?? "")) : api(files, parts, url.searchParams).then(answer, () => fail(404)));
122
125
  }
123
126
  if (req.method !== "GET") return void fail(403);
124
127
  // The page loads its assets and the stream relative to its own URL, so that URL must end with a slash.
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
@@ -10,6 +11,7 @@ import { createHub, type Hub } from "./dialogs.ts";
10
11
  import { answers, type Gui, openBrowser, readRecord, sharedGui, startGui, urlOf } from "./gui.ts";
11
12
  import { netSource } from "./net.ts";
12
13
  import { clearOrder, markKids, netTokens } from "./order.ts";
14
+ import { orphans } from "./orphan.ts";
13
15
  import { copyText, pairingKeys, showLines } from "./pairing.ts";
14
16
  import { type Run, switchRemote, tailscale } from "./remote.ts";
15
17
  import { ensureTower, headOf, towerModel, herdrApi, type HerdrApi, herdrCall, type HerdrCall, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
@@ -22,6 +24,8 @@ const NAMESPACE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
22
24
  const ENTRY = "straw-boss";
23
25
  const PROMPTS = ["main", "tower", "member", "member-no-main"];
24
26
  const REFRESH_MS = 4 * 60_000;
27
+ const KID_MARK_MS = [3_000, 15_000];
28
+ const NUDGE_MS = 15_000;
25
29
  const CLAIM_MS = 5000;
26
30
  const CONNECT_MS = 15_000;
27
31
  const SEND_MS = 30_000;
@@ -38,7 +42,7 @@ interface Saved { tower?: boolean }
38
42
  type Role = "tower" | "main" | "member";
39
43
  type Scope = "tab" | "workspace" | "towers" | "all";
40
44
  type Member = ReturnType<typeof netMembers>[number];
41
- export interface Deps { run?: HerdrRun; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string; autoTower?: boolean }
45
+ export interface Deps { run?: HerdrRun; api?: HerdrApi; call?: HerdrCall; tailscale?: Run; copy?: (text: string) => Promise<void>; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string; autoTower?: boolean; nudgeMs?: number }
42
46
 
43
47
  /** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
44
48
  function netMembers(sessions: Session[], ps: Pane[]) {
@@ -50,7 +54,7 @@ function netMembers(sessions: Session[], ps: Pane[]) {
50
54
  }
51
55
  const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
52
56
 
53
- export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir, autoTower = true }: Deps = {}) {
57
+ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdrCall(), tailscale: ts = tailscale, copy = copyText, env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir, autoTower = true, nudgeMs = NUDGE_MS }: Deps = {}) {
54
58
  return function strawBoss(pi: ExtensionAPI): void {
55
59
  // Outside Herdr there is no tab to coordinate. Every session in Herdr relays its dialogs to the GUI (bridge.ts), subagents too: they ask
56
60
  // questions as well. A pi-herdr-agents worker never joins: its parent represents it.
@@ -61,6 +65,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
61
65
  if (env.PI_SUBAGENT_ID) return;
62
66
  let isTower = env.STRAW_BOSS_ROLE === "tower";
63
67
  let joined = false, isMain = false, holdsTower = false, requests = 0;
68
+ const nonce = randomUUID().slice(0, 8);
64
69
  // The tab's live main agent as of the last refresh, and the main this session pinned as owner before the broker echoes it back.
65
70
  let mainId: string | undefined, pinned: string | undefined, joining: Promise<void> | undefined;
66
71
  // Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
@@ -71,6 +76,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
71
76
  let queue: Promise<void> = Promise.resolve();
72
77
  // This session's intercom name, which the subagents it launched show as their launcher, and the subagent panes it has marked.
73
78
  let ownName = "", kids = new Set<string>();
79
+ // Watches for subagents this session launched whose result can no longer reach it (orphan.ts).
80
+ let nudges: ReturnType<typeof orphans> | undefined, nudgeTimer: ReturnType<typeof setInterval> | undefined;
74
81
  const me = () => env.PI_INTERCOM_SESSION_ID ?? "";
75
82
  const role = (): Role | undefined => {
76
83
  if (isTower) return "tower";
@@ -219,7 +226,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
219
226
  };
220
227
  const send = (to: string, message: string) =>
221
228
  new Promise<SendResult>((resolve) => {
222
- const requestId = `straw-boss-${me()}-${++requests}`;
229
+ // pi-intercom keeps a message id per sender session and refuses it again with other content, and a restarted pi keeps its session id: the id carries this process's own nonce.
230
+ const requestId = `straw-boss-${me()}-${nonce}-${++requests}`;
223
231
  const done = (result: SendResult) => {
224
232
  off();
225
233
  clearTimeout(timeout);
@@ -356,6 +364,11 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
356
364
  prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
357
365
  saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
358
366
  isTower ||= Boolean(saved.tower);
367
+ const log = ctx.sessionManager;
368
+ clearInterval(nudgeTimer);
369
+ nudges?.stop();
370
+ nudges = orphans({ run, file: () => log.getSessionFile(), entries: () => log.getEntries(), sessions, send, type: ENTRY, remember: (key) => pi.appendEntry(ENTRY, { nudged: key }), me, tell });
371
+ nudgeTimer = setInterval(() => void nudges?.poll().catch(() => {}), nudgeMs).unref();
359
372
  // Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
360
373
  let starting: Promise<void> | undefined;
361
374
  if (isTower) {
@@ -390,6 +403,11 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
390
403
  }
391
404
  });
392
405
 
406
+ // A launched subagent is marked within seconds, once its pane shows its session file, instead of at the next 4 minute refresh.
407
+ pi.on("tool_result", (event) => {
408
+ if (["subagent", "subagent_resume"].includes(event.toolName) && !isTower && !barred && role()) for (const ms of KID_MARK_MS) setTimeout(() => void publish(), ms).unref();
409
+ });
410
+
393
411
  pi.on("session_shutdown", async () => {
394
412
  if (gui) await switchRemote(gui, false, guiDir, ts).catch(() => {});
395
413
  await gui?.stop();
@@ -398,6 +416,8 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), call = herdr
398
416
  dashboard = undefined;
399
417
  clearInterval(timer);
400
418
  timer = undefined;
419
+ clearInterval(nudgeTimer);
420
+ nudges?.stop();
401
421
  stopped = true; // a refresh still in flight must not write tokens after they are cleared
402
422
  await publishing;
403
423
  const held = holdsTower;
package/src/net.ts CHANGED
@@ -3,16 +3,17 @@ import { basename, join, sep } from "node:path";
3
3
  import { type Mark, markOf, type Row, sessionMark, subagents } from "./dashboard.ts";
4
4
  import { headOf, type HerdrRun, isSub, type Pane, panes, parentOf, type Session, workspaces, type Workspace } from "./herdr.ts";
5
5
  import { type Pending, type Relayed, withPending } from "./ask.ts";
6
+ import type { Shelf } from "./documents.ts";
6
7
  import { canonical } from "./history.ts";
7
8
 
8
9
  // The whole straw net as one JSON-able snapshot for the browser view: every workspace as a base with its tower, tabs, agents,
9
10
  // and subagents (linked to their launcher the way the tower dashboard links them), and sub workspaces as outposts.
10
11
 
11
12
  /** `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). */
12
- export interface Unit { id: string; key: string; control: boolean; name: string; role: "main" | "member" | "subagent" | "outside"; mark: Mark; note?: string; subs: Unit[]; pending?: Pending }
13
+ export interface Unit extends Shelf { id: string; key: string; control: boolean; name: string; role: "main" | "member" | "subagent" | "outside"; mark: Mark; note?: string; subs: Unit[]; pending?: Pending }
13
14
  export interface Tab { id: string; label: string; units: Unit[] }
14
15
  export interface Outpost { id: string; label: string; mark: Mark }
15
- export interface Base { id: string; label: string; mark: Mark; head: boolean; tower: { name: string; mark: Mark; key: string; control: boolean; pending?: Pending } | null; tabs: Tab[]; outposts: Outpost[]; outside: Unit[] }
16
+ export interface Base { id: string; label: string; mark: Mark; head: boolean; tower: ({ name: string; mark: Mark; key: string; control: boolean; pending?: Pending } & Shelf) | null; tabs: Tab[]; outposts: Outpost[]; outside: Unit[] }
16
17
  export interface Net { bases: Base[] }
17
18
  /** What the server may read for a key: the registry entry is the allow list, and paths never come from the browser. */
18
19
  export interface Ref { key: string; base: string; name: string; role: string; file?: string; pane?: string }
package/src/orphan.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { artifacts, ownFile, readJson } from "./dashboard.ts";
4
+ import { type HerdrRun, panes, type Session } from "./herdr.ts";
5
+
6
+ // A subagent that has finished, while the session that launched it can no longer receive its result, is asked once to report over intercom.
7
+ //
8
+ // pi-herdr-agents delivers a child's result from the launcher's own process: its watchers, and its registry of launched children (`runningSubagents`, kept
9
+ // in the global `Symbol.for("pi-subagents/runtime")` slot so that a reloaded module adopts the map), live in that process's memory. /reload, /new, /resume
10
+ // and /fork keep them. Quitting pi suppresses the delivery, and a crash or restart drops them for good: a restarted pi does not adopt the children it launched
11
+ // before ("V1 does not automatically recover watchers after a full process restart", docs/tools.md). So a child of this session that the registry no longer
12
+ // holds, and whose result is not in the transcript, can no longer be delivered. A registry that cannot be read proves nothing, so nothing is sent then.
13
+
14
+ export interface Entry { type: string; customType?: string; data?: unknown; details?: unknown }
15
+ export interface Orphans {
16
+ run: HerdrRun;
17
+ /** This session's file, and every entry of its transcript. */
18
+ file(): string | undefined;
19
+ entries(): Entry[];
20
+ sessions(): Promise<Session[]>;
21
+ send(to: string, message: string): Promise<{ status: string; code?: string; detail?: string }>;
22
+ /** The session entry type that records a nudge, and the write of one. */
23
+ type: string;
24
+ remember(key: string): void;
25
+ me(): string;
26
+ tell(text: string, level?: "info" | "warning"): void;
27
+ }
28
+
29
+ const REGISTRY = Symbol.for("pi-subagents/runtime");
30
+ // The messages that carry a finished child's result into the launcher's transcript; their details name the child's session file.
31
+ const FINAL = ["subagent_result", "subagent_ping"];
32
+ const GIVE_UP = 4;
33
+
34
+ /** The ids of the runs this process's pi-herdr-agents still tracks, or undefined when the registry cannot be read. */
35
+ export function tracked(): Set<string> | undefined {
36
+ const map = (globalThis as Record<symbol, { runningSubagents?: unknown } | undefined>)[REGISTRY]?.runningSubagents;
37
+ return map instanceof Map ? new Set(map.keys() as Iterable<string>) : undefined;
38
+ }
39
+
40
+ export function orphans(d: Orphans) {
41
+ const nudged = new Set(d.entries().flatMap((e) => (e.type === "custom" && e.customType === d.type && typeof (e.data as { nudged?: unknown } | undefined)?.nudged === "string" ? [(e.data as { nudged: string }).nudged] : [])));
42
+ // A child counts as a miss when its pane (running its own launch script) or its intercom session cannot be found; after GIVE_UP misses it is left alone.
43
+ const misses = new Map<string, number>();
44
+ let before = new Set<string>(), busy = false, stopped = false;
45
+ const miss = (id: string) => void misses.set(id, (misses.get(id) ?? 0) + 1);
46
+ /**
47
+ * Whether the foreground process of `pane` runs the launch script this session wrote for run `id`. A pane that only reuses the run's session file
48
+ * (a run another session resumed, or a pi someone started on that file) is not the run, and the run's activity file says nothing about it.
49
+ */
50
+ async function runs(pane: string, id: string, session: string): Promise<boolean> {
51
+ const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), script = new RegExp(`/artifacts/${quote(session)}/subagent-scripts/[^/]*-${quote(id)}\\.sh$`);
52
+ try {
53
+ const info = JSON.parse(await d.run(["pane", "process-info", "--pane", pane])).result.process_info.foreground_processes as { argv?: string[] }[];
54
+ return info.some((p) => p.argv?.some((arg) => script.test(arg)));
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ async function poll(): Promise<void> {
61
+ if (busy || stopped) return;
62
+ const file = d.file(), live = tracked();
63
+ // Without its own session id, a session file, or a readable registry there is nothing to say or to judge by.
64
+ if (!file || !live || !d.me()) return void (before = new Set());
65
+ busy = true;
66
+ try {
67
+ const dir = join(artifacts(file), "subagent-activity");
68
+ let ids: string[] = [];
69
+ try {
70
+ ids = readdirSync(dir).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5));
71
+ } catch {} // no subagent was ever launched from this session
72
+ ids = ids.filter((id) => !live.has(id) && !nudged.has(id) && (misses.get(id) ?? 0) < GIVE_UP);
73
+ const reported = ids.length ? d.entries().flatMap((e) => (e.type === "custom_message" && FINAL.includes(e.customType ?? "") ? [String((e.details as { sessionFile?: unknown } | undefined)?.sessionFile ?? "")] : [])) : [];
74
+ const open = ids.filter((id) => !reported.some((f) => ownFile(f, id)));
75
+ // Only a child whose own activity says it ended its turn and waits is looked up, and the broker has to list this session under its own
76
+ // id first (the report is asked for there): that is cheaper than Herdr.
77
+ const waiting = open.filter((id) => readJson(join(dir, `${id}.json`)).phase === "waiting");
78
+ const list = waiting.length ? await d.sessions().catch(() => undefined) : undefined, self = list?.find((s) => s.id === d.me());
79
+ const ps = self ? await panes(d.run) : [];
80
+ const found: { id: string; pane: string; name: string }[] = [];
81
+ for (const id of self ? waiting : []) {
82
+ const pane = ps.find((p) => p.agent === "pi" && ownFile(p.agent_session?.value, id));
83
+ if (!pane) miss(id);
84
+ // Working or asking a question is not finished; a pane someone has looked at is idle, not done.
85
+ else if (["done", "idle"].includes(pane.agent_status ?? "")) found.push({ id, pane: pane.pane_id, name: pane.label ?? id.slice(0, 8) });
86
+ }
87
+ // A child has to be found finished twice in a row, so that a delivery that is just going out is not mistaken for a lost one.
88
+ const ripe = found.filter((c) => before.has(c.id));
89
+ before = new Set(found.map((c) => c.id));
90
+ if (!list || !self) return;
91
+ for (const c of ripe) {
92
+ const kid = list.find((s) => s.herdrLocation?.paneId === c.pane && s.id !== self.id);
93
+ if (!kid) {
94
+ miss(c.id);
95
+ continue;
96
+ }
97
+ // Its own intercom status says it is idle; a child that went back to work is not asked.
98
+ if (kid.status !== "idle" || stopped) continue;
99
+ const own = await runs(c.pane, c.id, basename(artifacts(file)));
100
+ if (!own) miss(c.id);
101
+ // The session may have ended while Herdr was asked.
102
+ if (!own || stopped) continue;
103
+ // Written down before it is sent: a nudge is sent at most once per run, even when the send fails or this process ends meanwhile.
104
+ nudged.add(c.id);
105
+ d.remember(c.id);
106
+ const result = await d.send(kid.id, `Straw Boss: the session that launched you ("${self.name ?? self.id}", intercom session ${self.id}) can no longer receive your result by itself; for example, its pi was restarted after it launched you. Send your final report to that session now, with the intercom tool (action "send", to "${self.id}"): the outcome, what changed, and the evidence. Then stop; start no new work. This is sent once and needs no reply.`);
107
+ if (result.status === "sent") d.tell(`Straw Boss: asked ${c.name} to send its report here over intercom, because its result could no longer reach this session`);
108
+ else d.tell(`Straw Boss: could not ask ${c.name} to send its report: ${result.status} ${result.code ?? ""} ${result.detail ?? ""}`.trim(), "warning");
109
+ }
110
+ } finally {
111
+ busy = false;
112
+ }
113
+ }
114
+ return { poll, stop: () => void (stopped = true) };
115
+ }