pi-straw-boss 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/gui/assets/chat-B8KUfo8d.js +7 -0
- package/dist/gui/assets/index-DWQG2ADm.js +1 -0
- package/dist/gui/assets/index-xkoHrvS5.css +1 -0
- package/dist/gui/assets/layer-D7Fuxmih.js +2 -0
- package/dist/gui/assets/views-Dh8gbtFc.js +2 -0
- package/dist/gui/index.html +23 -10
- package/package.json +2 -2
- package/prompts/member-no-main.md +1 -0
- package/prompts/member.md +1 -1
- package/src/dashboard.ts +6 -4
- package/src/gui.ts +41 -18
- package/src/history.ts +226 -0
- package/src/index.ts +100 -60
- package/src/items.ts +93 -0
- package/src/net.ts +62 -11
- package/src/watch.ts +90 -0
- package/dist/gui/assets/index-BP87OlaT.css +0 -1
- package/dist/gui/assets/index-CH3266oL.js +0 -1
- package/prompts/reminder.md +0 -1
package/src/history.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
+
import { type FileHandle, open, stat } from "node:fs/promises";
|
|
3
|
+
import { FULL_LIMIT, type Item, type Json, parse, toItem } from "./items.ts";
|
|
4
|
+
|
|
5
|
+
// The session reader: a pi session file in, pages of displayable items out, plus a live tail. Only an index stays in memory
|
|
6
|
+
// (type, id, parent, role, offset, length per entry); item content is read from the file by offset for the page being sent.
|
|
7
|
+
//
|
|
8
|
+
// Index: 1 MB chunks, yielding to the event loop between them. Per line only its first bytes (type, id, parentId, role come first
|
|
9
|
+
// in most entries) and its last bytes (custom entries end with id, parentId, timestamp) are kept and matched, so a 58 MB file costs
|
|
10
|
+
// tens of milliseconds. A line neither matches is read and parsed once. The leaf is the last entry in file order (pi's own load
|
|
11
|
+
// rule) and the view is the path from the leaf up through parentId, oldest first. A rewritten file (smaller, new inode, new header)
|
|
12
|
+
// is re-indexed from scratch; a new entry whose parent is not the current leaf is a branch move. Both reset the page.
|
|
13
|
+
// All index work and every read run one at a time, in order (`serial`): the first index ends before any append starts, and a page or
|
|
14
|
+
// entry is never read while the index is being rebuilt under it.
|
|
15
|
+
|
|
16
|
+
const CHUNK = 1 << 20, HEAD = 512, TAIL = 400, BIG = 16_384;
|
|
17
|
+
export const PAGE = 150;
|
|
18
|
+
const HIDDEN = new Set(["session", "custom", "label", "usage", "context_edit"]);
|
|
19
|
+
|
|
20
|
+
export interface Change { reset: boolean; items: Item[]; before?: string }
|
|
21
|
+
export interface Reader {
|
|
22
|
+
ready: Promise<void>;
|
|
23
|
+
header(): { id?: string; cwd?: string } | undefined;
|
|
24
|
+
unreadable(): number;
|
|
25
|
+
page(before?: string, limit?: number): Promise<{ items: Item[]; before?: string }>;
|
|
26
|
+
entry(id: string): Promise<Item | undefined>;
|
|
27
|
+
subscribe(fn: (change: Change) => void): () => void;
|
|
28
|
+
start(): void;
|
|
29
|
+
stop(): void;
|
|
30
|
+
}
|
|
31
|
+
interface Ent { type: string; role?: string; id: string; parent: string | null; off: number; len: number }
|
|
32
|
+
|
|
33
|
+
export function openReader(file: string, pollMs = 1000): Reader {
|
|
34
|
+
let ents: Ent[] = [], byId = new Map<string, number>(), path: number[] = [], bad = 0, size = 0, ino = -1, headText = "", head: Json | undefined;
|
|
35
|
+
let cur = { start: 0, len: 0, head: Buffer.alloc(0), tail: Buffer.alloc(0) };
|
|
36
|
+
const listeners = new Set<(c: Change) => void>();
|
|
37
|
+
let watcher: FSWatcher | undefined, timer: ReturnType<typeof setInterval> | undefined, chain: Promise<unknown> = Promise.resolve(), queued = false;
|
|
38
|
+
const serial = <T>(task: () => Promise<T>): Promise<T> => {
|
|
39
|
+
const run = chain.then(task);
|
|
40
|
+
chain = run.catch(() => {});
|
|
41
|
+
return run;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const grab = async (fh: FileHandle, off: number, len: number) => {
|
|
45
|
+
const buf = Buffer.allocUnsafe(len);
|
|
46
|
+
return buf.subarray(0, (await fh.read(buf, 0, len, off)).bytesRead);
|
|
47
|
+
};
|
|
48
|
+
/** A finished line: its entry, from the bytes at either end, else from the whole line read once. */
|
|
49
|
+
async function line(fh: FileHandle, start: number, len: number, h: string, t: string) {
|
|
50
|
+
const a = h.match(/^\{"type":"(\w+)","id":"([^"]+)","parentId":(null|"[^"]*")/) ?? t.match(/^()[^]*"id":"([^"]+)","parentId":(null|"[^"]*"),"timestamp":"[^"]*"\}$/) ?? undefined;
|
|
51
|
+
let type = a?.[1] || h.match(/^\{"type":"(\w+)"/)?.[1], id = a?.[2], parent = a?.[3];
|
|
52
|
+
if (type === "session") return void (headText ||= h);
|
|
53
|
+
if (!a || !type) {
|
|
54
|
+
const d = parse((await grab(fh, start, len)).toString("utf8"));
|
|
55
|
+
if (!d?.id) return void bad++;
|
|
56
|
+
[type, id, parent] = [d.type, d.id, d.parentId === null ? "null" : `"${d.parentId}"`];
|
|
57
|
+
}
|
|
58
|
+
const role = type === "message" ? h.match(/"message":\{"role":"(\w+)"/)?.[1] : undefined;
|
|
59
|
+
byId.set(id!, ents.push({ type: type!, role, id: id!, parent: parent === "null" ? null : parent!.slice(1, -1), off: start, len }) - 1);
|
|
60
|
+
}
|
|
61
|
+
/** Feeds file bytes starting at `at`; a line cut by the end of the data stays in `cur` until its newline arrives. */
|
|
62
|
+
async function feed(fh: FileHandle, chunk: Buffer, at: number) {
|
|
63
|
+
for (let i = 0; i < chunk.length; ) {
|
|
64
|
+
const nl = chunk.indexOf(10, i), seg = chunk.subarray(i, nl < 0 ? chunk.length : nl);
|
|
65
|
+
if (cur.head.length < HEAD) cur.head = Buffer.concat([cur.head, seg.subarray(0, HEAD - cur.head.length)]);
|
|
66
|
+
cur.tail = seg.length >= TAIL ? Buffer.from(seg.subarray(-TAIL)) : Buffer.concat([cur.tail, seg]).subarray(-TAIL);
|
|
67
|
+
cur.len += seg.length;
|
|
68
|
+
if (nl < 0) break;
|
|
69
|
+
if (cur.len) await line(fh, cur.start, cur.len, cur.head.toString("latin1"), cur.tail.toString("latin1"));
|
|
70
|
+
cur = { start: at + nl + 1, len: 0, head: Buffer.alloc(0), tail: Buffer.alloc(0) };
|
|
71
|
+
i = nl + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const pathTo = (leaf: number) => {
|
|
75
|
+
const out: number[] = [];
|
|
76
|
+
for (let i: number | undefined = leaf; i !== undefined && out.length <= ents.length; i = ents[i]!.parent === null ? undefined : byId.get(ents[i]!.parent!)) out.push(i);
|
|
77
|
+
return out.reverse();
|
|
78
|
+
};
|
|
79
|
+
async function scan(fh: FileHandle, to: number) {
|
|
80
|
+
for (let at = size; at < to; at += CHUNK) {
|
|
81
|
+
await feed(fh, await grab(fh, at, Math.min(CHUNK, to - at)), at);
|
|
82
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
83
|
+
}
|
|
84
|
+
size = to;
|
|
85
|
+
}
|
|
86
|
+
/** The items of the entries at these indexes, in order; hidden kinds are skipped without reading. */
|
|
87
|
+
async function load(idx: number[], max = Infinity) {
|
|
88
|
+
const items: Item[] = [];
|
|
89
|
+
if (!idx.length) return items;
|
|
90
|
+
const fh = await open(file, "r");
|
|
91
|
+
try {
|
|
92
|
+
for (const i of idx.toReversed()) {
|
|
93
|
+
const e = ents[i]!;
|
|
94
|
+
if (items.length >= max) break;
|
|
95
|
+
if (HIDDEN.has(e.type) || e.role === "system") continue;
|
|
96
|
+
const big = e.role === "toolResult" && e.len > BIG * 2;
|
|
97
|
+
const text = (await grab(fh, e.off, big ? BIG : e.len)).toString("utf8");
|
|
98
|
+
const d = big ? { type: "message", id: e.id, message: { role: "toolResult", toolCallId: text.match(/"toolCallId":"([^"]+)"/)?.[1], toolName: text.match(/"toolName":"([^"]+)"/)?.[1], content: [{ type: "text", text: parse(`"${(text.match(/"text":"((?:[^"\\]|\\.)*)/)?.[1] ?? "").replace(/\\(?:u[0-9a-fA-F]{0,3})?$/, "")}"`) ?? "" }], isError: (await grab(fh, e.off + e.len - 300, 300)).toString("utf8").includes('"isError":true') } } : parse(text);
|
|
99
|
+
const item = d && toItem(d, e.len);
|
|
100
|
+
if (item) items.unshift(item);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
await fh.close();
|
|
104
|
+
}
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
async function page(before?: string, limit = PAGE) {
|
|
108
|
+
const end = before === undefined ? path.length : path.findIndex((i) => ents[i]!.id === before);
|
|
109
|
+
const idx = path.slice(0, Math.max(0, end)), items = await load(idx, limit);
|
|
110
|
+
const oldest = items[0] && path.findIndex((i) => ents[i]!.id === items[0]!.id);
|
|
111
|
+
return { items, before: oldest && oldest > 0 ? items[0]!.id : undefined };
|
|
112
|
+
}
|
|
113
|
+
async function reindex() {
|
|
114
|
+
[ents, byId, path, bad, size, headText] = [[], new Map(), [], 0, 0, ""];
|
|
115
|
+
cur = { start: 0, len: 0, head: Buffer.alloc(0), tail: Buffer.alloc(0) };
|
|
116
|
+
const fh = await open(file, "r");
|
|
117
|
+
try {
|
|
118
|
+
const s = await fh.stat();
|
|
119
|
+
ino = s.ino;
|
|
120
|
+
await scan(fh, s.size);
|
|
121
|
+
head = headText ? parse((await grab(fh, 0, Math.min(s.size, 4096))).toString("utf8").split("\n")[0]!) : undefined;
|
|
122
|
+
} finally {
|
|
123
|
+
await fh.close();
|
|
124
|
+
}
|
|
125
|
+
path = ents.length ? pathTo(ents.length - 1) : [];
|
|
126
|
+
}
|
|
127
|
+
const emit = (change: Change) => listeners.forEach((fn) => fn(change));
|
|
128
|
+
/** New bytes were appended: new entries extend the path, or move the branch (a new parent) and reset the page. */
|
|
129
|
+
async function append(to: number) {
|
|
130
|
+
const n = ents.length, fh = await open(file, "r");
|
|
131
|
+
try {
|
|
132
|
+
await scan(fh, to);
|
|
133
|
+
} finally {
|
|
134
|
+
await fh.close();
|
|
135
|
+
}
|
|
136
|
+
const added = ents.slice(n), leaf = path.at(-1), from = path.length;
|
|
137
|
+
if (!added.length) return;
|
|
138
|
+
if (added.every((e, k) => e.parent === (k ? added[k - 1]!.id : leaf === undefined ? null : ents[leaf]!.id))) {
|
|
139
|
+
path.push(...added.map((_, k) => n + k));
|
|
140
|
+
return emit({ reset: false, items: await load(path.slice(from)) });
|
|
141
|
+
}
|
|
142
|
+
path = pathTo(ents.length - 1);
|
|
143
|
+
emit({ reset: true, ...(await page()) });
|
|
144
|
+
}
|
|
145
|
+
const headChanged = async () => {
|
|
146
|
+
const fh = await open(file, "r");
|
|
147
|
+
try {
|
|
148
|
+
return headText !== "" && (await grab(fh, 0, headText.length)).toString("latin1") !== headText;
|
|
149
|
+
} finally {
|
|
150
|
+
await fh.close();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/** One look at the file: a rewrite re-indexes, new bytes are appended. Reads look first, so they never use an index of a replaced file. */
|
|
154
|
+
async function look() {
|
|
155
|
+
try {
|
|
156
|
+
const s = await stat(file).catch(() => undefined);
|
|
157
|
+
if (!s) return; // a new session has no file until its first message: keep waiting for it
|
|
158
|
+
if (s.ino !== ino || s.size < size || (s.size > size && (await headChanged()))) {
|
|
159
|
+
await reindex();
|
|
160
|
+
emit({ reset: true, ...(await page()) });
|
|
161
|
+
} else if (s.size > size) await append(s.size);
|
|
162
|
+
} catch {} // a read that fails (the file vanished mid-read) is retried by the next poll
|
|
163
|
+
}
|
|
164
|
+
/** A look queued behind the work already running; changes that arrive meanwhile queue one more. */
|
|
165
|
+
function refresh() {
|
|
166
|
+
if (queued) return;
|
|
167
|
+
queued = true;
|
|
168
|
+
void serial(() => ((queued = false), look()));
|
|
169
|
+
}
|
|
170
|
+
const ready: Promise<void> = serial(reindex).catch(() => undefined);
|
|
171
|
+
return {
|
|
172
|
+
ready,
|
|
173
|
+
header: () => head && { id: head.id, cwd: head.cwd },
|
|
174
|
+
unreadable: () => bad,
|
|
175
|
+
page: (before, limit) => serial(async () => (await look(), page(before, limit))),
|
|
176
|
+
entry: (id) => serial(async () => {
|
|
177
|
+
await look();
|
|
178
|
+
const i = byId.get(id), e = i === undefined ? undefined : ents[i];
|
|
179
|
+
if (!e) return undefined;
|
|
180
|
+
const fh = await open(file, "r");
|
|
181
|
+
try {
|
|
182
|
+
const d = parse((await grab(fh, e.off, Math.min(e.len, 2 * FULL_LIMIT))).toString("utf8"));
|
|
183
|
+
return d && toItem(d, e.len, true);
|
|
184
|
+
} finally {
|
|
185
|
+
await fh.close();
|
|
186
|
+
}
|
|
187
|
+
}),
|
|
188
|
+
subscribe: (fn) => (listeners.add(fn), () => void listeners.delete(fn)),
|
|
189
|
+
start() {
|
|
190
|
+
if (timer) return;
|
|
191
|
+
timer = setInterval(() => void refresh(), pollMs).unref();
|
|
192
|
+
try {
|
|
193
|
+
watcher = watch(file, refresh);
|
|
194
|
+
watcher.unref().on("error", () => {});
|
|
195
|
+
} catch {} // no file yet: the poll finds it
|
|
196
|
+
refresh();
|
|
197
|
+
},
|
|
198
|
+
stop() {
|
|
199
|
+
clearInterval(timer);
|
|
200
|
+
timer = undefined;
|
|
201
|
+
watcher?.close();
|
|
202
|
+
watcher = undefined;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Readers for the files pages watch: one per file while watched, the last eight kept (index only, not watching) afterwards. */
|
|
208
|
+
export function readers(keep = 8, make = openReader) {
|
|
209
|
+
const all = new Map<string, { reader: Reader; users: number }>();
|
|
210
|
+
return {
|
|
211
|
+
acquire(file: string): Reader {
|
|
212
|
+
const held = all.get(file) ?? { reader: make(file), users: 0 };
|
|
213
|
+
held.users++;
|
|
214
|
+
all.delete(file);
|
|
215
|
+
all.set(file, held);
|
|
216
|
+
held.reader.start();
|
|
217
|
+
return held.reader;
|
|
218
|
+
},
|
|
219
|
+
release(file: string) {
|
|
220
|
+
const held = all.get(file);
|
|
221
|
+
if (!held || --held.users > 0) return;
|
|
222
|
+
held.reader.stop();
|
|
223
|
+
for (const [name, other] of all) if (all.size > keep && !other.users) all.delete(name);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
6
6
|
import { Type, type Static, type TSchema } from "typebox";
|
|
7
7
|
import { collect, startDashboard } from "./dashboard.ts";
|
|
8
8
|
import { answers, type Gui, openBrowser, readRecord, sharedGui, startGui } from "./gui.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { netSource } from "./net.ts";
|
|
10
10
|
import { ensureTower, headOf, herdrApi, type HerdrApi, herdrRun, type HerdrRun, isSub, type Pane, panes, parentOf, place, type Place, report, type Session, type Sleep, waitFor, workspaces, type Workspace } from "./herdr.ts";
|
|
11
11
|
|
|
12
12
|
// pi-intercom's extension API as constants and minimal types: intercom is a runtime requirement, not an import.
|
|
@@ -15,7 +15,7 @@ const OUTBOX = "intercom:outbox-request";
|
|
|
15
15
|
const OUTBOX_RESULT = "intercom:outbox-result";
|
|
16
16
|
const NAMESPACE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
|
|
17
17
|
const ENTRY = "straw-boss";
|
|
18
|
-
const PROMPTS = ["main", "tower", "member", "
|
|
18
|
+
const PROMPTS = ["main", "tower", "member", "member-no-main"];
|
|
19
19
|
const REFRESH_MS = 4 * 60_000;
|
|
20
20
|
const CLAIM_MS = 5000;
|
|
21
21
|
const CONNECT_MS = 15_000;
|
|
@@ -29,11 +29,11 @@ interface Channel {
|
|
|
29
29
|
listSessions(): Promise<Session[]>;
|
|
30
30
|
}
|
|
31
31
|
interface SendResult { requestId?: string; status: string; code?: string; detail?: string }
|
|
32
|
-
interface Saved {
|
|
32
|
+
interface Saved { tower?: boolean }
|
|
33
33
|
type Role = "tower" | "main" | "member";
|
|
34
34
|
type Scope = "tab" | "workspace" | "towers" | "all";
|
|
35
35
|
type Member = ReturnType<typeof netMembers>[number];
|
|
36
|
-
export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string }
|
|
36
|
+
export interface Deps { run?: HerdrRun; api?: HerdrApi; env?: NodeJS.ProcessEnv; sleep?: Sleep; promptDir?: string; bundle?: string; openUrl?: (url: string) => Promise<void>; stateDir?: string; autoTower?: boolean }
|
|
37
37
|
|
|
38
38
|
/** Live sessions whose pane carries an `sb_role` token: the sessions that joined the net, and towers. */
|
|
39
39
|
function netMembers(sessions: Session[], ps: Pane[]) {
|
|
@@ -45,12 +45,14 @@ function netMembers(sessions: Session[], ps: Pane[]) {
|
|
|
45
45
|
}
|
|
46
46
|
const nameOf = (s?: Session) => (s ? (s.name ?? s.id) : "");
|
|
47
47
|
|
|
48
|
-
export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir }: Deps = {}) {
|
|
48
|
+
export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = process.env, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), promptDir = fileURLToPath(new URL("../prompts", import.meta.url)), bundle, openUrl = openBrowser, stateDir, autoTower = true }: Deps = {}) {
|
|
49
49
|
return function strawBoss(pi: ExtensionAPI): void {
|
|
50
50
|
// Outside Herdr there is no tab to coordinate. A pi-herdr-agents worker never joins: its parent represents it.
|
|
51
51
|
if (!env.HERDR_PANE_ID || env.PI_SUBAGENT_ID) return;
|
|
52
52
|
let isTower = env.STRAW_BOSS_ROLE === "tower";
|
|
53
|
-
let
|
|
53
|
+
let joined = false, isMain = false, holdsTower = false, requests = 0;
|
|
54
|
+
// 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.
|
|
55
|
+
let mainId: string | undefined, pinned: string | undefined, joining: Promise<void> | undefined;
|
|
54
56
|
// Why this session cannot join: it sits in a sub workspace, or Herdr could not say whether it does.
|
|
55
57
|
let barred: string | undefined, dashboard: ReturnType<typeof startDashboard> | undefined, hasUI = false;
|
|
56
58
|
let prompts: Record<string, string> = {}, info: Record<string, string> = {}, saved: Saved = {};
|
|
@@ -75,6 +77,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
75
77
|
},
|
|
76
78
|
});
|
|
77
79
|
const connect = (eligible: boolean) => {
|
|
80
|
+
if (c) return; // a join that failed after registering retries on the channel it has
|
|
78
81
|
const namespace = `straw-boss/tab/${Buffer.from(at.tabId).toString("hex")}`;
|
|
79
82
|
if (!NAMESPACE.test(namespace)) throw new Error(`Tab id ${at.tabId} is too long for an intercom namespace (limit 24 characters)`);
|
|
80
83
|
pi.events.emit(REGISTER, { namespace, ownerEligible: eligible, onReady: (channel: Channel) => (c = channel), onEvent });
|
|
@@ -92,33 +95,53 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
92
95
|
timer.unref();
|
|
93
96
|
};
|
|
94
97
|
|
|
95
|
-
// The owner records who is main.
|
|
96
|
-
const pin = (main
|
|
97
|
-
if (
|
|
98
|
+
// The owner records who is main. Promotion is first come, first served, and the main stays until it leaves: a later owner change never swaps it.
|
|
99
|
+
const pin = (main: string) => {
|
|
100
|
+
if (c.snapshot().owner?.sessionId !== me()) return;
|
|
101
|
+
c.commitState({ v: 1, main, since: Date.now() });
|
|
102
|
+
pinned = main;
|
|
98
103
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
// The owner decides a claim: a tab has one main, so a claim is dropped while a live main exists, unless the user forced it with /boss.
|
|
105
|
+
const claim = async (from: string, force: boolean) => {
|
|
106
|
+
try {
|
|
107
|
+
if (c.snapshot().owner?.sessionId !== me()) return;
|
|
108
|
+
const current = pinned ?? (c.snapshot().state?.payload as { main?: string } | undefined)?.main;
|
|
109
|
+
if (!force && current && current !== from && (await c.listSessions()).some((s) => s.id === current)) return;
|
|
110
|
+
pin(from);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
tell(`Straw Boss: ${(error as Error).message}`, "warning");
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const onEvent = (event: { type: string; fromSessionId?: string; owner?: { sessionId: string }; payload?: { type?: string; force?: boolean } }) => {
|
|
116
|
+
// Ownership moved away: what this session pinned as owner is no longer its to defend.
|
|
117
|
+
if (event.type === "owner" && event.owner?.sessionId !== me()) pinned = undefined;
|
|
118
|
+
// A join that failed at start (intercom not connected yet, no owner yet) is retried when the channel changes.
|
|
119
|
+
if (!joined && !barred && !isTower && c && ["connection", "owner"].includes(event.type)) void joinNet().catch(() => {});
|
|
120
|
+
if (event.type === "message" && event.payload?.type === "claim" && event.fromSessionId) {
|
|
121
|
+
const [from, force] = [event.fromSessionId, Boolean(event.payload.force)];
|
|
122
|
+
queue = queue.then(() => claim(from, force));
|
|
123
|
+
} else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
|
|
102
124
|
if (["presence_update", "session_joined", "session_left"].includes(event.type)) dashboard?.poke();
|
|
103
125
|
};
|
|
104
126
|
const refresh = async () => {
|
|
105
127
|
if (!joined) return;
|
|
106
128
|
try {
|
|
107
129
|
const snap = c.snapshot();
|
|
130
|
+
if (snap.owner?.sessionId !== me()) pinned = undefined;
|
|
108
131
|
const sessions = await c.listSessions();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
pin(main);
|
|
114
|
-
}
|
|
132
|
+
// A main that has left leaves the tab without one until a member promotes.
|
|
133
|
+
const recorded = (snap.state?.payload as { main?: string } | undefined)?.main;
|
|
134
|
+
if (pinned === recorded) pinned = undefined; // the broker has echoed it
|
|
135
|
+
mainId = sessions.find((s) => s.id === (pinned ?? recorded))?.id;
|
|
115
136
|
const ws = (await workspaces(run)).find((w) => w.workspace_id === at.workspaceId);
|
|
116
137
|
const tab = sessions.find((s) => s.id === me())?.herdrLocation?.tab?.label ?? at.tabId;
|
|
117
|
-
info = { tab, workspace: ws?.label ?? at.workspaceId, main: nameOf(sessions.find((s) => s.id ===
|
|
138
|
+
info = { tab, workspace: ws?.label ?? at.workspaceId, main: nameOf(sessions.find((s) => s.id === mainId)), tower: nameOf(sessions.find((s) => s.id === ws?.tokens?.tower)) };
|
|
118
139
|
const wasMain = isMain;
|
|
119
|
-
isMain =
|
|
140
|
+
isMain = mainId === me();
|
|
120
141
|
if (isMain !== wasMain) tell(`This session is ${isMain ? "now" : "no longer"} the main agent of ${info.tab}`);
|
|
121
142
|
await publish();
|
|
143
|
+
// The workspace's first main opens the tower at once; later mains find it open.
|
|
144
|
+
if (isMain && !wasMain && autoTower) void openTower().catch((error: Error) => tell(`Straw Boss: could not open the tower: ${error.message}`, "warning"));
|
|
122
145
|
} catch (error) {
|
|
123
146
|
tell(`Straw Boss: ${(error as Error).message}`, "warning");
|
|
124
147
|
}
|
|
@@ -129,27 +152,40 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
129
152
|
if (await waitFor(sleep, () => channel.snapshot().connected && channel.snapshot().supported, CONNECT_MS)) return;
|
|
130
153
|
throw new Error(channel.snapshot().connected ? "The intercom broker lacks extension-bus-v1" : "pi-intercom is not connected");
|
|
131
154
|
};
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
// Every joinable session joins at start as a member (owner-eligible, so the tab channel always has an owner). One join at a time.
|
|
156
|
+
const joinNet = () =>
|
|
157
|
+
(joining ??= (async () => {
|
|
158
|
+
if (joined) return;
|
|
159
|
+
if (barred) throw new Error(barred);
|
|
160
|
+
at = await place(run);
|
|
161
|
+
connect(true);
|
|
162
|
+
await connected();
|
|
163
|
+
if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
|
|
164
|
+
joined = true;
|
|
165
|
+
addNetTools();
|
|
166
|
+
addPromote();
|
|
167
|
+
keepFresh();
|
|
168
|
+
await sync();
|
|
169
|
+
})().catch((error) => {
|
|
170
|
+
joining = undefined;
|
|
171
|
+
throw error;
|
|
172
|
+
}));
|
|
173
|
+
// Becoming the tab's main agent. Without `force` a tab that already has a live main refuses; /boss forces.
|
|
174
|
+
const promote = async (force: boolean): Promise<{ promoted: boolean; text: string }> => {
|
|
146
175
|
await joinNet();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
176
|
+
await sync();
|
|
177
|
+
if (isMain) return { promoted: true, text: `You are already the main agent of tab ${info.tab}.` };
|
|
178
|
+
const refused = () => ({ promoted: false, text: `Tab ${info.tab} already has a main agent, ${info.main}. Stay a member: you may dispatch your own subagents, but tell ${info.main} through intercom first what you will do and which resources you will use.` });
|
|
179
|
+
if (!force && mainId) return refused();
|
|
180
|
+
if (c.snapshot().owner?.sessionId === me()) await (queue = queue.then(() => claim(me(), force)));
|
|
181
|
+
else c.publish({ type: "claim", force }, { audience: "owner" });
|
|
182
|
+
if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) {
|
|
183
|
+
await sync();
|
|
184
|
+
if (isMain) return { promoted: true, text: `You are now the main agent of tab ${info.tab}.` };
|
|
185
|
+
if (!force && mainId && mainId !== me()) return refused();
|
|
186
|
+
throw new Error("The owner did not confirm the promotion within 5 s");
|
|
151
187
|
}
|
|
152
|
-
return `
|
|
188
|
+
return { promoted: true, text: `You are now the main agent of tab ${info.tab}.` };
|
|
153
189
|
};
|
|
154
190
|
const send = (to: string, message: string) =>
|
|
155
191
|
new Promise<SendResult>((resolve) => {
|
|
@@ -237,6 +273,13 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
237
273
|
}),
|
|
238
274
|
({ message, scope }) => broadcast(message, scope ?? "workspace"));
|
|
239
275
|
}
|
|
276
|
+
function addPromote() {
|
|
277
|
+
tool("boss_promote", "Become the main agent of this Herdr tab when your work grows: several steps, dispatching workers, or touching more than one app. Refused when the tab already has a main agent.", Type.Object({}), async () => {
|
|
278
|
+
const { promoted, text } = await promote(false);
|
|
279
|
+
if (!promoted) throw new Error(text);
|
|
280
|
+
return { text, details: { role: role() } };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
240
283
|
const startTower = async () => {
|
|
241
284
|
at = await place(run);
|
|
242
285
|
connect(false);
|
|
@@ -281,7 +324,6 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
281
324
|
hasUI = ctx.hasUI;
|
|
282
325
|
prompts = Object.fromEntries(PROMPTS.map((name) => [name, readFileSync(join(promptDir, `${name}.md`), "utf8").trim()]));
|
|
283
326
|
saved = Object.assign({}, ...ctx.sessionManager.getBranch().flatMap((e) => (e.type === "custom" && e.customType === ENTRY ? [e.data] : [])));
|
|
284
|
-
asked = Boolean(saved.asked);
|
|
285
327
|
isTower ||= Boolean(saved.tower);
|
|
286
328
|
// Starting runs in the background: pi awaits session_start handlers in turn, and pi-intercom connects in its own, after this one.
|
|
287
329
|
let starting: Promise<void> | undefined;
|
|
@@ -293,12 +335,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
293
335
|
if (bar) {
|
|
294
336
|
tell(bar.text, bar.level);
|
|
295
337
|
} else {
|
|
296
|
-
|
|
297
|
-
await joinNet();
|
|
298
|
-
const text = isMain ? `Joined as the main agent of tab ${info.tab}.` : `Joined tab ${info.tab} as a member; its main agent is ${info.main}.`;
|
|
299
|
-
return { text, details: { role: role() } };
|
|
300
|
-
});
|
|
301
|
-
if (saved.joined) starting = joinNet(true);
|
|
338
|
+
starting = joinNet();
|
|
302
339
|
}
|
|
303
340
|
}
|
|
304
341
|
starting?.catch((error: Error) => tell(`Straw Boss: ${error.message}`, "error"));
|
|
@@ -306,19 +343,22 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
306
343
|
|
|
307
344
|
pi.on("before_agent_start", (event) => {
|
|
308
345
|
const current = role();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (first) {
|
|
312
|
-
asked = true;
|
|
313
|
-
pi.appendEntry(ENTRY, { asked: true });
|
|
314
|
-
}
|
|
315
|
-
let text: string | undefined;
|
|
316
|
-
if (current) text = render(prompts[current]);
|
|
317
|
-
else if (first) text = render(prompts.reminder);
|
|
318
|
-
if (text) event.systemPromptOptions.sections[ENTRY] = text;
|
|
346
|
+
const name = current === "member" && !mainId ? "member-no-main" : current;
|
|
347
|
+
if (name) event.systemPromptOptions.sections[ENTRY] = render(prompts[name]);
|
|
319
348
|
else delete event.systemPromptOptions.sections[ENTRY];
|
|
320
349
|
});
|
|
321
350
|
|
|
351
|
+
// A member that dispatches a subagent without promoting is promoted here, before the subagent starts, unless the tab already has a main.
|
|
352
|
+
pi.on("tool_call", async (event) => {
|
|
353
|
+
if (event.toolName !== "subagent" || isTower || barred) return;
|
|
354
|
+
try {
|
|
355
|
+
await joinNet();
|
|
356
|
+
if (!isMain) await promote(false);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
tell(`Straw Boss: ${(error as Error).message}`, "warning");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
322
362
|
pi.on("session_shutdown", async () => {
|
|
323
363
|
await gui?.stop();
|
|
324
364
|
gui = undefined;
|
|
@@ -357,7 +397,7 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
357
397
|
// in this session share one start.
|
|
358
398
|
opening ??= (async () => {
|
|
359
399
|
if (gui) return gui.url;
|
|
360
|
-
const shared = await sharedGui(guiDir, `${me() || "unknown"} (pane ${env.HERDR_PANE_ID})`, (prefer) =>
|
|
400
|
+
const shared = await sharedGui(guiDir, `${me() || "unknown"} (pane ${env.HERDR_PANE_ID})`, (prefer) => { const net = netSource({ run, sessions }); return startGui({ read: net.read, net, dir: bundle, ...prefer }); }, sleep);
|
|
361
401
|
gui = shared.mine;
|
|
362
402
|
return shared.url;
|
|
363
403
|
})().finally(() => (opening = undefined));
|
|
@@ -367,14 +407,14 @@ export function createStrawBoss({ run = herdrRun, api = herdrApi(), env = proces
|
|
|
367
407
|
};
|
|
368
408
|
|
|
369
409
|
pi.registerCommand("boss", {
|
|
370
|
-
description: "
|
|
410
|
+
description: "Take over as this tab's main agent (every session already joins as a member). /boss status shows the coordinators; /boss tower opens and focuses the workspace tower; /boss gui opens the net in the browser (/boss gui stop closes it).",
|
|
371
411
|
handler: async (args, ctx) => {
|
|
372
412
|
ui = ctx.ui;
|
|
373
413
|
try {
|
|
374
414
|
if (/^gui( stop)?$/.test(args.trim())) return await guiCommand(args.trim() === "gui stop");
|
|
375
|
-
if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net
|
|
415
|
+
if (args.trim() === "status") return tell(joined || isTower ? JSON.stringify(await status(), null, 2) : "Not in the net: still joining, or this session cannot join.");
|
|
376
416
|
if (isTower) throw new Error("This session is the tower, not a main agent");
|
|
377
|
-
if (args.trim() !== "tower") return tell(await
|
|
417
|
+
if (args.trim() !== "tower") return tell((await promote(true)).text);
|
|
378
418
|
await joinNet();
|
|
379
419
|
const tower = await openTower();
|
|
380
420
|
const where = (await c.listSessions()).find((s) => s.id === tower.id)?.herdrLocation;
|
package/src/items.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type FileHandle, open } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Session entries as displayable items: what the chat shows for each kind of entry, with previews capped so a page stays small.
|
|
5
|
+
// `full` lifts the caps for the one-entry fetch (thinking text, whole tool result, images).
|
|
6
|
+
|
|
7
|
+
const PREVIEW = 2048, FULL = 1 << 20, TEXT = 100_000, LAST_BYTES = 65_536;
|
|
8
|
+
export const FULL_LIMIT = FULL;
|
|
9
|
+
|
|
10
|
+
export interface Tool { id: string; name: string; summary: string; args?: string }
|
|
11
|
+
export interface Item {
|
|
12
|
+
id: string; kind: "user" | "assistant" | "result" | "bash" | "notice" | "divider" | "meta"; at?: string; text?: string; more?: boolean; images?: number; pics?: string[];
|
|
13
|
+
thought?: number; thoughtText?: string; tools?: Tool[]; model?: string; stop?: string; error?: string; ctx?: number;
|
|
14
|
+
callId?: string; name?: string; isError?: boolean; bytes?: number; command?: string; exit?: number; type?: string; from?: string; link?: string; tokens?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Json { [key: string]: any } // eslint-disable-line @typescript-eslint/no-explicit-any -- session entries are untrusted JSON, read defensively
|
|
17
|
+
|
|
18
|
+
const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n) : s);
|
|
19
|
+
const first = (v: unknown, n = 160) => clip(String(v ?? "").split("\n")[0] ?? "", n);
|
|
20
|
+
const blocks = (c: unknown): Json[] => (Array.isArray(c) ? c : []);
|
|
21
|
+
const textOf = (c: unknown) => (typeof c === "string" ? c : blocks(c).filter((b) => b?.type === "text").map((b) => String(b.text)).join("\n"));
|
|
22
|
+
export const parse = (s: string): Json | undefined => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(s);
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const pics = (c: unknown) => blocks(c).filter((b) => b?.type === "image" && /^image\/(png|jpe?g|gif|webp)$/.test(b.mimeType)).map((b) => `data:${b.mimeType};base64,${b.data}`);
|
|
30
|
+
const idOf = (file: string) => basename(file, ".jsonl").split("_").pop()!;
|
|
31
|
+
|
|
32
|
+
function toolLine(name: string, a: Json): string {
|
|
33
|
+
switch (name) {
|
|
34
|
+
case "bash": return first(a.command);
|
|
35
|
+
case "read": case "edit": case "write": return first(a.path ?? a.file_path);
|
|
36
|
+
case "subagent": case "subagent_resume": return [a.name, a.agent].filter(Boolean).join(" · ");
|
|
37
|
+
case "intercom": return [a.action, a.to, first(a.message, 80)].filter(Boolean).join(" ");
|
|
38
|
+
case "ask_user": return first(a.question ?? a.questions?.[0]?.question);
|
|
39
|
+
case "show_doc": return first([a.paths].flat().join(", "));
|
|
40
|
+
case "todo": return [a.action, first(a.subject ?? a.id, 80)].filter(Boolean).join(" ");
|
|
41
|
+
case "MonitorCreate": return first(a.description ?? a.command);
|
|
42
|
+
default: return `(${first(Object.values(a)[0], 100)})`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One session entry as an item, or undefined for kinds the chat does not show. `full` lifts the preview caps (thinking text, whole tool result). */
|
|
47
|
+
export function toItem(d: Json, len: number, full = false): Item | undefined {
|
|
48
|
+
const base = { id: String(d.id), at: d.timestamp as string | undefined };
|
|
49
|
+
const m: Json = d.message ?? {}, limit = full ? FULL : PREVIEW;
|
|
50
|
+
const preview = (s: string, kind: "result" | "bash") => ({ text: clip(s, limit), more: s.length > limit, ...(kind === "result" ? { bytes: len } : {}) });
|
|
51
|
+
switch (d.type === "message" ? m.role : d.type) {
|
|
52
|
+
case "user": return { ...base, kind: "user", text: clip(textOf(m.content), TEXT), images: blocks(m.content).filter((b) => b?.type === "image").length, ...(full ? { pics: pics(m.content) } : {}) };
|
|
53
|
+
case "assistant": {
|
|
54
|
+
const thinking = blocks(m.content).filter((b) => b?.type === "thinking");
|
|
55
|
+
const thought = thinking.reduce((n, b) => n + String(b.thinking ?? "").length, 0);
|
|
56
|
+
const tools = blocks(m.content).filter((b) => b?.type === "toolCall").map((b): Tool => ({ id: b.id, name: b.name, summary: toolLine(b.name, b.arguments ?? {}), args: clip(JSON.stringify(b.arguments ?? {}, null, 1), full ? FULL : 1500) }));
|
|
57
|
+
const stop = ["error", "aborted", "length"].includes(m.stopReason) ? m.stopReason : undefined;
|
|
58
|
+
return { ...base, kind: "assistant", text: clip(textOf(m.content), TEXT), thought: thought || (thinking.length ? -1 : undefined), ...(full ? { thoughtText: thinking.map((b) => b.thinking).join("\n\n") } : {}), tools, model: m.model, stop, error: m.errorMessage, ctx: m.usage?.totalTokens };
|
|
59
|
+
}
|
|
60
|
+
case "toolResult": return { ...base, kind: "result", callId: m.toolCallId, name: m.toolName, isError: Boolean(m.isError), images: blocks(m.content).filter((b) => b?.type === "image").length, ...(full ? { pics: pics(m.content) } : {}), ...preview(textOf(m.content), "result") };
|
|
61
|
+
case "bashExecution": return { ...base, kind: "bash", command: first(m.command, 400), exit: m.exitCode, ...preview(String(m.output ?? ""), "bash") };
|
|
62
|
+
case "custom_message": {
|
|
63
|
+
if (!d.display) return undefined;
|
|
64
|
+
const sessionFile = d.details?.sessionFile;
|
|
65
|
+
return { ...base, kind: "notice", type: d.customType, text: clip(textOf(d.content), full ? FULL : 600), from: d.details?.from?.name ?? d.details?.name, link: typeof sessionFile === "string" ? idOf(sessionFile) : undefined };
|
|
66
|
+
}
|
|
67
|
+
case "compaction": return { ...base, kind: "divider", type: "compaction", tokens: d.tokensBefore, text: clip(String(d.summary ?? ""), full ? FULL : 600) };
|
|
68
|
+
case "branch_summary": return { ...base, kind: "divider", type: "branch", text: clip(String(d.summary ?? ""), full ? FULL : 600) };
|
|
69
|
+
case "model_change": return { ...base, kind: "meta", text: `model ${d.provider}/${d.modelId}` };
|
|
70
|
+
case "thinking_level_change": return { ...base, kind: "meta", text: `thinking ${d.thinkingLevel}` };
|
|
71
|
+
case "session_info": return { ...base, kind: "meta", text: `session named ${d.name}` };
|
|
72
|
+
default: return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The last assistant text of a session file, from its last 64 KB: what a workspace card shows. */
|
|
77
|
+
export async function lastLine(file: string): Promise<string> {
|
|
78
|
+
let fh: FileHandle | undefined;
|
|
79
|
+
try {
|
|
80
|
+
fh = await open(file, "r");
|
|
81
|
+
const { size } = await fh.stat(), start = Math.max(0, size - LAST_BYTES), buf = Buffer.alloc(size - start);
|
|
82
|
+
await fh.read(buf, 0, buf.length, start);
|
|
83
|
+
for (const line of buf.toString("utf8").split("\n").slice(start ? 1 : 0).reverse()) {
|
|
84
|
+
const text = textOf((line.includes('"role":"assistant"') ? parse(line) : undefined)?.message?.content).trim().split("\n").filter(Boolean).at(-1);
|
|
85
|
+
if (text) return clip(text, 240);
|
|
86
|
+
}
|
|
87
|
+
} catch {} // an unreadable or missing file simply has no last line
|
|
88
|
+
finally {
|
|
89
|
+
await fh?.close();
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|