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/README.md +2 -1
- package/dist/gui/assets/chat-C9mKEHEC.js +2 -0
- package/dist/gui/assets/index-DoXkXGW_.css +1 -0
- package/dist/gui/assets/index-F5nF0TfD.js +1 -0
- package/dist/gui/assets/{layer-DkIPqu42.js → layer-CEI0ft9k.js} +1 -1
- package/dist/gui/assets/views-D6rBFuay.js +8 -0
- package/dist/gui/index.html +2 -2
- package/package.json +1 -1
- package/src/ask.ts +4 -3
- package/src/blocked.ts +24 -1
- package/src/bridge.ts +27 -10
- package/src/dashboard.ts +6 -3
- package/src/dialogs.ts +47 -18
- package/src/docs.ts +103 -0
- package/src/documents.ts +76 -0
- package/src/gui.ts +6 -3
- package/src/index.ts +23 -3
- package/src/net.ts +3 -2
- package/src/orphan.ts +115 -0
- package/src/remote.ts +2 -2
- package/dist/gui/assets/chat-DAQHdiSd.js +0 -8
- package/dist/gui/assets/index-CHtZ727l.css +0 -1
- package/dist/gui/assets/index-CWA0eWOk.js +0 -1
- package/dist/gui/assets/views-CItUTayr.js +0 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/documents.ts
ADDED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
+
}
|