pi-straw-boss 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 { collectNet } from "./net.ts";
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", "reminder"];
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 { asked?: boolean; joined?: boolean; tower?: boolean }
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 asked = false, joined = false, isMain = false, holdsTower = false, requests = 0;
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. The first joiner pins itself, so a later owner change never swaps the main agent.
96
- const pin = (main?: string) => {
97
- if (main && c.snapshot().owner?.sessionId === me()) c.commitState({ v: 1, main, since: Date.now() });
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
- const onEvent = (event: { type: string; fromSessionId?: string; payload?: { type?: string } }) => {
100
- if (event.type === "message" && event.payload?.type === "claim") pin(event.fromSessionId);
101
- else if (["connection", "owner", "state", "session_left"].includes(event.type)) void sync();
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
- let main = (snap.state?.payload as { main?: string } | undefined)?.main;
110
- if (!sessions.some((s) => s.id === main)) {
111
- // The pinned main is gone: the owner is main until it pins itself.
112
- main = snap.owner?.sessionId;
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 === main)), tower: nameOf(sessions.find((s) => s.id === ws?.tokens?.tower)) };
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 = main === me();
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
- const joinNet = async (restored = false) => {
133
- if (joined) return;
134
- if (barred) throw new Error(barred);
135
- at = await place(run);
136
- connect(true);
137
- await connected();
138
- if (!(await waitFor(sleep, () => c.snapshot().owner, CLAIM_MS))) throw new Error("No intercom owner was elected for this tab");
139
- joined = true;
140
- if (!restored) pi.appendEntry(ENTRY, { asked: true, joined: true });
141
- addNetTools();
142
- keepFresh();
143
- await sync();
144
- };
145
- const takeover = async () => {
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
- if (!isMain) {
148
- if (c.snapshot().owner?.sessionId === me()) pin(me());
149
- else c.publish({ type: "claim" }, { audience: "owner" });
150
- if (!(await waitFor(sleep, () => isMain, CLAIM_MS))) throw new Error("The owner did not confirm the takeover within 5 s");
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 `This session is the main agent of tab ${info.tab}`;
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
- tool("boss_join", "Join this Herdr workspace's Straw Boss net when your current work competes for resources with, or collaborates with, other work in the workspace. The first session of a tab to join becomes its main agent; later ones are members.", Type.Object({}), async () => {
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
- // A session outside the net is asked once, at its first run, whether it should join.
310
- const first = !current && !asked && !barred;
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) => startGui({ read: () => collectNet({ run, sessions }), dir: bundle, ...prefer }), sleep);
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: "Join this tab's Straw Boss net and become its main agent. /boss status shows the coordinators; /boss tower opens and focuses the workspace tower; /boss gui opens the net in the browser (/boss gui stop closes it).",
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. /boss joins it.");
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 takeover());
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
+