pi-jar 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/discussion.ts CHANGED
@@ -1,227 +1,459 @@
1
- import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { createConnection, createServer, type Server, type Socket } from "node:net";
3
4
  import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
4
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
7
  import { Type } from "typebox";
8
+ import {
9
+ DiscussionError, DiscussionHub, MAX_DISCUSSION_BYTES, MAX_DISCUSSION_MESSAGES, MAX_DISCUSSION_TEXT, MAX_LIST_CHARS, messageLine,
10
+ MODERATOR, parseDiscussionId, type ListResult, type PendingResult, type PostResult, type ThreadResult, type UnreadResult
11
+ } from "./discussion-hub.ts";
6
12
  import { cleanText } from "./status.ts";
7
13
 
8
- export const DISCUSSION_FILE_ENV = "PI_JAR_DISCUSSION_FILE";
9
14
  export const SUBAGENT_KEY_ENV = "PI_JAR_SUBAGENT_KEY";
10
15
  export const SUBAGENT_NAME_ENV = "PI_JAR_SUBAGENT_NAME";
11
- export const MAX_DISCUSSION_ENTRIES = 64;
12
- export const MAX_DISCUSSION_TEXT = 1600;
13
- const MAX_DISCUSSION_BYTES = 64 * 1024;
14
-
15
- export interface DiscussionEntry {
16
- id: string;
17
- kind: "question" | "answer";
18
- from: string;
19
- to?: string;
20
- questionId?: string;
21
- text: string;
22
- at: string;
16
+ export const DISCUSSION_ENDPOINT_ENV = "PI_JAR_DISCUSSION_ENDPOINT";
17
+ /** Session-local capability that maps a child's requests to the identity the parent registered. Never logged. */
18
+ export const DISCUSSION_TOKEN_ENV = "PI_JAR_DISCUSSION_TOKEN";
19
+ /** Everything a child inherits for discussion; scrubbed from the parent's own env before each spawn. */
20
+ export const DISCUSSION_ENV_KEYS: readonly string[] = [DISCUSSION_ENDPOINT_ENV, DISCUSSION_TOKEN_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV];
21
+ /** Custom message type of the unread notice injected at a natural turn boundary. */
22
+ export const DISCUSSION_NOTICE = "pi-jar.discussion-notice";
23
+
24
+ export interface DiscussionStats {
25
+ transport: "broker";
26
+ listening: boolean;
27
+ agents: number;
28
+ messages: number;
29
+ maxMessages: number;
30
+ bytes: number;
31
+ maxBytes: number;
32
+ unanswered: number;
33
+ unread: number;
34
+ oldestId?: number;
35
+ newestId?: number;
36
+ requests: number;
37
+ errors: number;
23
38
  }
24
- interface DiscussionPaper { version: 1; seq: number; entries: DiscussionEntry[] }
25
-
26
- const empty = (): DiscussionPaper => ({ version: 1, seq: 0, entries: [] });
27
- const safeActor = (value: string | undefined) => cleanText(value || "moderator", 48) || "moderator";
28
-
29
- function readPaper(file: string): DiscussionPaper {
30
- try {
31
- const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<DiscussionPaper>;
32
- if (raw.version !== 1 || !Array.isArray(raw.entries)) return empty();
33
- const entries = raw.entries.filter((item): item is DiscussionEntry => !!item && typeof item === "object"
34
- && (item.kind === "question" || item.kind === "answer") && typeof item.id === "string"
35
- && typeof item.from === "string" && typeof item.text === "string" && typeof item.at === "string")
36
- .slice(-MAX_DISCUSSION_ENTRIES);
37
- return { version: 1, seq: typeof raw.seq === "number" && Number.isFinite(raw.seq) ? Math.max(0, raw.seq) : entries.length, entries };
38
- } catch { return empty(); }
39
+
40
+ /** One agent's view of the broker: in-process for the moderator, local IPC for a subagent. */
41
+ export interface DiscussionTransport {
42
+ ask(text: string, to?: string): Promise<PostResult>;
43
+ answer(questionId: number, text: string): Promise<PostResult>;
44
+ list(since?: number): Promise<ListResult>;
45
+ thread(questionId: number, since?: number): Promise<ThreadResult>;
46
+ pending(): Promise<PendingResult>;
47
+ unread(): Promise<UnreadResult>;
39
48
  }
40
49
 
41
- function writePaper(file: string, paper: DiscussionPaper): void {
42
- mkdirSync(dirname(file), { recursive: true });
43
- const temp = file + "." + process.pid + ".tmp";
44
- // Compact JSON: indentation only cost bytes against the cap and evicted entries earlier.
45
- let data = JSON.stringify(paper) + "\n";
46
- // Entry count/character caps are not byte caps (CJK, emoji and JSON escaping cost
47
- // more). Evict oldest entries until the actual serialized UTF-8 paper fits.
48
- while (Buffer.byteLength(data, "utf8") > MAX_DISCUSSION_BYTES && paper.entries.length) {
49
- paper.entries.shift();
50
- data = JSON.stringify(paper) + "\n";
50
+ /** A 1600-character message JSON-escapes to well under this; anything larger is not from our client. */
51
+ const MAX_REQUEST_BYTES = 16 * 1024;
52
+ const MAX_REPLY_BYTES = 256 * 1024;
53
+ const MAX_CONNECTIONS = 64;
54
+ const IO_TIMEOUT_MS = 5_000;
55
+ const SOCKET_NAME = "broker.sock";
56
+ /** macOS caps a Unix socket path near 104 bytes. */
57
+ const MAX_SOCKET_PATH = 100;
58
+
59
+ type Reply = { ok: true; result: unknown } | { ok: false; error: string };
60
+ type Request = (op: string, args: Record<string, unknown>) => Promise<unknown>;
61
+
62
+ const transportOf = (request: Request): DiscussionTransport => ({
63
+ ask: (text, to) => request("ask", to === undefined ? { text } : { text, to }) as Promise<PostResult>,
64
+ answer: (questionId, text) => request("answer", { questionId, text }) as Promise<PostResult>,
65
+ list: (since) => request("list", since === undefined ? {} : { since }) as Promise<ListResult>,
66
+ thread: (questionId, since) => request("thread", since === undefined ? { questionId } : { questionId, since }) as Promise<ThreadResult>,
67
+ pending: () => request("pending", {}) as Promise<PendingResult>,
68
+ unread: () => request("unread", {}) as Promise<UnreadResult>
69
+ });
70
+
71
+ /** Shared by the tool adapter and the broker, so a bad id is rejected the same way on both sides. */
72
+ function idArgument(value: unknown, name: string, required: boolean): number | undefined {
73
+ if (value === undefined || value === "") {
74
+ if (required) throw new DiscussionError(`${name} is required (e.g. d12).`);
75
+ return undefined;
51
76
  }
52
- writeFileSync(temp, data);
53
- renameSync(temp, file);
77
+ const id = parseDiscussionId(value);
78
+ if (id === undefined) throw new DiscussionError(`${name} must be a discussion id like d12.`);
79
+ return id;
54
80
  }
55
81
 
56
- export function createDiscussionPaper(): string {
57
- const root = mkdtempSync(join(tmpdir(), "pi-jar-discussion-"));
58
- const file = join(root, "paper.json");
59
- writePaper(file, empty());
60
- return file;
61
- }
82
+ /**
83
+ * Parent-owned discussion broker: the in-memory hub behind a local IPC endpoint (a Unix socket in an
84
+ * owner-only temp directory, or a named pipe on Windows). Children connect once per call and speak one
85
+ * newline-delimited JSON request/reply; each is authenticated by its own session token, so a child can
86
+ * never choose its identity. The socket is the only file it ever creates; nothing polls.
87
+ */
88
+ export class DiscussionBroker {
89
+ /** Called after any change (new message, read, agent registered or retired), for a cheap repaint. */
90
+ onChange?: () => void;
91
+ private readonly hub = new DiscussionHub();
92
+ private notified = this.hub.revision;
93
+ /** token → agent key, and key → token so childEnv is idempotent per key. */
94
+ private readonly keys = new Map<string, string>();
95
+ private readonly tokens = new Map<string, string>();
96
+ private readonly sockets = new Set<Socket>();
97
+ private server?: Server;
98
+ /** start/close run strictly one after another, so a session replacement can never interleave them. */
99
+ private lifecycle: Promise<void> = Promise.resolve();
100
+ private dir?: string;
101
+ private endpoint?: string;
102
+ private localTransport?: DiscussionTransport;
103
+ private requests = 0;
104
+ private errors = 0;
105
+ /** A crash that skips close() must still not leave the socket directory behind. */
106
+ private readonly removeOnExit = () => {
107
+ try { this.removeEndpoint(); } catch { /* the process is exiting; there is no one left to report to */ }
108
+ };
62
109
 
63
- export function disposeDiscussionPaper(file: string | undefined): void {
64
- if (!file) return;
65
- try { rmSync(dirname(file), { recursive: true, force: true }); } catch { /* best effort */ }
66
- }
110
+ /** Idempotent: creates the endpoint and starts accepting connections. */
111
+ start(): Promise<void> {
112
+ return this.serial(async () => { if (!this.server) await this.listen(); });
113
+ }
67
114
 
68
- export function discussionEntries(file: string | undefined): DiscussionEntry[] {
69
- return file ? readPaper(file).entries : [];
70
- }
115
+ /** Registers the agent (idempotent per key) and returns the env its child process needs. Endpoint and
116
+ * token are omitted until the broker is started, so that child reports the broker unavailable. */
117
+ childEnv(key: string, name: string): Record<string, string> {
118
+ const env: Record<string, string> = { [SUBAGENT_KEY_ENV]: key, [SUBAGENT_NAME_ENV]: name };
119
+ // The moderator's identity is never handed to a child.
120
+ if (key === MODERATOR) return env;
121
+ try { this.hub.register(key, name); }
122
+ catch (error) {
123
+ if (!(error instanceof DiscussionError)) throw error;
124
+ // Past the agent cap the delegation still starts; only its jar_discuss reports the broker unavailable.
125
+ this.errors++;
126
+ return env;
127
+ }
128
+ let token = this.tokens.get(key);
129
+ if (!token) {
130
+ token = randomBytes(24).toString("base64url");
131
+ this.tokens.set(key, token);
132
+ this.keys.set(token, key);
133
+ }
134
+ this.notify();
135
+ if (this.endpoint) Object.assign(env, { [DISCUSSION_ENDPOINT_ENV]: this.endpoint, [DISCUSSION_TOKEN_ENV]: token });
136
+ return env;
137
+ }
71
138
 
72
- /** Waits by yielding to the event loop, never blocking it: the moderator's UI keeps painting. */
73
- async function withPaperLock<T>(file: string, run: () => T): Promise<T> {
74
- const lock = file + ".lock";
75
- for (let attempt = 0; attempt < 100; attempt++) {
139
+ /** Permanent: revokes the agent's token and mailbox; its messages age out through normal retention. */
140
+ retire(key: string): void {
141
+ const token = this.tokens.get(key);
142
+ if (token) {
143
+ this.tokens.delete(key);
144
+ this.keys.delete(token);
145
+ }
146
+ this.hub.retire(key);
147
+ this.notify();
148
+ }
149
+
150
+ unreadFor(key: string): number { return this.hub.has(key) ? this.hub.unread(key).unread : 0; }
151
+
152
+ stats(): DiscussionStats {
153
+ return {
154
+ transport: "broker", listening: this.server?.listening ?? false, ...this.hub.stats(),
155
+ maxMessages: MAX_DISCUSSION_MESSAGES, maxBytes: MAX_DISCUSSION_BYTES, requests: this.requests, errors: this.errors
156
+ };
157
+ }
158
+
159
+ /** Stops serving, removes the endpoint and drops all state and counters; start() may be called again. */
160
+ close(): Promise<void> {
161
+ return this.serial(async () => {
162
+ const server = this.server;
163
+ this.server = undefined;
164
+ for (const socket of this.sockets) socket.destroy();
165
+ this.sockets.clear();
166
+ if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
167
+ this.removeEndpoint();
168
+ this.tokens.clear();
169
+ this.keys.clear();
170
+ this.hub.clear();
171
+ this.requests = 0;
172
+ this.errors = 0;
173
+ this.notify();
174
+ });
175
+ }
176
+
177
+ /** The moderator's in-process transport: the same validation and accounting as IPC, minus the socket. */
178
+ local(): DiscussionTransport {
179
+ this.localTransport ??= transportOf((op, args) => new Promise((resolve) => resolve(this.call(MODERATOR, op, args))));
180
+ return this.localTransport;
181
+ }
182
+
183
+ private serial(step: () => Promise<void>): Promise<void> {
184
+ const run = this.lifecycle.then(step);
185
+ // The caller of `run` receives a failure; the chain itself must keep going for the next step.
186
+ this.lifecycle = run.catch(() => undefined);
187
+ return run;
188
+ }
189
+
190
+ private async listen(): Promise<void> {
191
+ let dir: string | undefined;
192
+ let endpoint: string;
193
+ if (process.platform === "win32") endpoint = `\\\\.\\pipe\\pi-jar-discussion-${process.pid}-${randomBytes(8).toString("hex")}`;
194
+ else {
195
+ // A long TMPDIR would overflow sun_path and fail listen(); /tmp always fits.
196
+ const base = Buffer.byteLength(join(tmpdir(), "pi-jar-discussion-XXXXXX", SOCKET_NAME)) <= MAX_SOCKET_PATH ? tmpdir() : "/tmp";
197
+ // mkdtemp creates the directory owner-only (0700), so no other local user can reach the socket.
198
+ dir = mkdtempSync(join(base, "pi-jar-discussion-"));
199
+ endpoint = join(dir, SOCKET_NAME);
200
+ }
201
+ this.dir = dir;
202
+ this.endpoint = endpoint;
203
+ process.on("exit", this.removeOnExit);
204
+ const server = createServer((socket) => this.serve(socket));
205
+ server.maxConnections = MAX_CONNECTIONS;
76
206
  try {
77
- mkdirSync(lock);
78
- try { return run(); }
79
- finally { rmSync(lock, { recursive: true, force: true }); }
207
+ await new Promise<void>((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(endpoint, () => {
210
+ server.off("error", reject);
211
+ resolve();
212
+ });
213
+ });
80
214
  } catch (error) {
81
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
82
- try {
83
- if (Date.now() - statSync(lock).mtimeMs > 5000) { rmSync(lock, { recursive: true, force: true }); continue; }
84
- } catch { /* another process may have released it */ }
85
- await new Promise((resolve) => setTimeout(resolve, 10));
215
+ this.removeEndpoint();
216
+ throw error;
217
+ }
218
+ // After listen, failures belong to one connection: counted and logged, never fatal to the parent.
219
+ server.on("error", (error) => {
220
+ this.errors++;
221
+ console.error("pi-jar: discussion broker error", error);
222
+ });
223
+ // The broker alone never keeps the parent process alive.
224
+ server.unref();
225
+ this.server = server;
226
+ }
227
+
228
+ private removeEndpoint(): void {
229
+ process.off("exit", this.removeOnExit);
230
+ if (this.dir) rmSync(this.dir, { recursive: true, force: true });
231
+ this.dir = undefined;
232
+ this.endpoint = undefined;
233
+ }
234
+
235
+ private notify(): void {
236
+ if (this.hub.revision === this.notified) return;
237
+ this.notified = this.hub.revision;
238
+ try { this.onChange?.(); }
239
+ catch (error) { console.error("pi-jar: discussion change listener failed", error); } // a repaint bug must not fail the request
240
+ }
241
+
242
+ /** The one entry point for both transports; `key` is already authenticated. */
243
+ private call(key: string, op: unknown, args: Record<string, unknown>): unknown {
244
+ this.requests++;
245
+ try {
246
+ switch (op) {
247
+ case "ask":
248
+ if (typeof args.text !== "string") throw new DiscussionError("Discussion text is required.");
249
+ if (args.to !== undefined && typeof args.to !== "string") throw new DiscussionError("to must be an agent key or name.");
250
+ return this.hub.ask(key, args.text, args.to);
251
+ case "answer":
252
+ if (typeof args.text !== "string") throw new DiscussionError("Discussion text is required.");
253
+ return this.hub.answer(key, idArgument(args.questionId, "questionId", true)!, args.text);
254
+ case "list": return this.hub.list(key, idArgument(args.since, "since", false));
255
+ case "thread": return this.hub.thread(key, idArgument(args.questionId, "questionId", true)!, idArgument(args.since, "since", false));
256
+ case "pending": return this.hub.pending(key);
257
+ case "unread": return this.hub.unread(key);
258
+ default: throw new DiscussionError("Unknown discussion operation.");
259
+ }
260
+ } catch (error) {
261
+ this.errors++;
262
+ throw error;
263
+ } finally {
264
+ this.notify();
265
+ }
266
+ }
267
+
268
+ /** One newline-terminated JSON request per connection, one reply line back. Nothing a peer sends can
269
+ * throw out of here: oversized, malformed or abandoned requests cost a counter tick, not the parent. */
270
+ private serve(socket: Socket): void {
271
+ const chunks: Buffer[] = [];
272
+ let size = 0;
273
+ let done = false;
274
+ this.sockets.add(socket);
275
+ socket.on("close", () => this.sockets.delete(socket));
276
+ socket.on("error", () => { this.errors++; });
277
+ // A peer that never finishes its request is an error; one that lingers after its reply is merely closed.
278
+ socket.setTimeout(IO_TIMEOUT_MS, () => {
279
+ if (!done) this.errors++;
280
+ socket.destroy();
281
+ });
282
+ socket.on("data", (chunk: Buffer) => {
283
+ if (done) return;
284
+ // UTF-8 never encodes a newline inside a multi-byte sequence, so splitting raw bytes is safe.
285
+ const newline = chunk.indexOf(10);
286
+ const part = newline < 0 ? chunk : chunk.subarray(0, newline);
287
+ size += part.length;
288
+ if (size > MAX_REQUEST_BYTES) {
289
+ done = true;
290
+ this.requests++;
291
+ this.errors++;
292
+ // Destroy once the reply flushes, so a peer that keeps streaming cannot hold the connection.
293
+ socket.end(JSON.stringify({ ok: false, error: "Discussion request too large." } satisfies Reply) + "\n", () => socket.destroy());
294
+ return;
295
+ }
296
+ chunks.push(part);
297
+ if (newline < 0) return;
298
+ done = true;
299
+ socket.end(JSON.stringify(this.handle(Buffer.concat(chunks).toString("utf8"))) + "\n");
300
+ });
301
+ }
302
+
303
+ private handle(line: string): Reply {
304
+ let request: unknown;
305
+ try { request = JSON.parse(line); } catch { /* answered as malformed below */ }
306
+ const fields = request && typeof request === "object" && !Array.isArray(request) ? request as Record<string, unknown> : undefined;
307
+ // Identity comes only from the token the parent handed this child, never from a request field.
308
+ const key = typeof fields?.token === "string" ? this.keys.get(fields.token) : undefined;
309
+ if (!fields || !key) {
310
+ this.requests++;
311
+ this.errors++;
312
+ return { ok: false, error: fields ? "Unauthorized discussion request." : "Malformed discussion request." };
313
+ }
314
+ const { token: _token, op, ...args } = fields;
315
+ try { return { ok: true, result: this.call(key, op, args) }; }
316
+ catch (error) {
317
+ if (error instanceof DiscussionError) return { ok: false, error: error.message };
318
+ console.error("pi-jar: discussion request failed", error);
319
+ return { ok: false, error: "Discussion broker error." };
86
320
  }
87
321
  }
88
- throw new Error("discussion paper is busy");
89
322
  }
90
323
 
91
- /** Append under the lock; `accept` validates against the same snapshot, so there is one read per write.
92
- * The written snapshot is returned too, so the caller can piggyback unseen entries without a second read. */
93
- function append(file: string, actor: string, entry: Omit<DiscussionEntry, "id" | "from" | "at">,
94
- accept: (paper: DiscussionPaper) => string | undefined = () => undefined): Promise<{ entry: DiscussionEntry; paper: DiscussionPaper } | string> {
95
- return withPaperLock(file, () => {
96
- const paper = readPaper(file);
97
- const rejected = accept(paper);
98
- if (rejected) return rejected;
99
- paper.seq++;
100
- const next: DiscussionEntry = {
101
- id: "d" + paper.seq,
102
- from: safeActor(actor),
103
- at: new Date().toISOString(),
104
- ...entry,
105
- text: cleanText(entry.text, MAX_DISCUSSION_TEXT)
324
+ /** One connection per call: write one request line, read one reply line. No retries, no polling. */
325
+ function brokerRequest(endpoint: string, token: string, op: string, args: Record<string, unknown>): Promise<unknown> {
326
+ return new Promise((resolve, reject) => {
327
+ const chunks: Buffer[] = [];
328
+ let size = 0;
329
+ let settled = false;
330
+ const socket = createConnection(endpoint, () => socket.write(JSON.stringify({ ...args, op, token }) + "\n"));
331
+ const settle = (error: Error | undefined, result?: unknown) => {
332
+ if (settled) return;
333
+ settled = true;
334
+ socket.destroy();
335
+ if (error) reject(error); else resolve(result);
106
336
  };
107
- paper.entries.push(next);
108
- if (paper.entries.length > MAX_DISCUSSION_ENTRIES) paper.entries.splice(0, paper.entries.length - MAX_DISCUSSION_ENTRIES);
109
- writePaper(file, paper);
110
- return { entry: next, paper };
337
+ socket.setTimeout(IO_TIMEOUT_MS, () => settle(new DiscussionError("Discussion broker unavailable (timed out).")));
338
+ // Node's connect error names the endpoint path; the model only needs to know the broker is gone.
339
+ socket.on("error", (error: NodeJS.ErrnoException) => settle(new DiscussionError(`Discussion broker unavailable (${error.code ?? "connection failed"}).`)));
340
+ socket.on("close", () => settle(new DiscussionError("Discussion broker unavailable (connection closed).")));
341
+ socket.on("data", (chunk: Buffer) => {
342
+ const newline = chunk.indexOf(10);
343
+ const part = newline < 0 ? chunk : chunk.subarray(0, newline);
344
+ size += part.length;
345
+ chunks.push(part);
346
+ if (size > MAX_REPLY_BYTES) return settle(new DiscussionError("Discussion broker reply too large."));
347
+ if (newline < 0) return;
348
+ type Parsed = { ok?: unknown; result?: unknown; error?: unknown } | null;
349
+ let reply: Parsed = null;
350
+ try { reply = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Parsed; } catch { /* reported as malformed below */ }
351
+ if (reply?.ok === true) settle(undefined, reply.result);
352
+ else settle(new DiscussionError(typeof reply?.error === "string" ? reply.error : "Malformed discussion broker reply."));
353
+ });
111
354
  });
112
355
  }
113
356
 
114
- /** A list reply never exceeds this, however many entries match; each listed entry is clipped too. */
115
- export const MAX_LIST_CHARS = 4000;
116
- const MAX_LISTED_TEXT = 400;
117
- const seqOf = (id: string | undefined) => { const match = /^d(\d+)$/.exec(id ?? ""); return match ? Number(match[1]) : undefined; };
118
- const entryLine = (entry: DiscussionEntry, limit: number) => {
119
- const text = entry.text.length > limit ? entry.text.slice(0, limit) + "…" : entry.text;
120
- return entry.kind === "question"
121
- ? `[${entry.id}] Q · ${entry.from}${entry.to ? " → " + entry.to : ""}: ${text}`
122
- : `[${entry.id}] A · ${entry.from} → ${entry.questionId ?? "?"}: ${text}`;
123
- };
124
- /** Newest entries first into the budget, rendered oldest first; omissions are stated, never silent. */
125
- const render = (entries: readonly DiscussionEntry[], limit: number): string => {
126
- if (!entries.length) return "(no discussion entries)";
127
- const lines: string[] = [];
128
- let used = 0;
129
- for (let index = entries.length - 1; index >= 0; index--) {
130
- const line = entryLine(entries[index]!, limit);
131
- if (lines.length && used + line.length + 1 > MAX_LIST_CHARS) break;
132
- lines.unshift(line);
133
- used += line.length + 1;
134
- }
135
- const omitted = entries.length - lines.length;
136
- return (omitted ? `(${omitted} older entries omitted; use questionId to read one thread)\n` : "") + lines.join("\n")
137
- + `\n(latest ${entries.at(-1)!.id})`;
138
- };
139
-
140
- /** Unseen entries piggybacked on an ask/answer reply never exceed this. */
141
- const MAX_PIGGYBACK_CHARS = 1000;
142
-
143
- export function registerDiscussionTool(pi: ExtensionAPI, file: () => string | undefined,
144
- actor: () => string = () => safeActor(process.env[SUBAGENT_NAME_ENV] ?? process.env[SUBAGENT_KEY_ENV])): void {
145
- // Every Pi process (moderator or child) registers its own tool, so this closure is one actor's read
146
- // cursor: the highest seq it has been shown on the current paper. A different path, or a paper whose
147
- // seq went backwards (recreated), starts over at zero.
148
- let cursor = { path: "", seq: 0 };
149
- const unseen = (path: string, paper: DiscussionPaper, me: string) => {
150
- if (cursor.path !== path || paper.seq < cursor.seq) cursor = { path, seq: 0 };
151
- const after = cursor.seq;
152
- return paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > after && entry.from !== me);
153
- };
154
- const advance = (seq: number) => { cursor.seq = Math.max(cursor.seq, seq); };
155
- // The cursor is one max-seq, so it may only move past everything unseen. An ask/answer reply therefore
156
- // attaches unseen entries (and advances) only when every unseen non-own entry is relevant to the caller
157
- // (a question addressed to it, or an answer to one of its questions) and all of them fit
158
- // MAX_PIGGYBACK_CHARS; otherwise it adds a one-line count and leaves them for `list`. The question being
159
- // answered (`known`) counts as seen: the caller already has it.
160
- const piggyback = (path: string, paper: DiscussionPaper, me: string, known?: string): string => {
161
- const fresh = unseen(path, paper, me).filter((entry) => entry.id !== known);
162
- if (!fresh.length) { advance(paper.seq); return ""; }
163
- const asked = new Set(paper.entries.filter((entry) => entry.kind === "question" && entry.from === me).map((entry) => entry.id));
164
- const relevant = fresh.filter((entry) => entry.kind === "question" ? entry.to === me : asked.has(entry.questionId ?? ""));
165
- const shown = relevant.map((entry) => entryLine(entry, MAX_LISTED_TEXT)).join("\n");
166
- if (relevant.length === fresh.length && shown.length <= MAX_PIGGYBACK_CHARS) { advance(paper.seq); return "\n" + shown; }
167
- return `\n(${fresh.length} new ${fresh.length === 1 ? "entry" : "entries"}${relevant.length ? `, ${relevant.length} for you` : ""}; list to read)`;
168
- };
357
+ /** A subagent's transport, from the env its parent broker issued; undefined outside a brokered child. */
358
+ export function discussionClientFromEnv(env: NodeJS.ProcessEnv = process.env): DiscussionTransport | undefined {
359
+ const endpoint = env[DISCUSSION_ENDPOINT_ENV];
360
+ const token = env[DISCUSSION_TOKEN_ENV];
361
+ return endpoint && token ? transportOf((op, args) => brokerRequest(endpoint, token, op, args)) : undefined;
362
+ }
363
+
364
+ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
365
+
366
+ export function registerDiscussionTool(pi: ExtensionAPI, transport: () => DiscussionTransport | undefined): void {
169
367
  const parameters = Type.Object({
170
- action: Type.Union([Type.Literal("list"), Type.Literal("ask"), Type.Literal("answer")]),
171
- text: Type.Optional(Type.String()),
172
- to: Type.Optional(Type.String()),
173
- questionId: Type.Optional(Type.String({ description: "answer: the question being answered. list: read only that question and its answers, in full." })),
174
- since: Type.Optional(Type.String({ description: "list: entries newer than this id (e.g. d12) instead of only unseen ones; \"d0\" rereads everything." }))
368
+ action: Type.Union([Type.Literal("ask"), Type.Literal("answer"), Type.Literal("list"), Type.Literal("thread"), Type.Literal("pending")]),
369
+ text: Type.Optional(Type.String({ description: `ask/answer: one concise question or answer (≤${MAX_DISCUSSION_TEXT} chars).` })),
370
+ to: Type.Optional(Type.String({ description: "ask: moderator, a subagent key or unique name; omit to broadcast." })),
371
+ questionId: Type.Optional(Type.String({ description: "answer: the question being answered (e.g. d12). thread: the question to read in full." })),
372
+ since: Type.Optional(Type.String({ description: "list: reread your messages newer than this id, read or not (d0 = all retained). thread: only answers after this id." }))
175
373
  });
176
- pi.registerTool?.<typeof parameters, { path?: string; entry?: DiscussionEntry }>({
374
+ const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
375
+ const failure = (text: string) => ({ ...reply(text), isError: true });
376
+ pi.registerTool?.<typeof parameters, Record<string, never>>({
177
377
  name: "jar_discuss",
178
378
  label: "discuss",
179
- description: `Shared bounded Q/A paper between this session's agents; not a transcript. ask/answer reply with the new id only. list returns only entries you have not seen (≤${MAX_LIST_CHARS} chars).`,
180
- promptSnippet: "Ask or answer one concrete cross-agent question on the shared discussion paper.",
379
+ description: `Parent-brokered Q/A between this session's moderator and subagents; not a transcript, and it never wakes anyone. ask/answer reply with the new id only. list returns only unread messages for you: questions to you, broadcasts, answers to your questions (≤${MAX_LIST_CHARS} chars).`,
380
+ promptSnippet: "Ask or answer one concrete cross-agent question through the session discussion broker.",
181
381
  promptGuidelines: [
182
- "jar_discuss: one precise question or answer per call; answer only the given questionId. Replies never echo your text.",
183
- "Answers to your questions also arrive on your next ask/answer. list shows only unseen entries; questionId reads one thread. Don't poll."
382
+ "jar_discuss: one precise question or answer per call. ask to moderator, a subagent key or unique name, or omit to to broadcast; answer needs the questionId. Replies never echo your text.",
383
+ "Answers to your questions also arrive on your next ask/answer. list shows only unread messages for you; thread reads one question in full; pending summarizes open items. Don't poll."
184
384
  ],
185
385
  parameters,
186
386
  async execute(_id, params) {
187
- const path = file();
188
- if (!path) return { content: [{ type: "text", text: "No shared discussion paper is active." }], details: {} };
189
- const me = safeActor(actor());
190
- if (params.action === "list") {
191
- const paper = readPaper(path);
192
- const thread = params.questionId ? cleanText(params.questionId, 24) : undefined;
193
- if (thread) {
194
- const items = paper.entries.filter((entry) => entry.id === thread || entry.questionId === thread);
195
- return { content: [{ type: "text", text: render(items, MAX_DISCUSSION_TEXT) }], details: { path } };
387
+ const broker = transport();
388
+ if (!broker) return failure("Discussion broker unavailable: this session has no Pi Jar moderator broker.");
389
+ try {
390
+ // `list` with a questionId keeps its old meaning: read that one thread.
391
+ const action = params.action === "list" && params.questionId ? "thread" : params.action;
392
+ if (action === "ask" || action === "answer") {
393
+ // One past the cap, so the broker can report clipping without receiving an unbounded string.
394
+ const text = cleanText(params.text ?? "", MAX_DISCUSSION_TEXT + 1);
395
+ if (!text) return failure("Discussion text is required.");
396
+ const answering = action === "answer" ? idArgument(params.questionId, "questionId", true)! : undefined;
397
+ const result = answering === undefined ? await broker.ask(text, params.to) : await broker.answer(answering, text);
398
+ const notes = [result.to ? `→ ${result.to}` : "", result.clipped ? `clipped to ${MAX_DISCUSSION_TEXT} chars` : ""].filter(Boolean).join("; ");
399
+ const head = (answering === undefined ? `Added d${result.id}` : `Answered d${answering} as d${result.id}`) + (notes ? ` (${notes}).` : ".");
400
+ const delivered = result.delivered.length ? "\n" + result.delivered.map(messageLine).join("\n")
401
+ : result.unread ? `\n(${plural(result.unread, "unread message")}; list to read)` : "";
402
+ return reply(head + delivered);
196
403
  }
197
- const since = seqOf(params.since);
198
- const fresh = unseen(path, paper, me);
199
- advance(paper.seq);
200
- if (since !== undefined) {
201
- const newer = paper.entries.filter((entry) => (seqOf(entry.id) ?? 0) > since);
202
- const text = newer.length ? render(newer, MAX_LISTED_TEXT) : `No entries newer than ${params.since}.`;
203
- return { content: [{ type: "text", text }], details: { path } };
404
+ if (action === "pending") {
405
+ const result = await broker.pending();
406
+ const lines = [`Unread for you: ${result.unread}${result.unread ? " (list to read)" : ""}.`];
407
+ for (const [label, ids] of [["Waiting on you", result.waiting], ["Your unanswered questions", result.open]] as const) {
408
+ if (ids.length) lines.push(`${label}: ${ids.slice(0, 16).map((id) => "d" + id).join(", ")}${ids.length > 16 ? `, +${ids.length - 16} more` : ""}.`);
409
+ }
410
+ if (result.agents?.length) lines.push(`Unread by agent: ${result.agents.map((agent) => `${agent.label} ${agent.unread}`).join(" · ")}.`);
411
+ if (result.unanswered?.total) {
412
+ const shown = result.unanswered.questions.map((question) => `d${question.id} ${question.from}${question.to ? " → " + question.to : ""}`);
413
+ lines.push(`Unanswered: ${result.unanswered.total} (${shown.join(" · ")}${result.unanswered.total > shown.length ? " · …" : ""}).`);
414
+ }
415
+ lines.push(`Retained: ${result.messages}/${MAX_DISCUSSION_MESSAGES} messages, ${(result.bytes / 1024).toFixed(1)}/${MAX_DISCUSSION_BYTES / 1024} KiB.`);
416
+ return reply(lines.join("\n"));
204
417
  }
205
- const latest = paper.entries.at(-1)?.id;
206
- const text = fresh.length ? render(fresh, MAX_LISTED_TEXT) : `No new entries${latest ? ` (latest ${latest})` : ""}.`;
207
- return { content: [{ type: "text", text }], details: { path } };
208
- }
209
- const text = cleanText(params.text ?? "", MAX_DISCUSSION_TEXT);
210
- if (!text) return { content: [{ type: "text", text: "Discussion text is required." }], details: {}, isError: true };
211
- if (params.action === "ask") {
212
- const added = await append(path, me, { kind: "question", text, ...(params.to ? { to: safeActor(params.to) } : {}) });
213
- if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
214
- const { entry, paper } = added;
215
- const reply = `Added ${entry.id}${entry.to ? ` (→ ${entry.to})` : ""}.` + piggyback(path, paper, me);
216
- return { content: [{ type: "text", text: reply }], details: { path, entry } };
418
+ const since = idArgument(params.since, "since", false);
419
+ if (action === "thread") {
420
+ const result = await broker.thread(idArgument(params.questionId, "questionId", true)!, since);
421
+ const lines = [
422
+ ...(result.missing && since === undefined ? [`(question d${result.id} is no longer retained)`] : []),
423
+ ...(result.question ? [messageLine(result.question)] : []),
424
+ ...result.answers.map(messageLine)
425
+ ];
426
+ if (since === undefined && !result.answers.length && !result.more) lines.push("(no answers yet)");
427
+ if (!lines.length) lines.push(`No answers newer than d${since}.`);
428
+ const last = result.answers.at(-1)?.id ?? result.question?.id;
429
+ if (result.more) lines.push(`(${plural(result.more, "more answer")}; thread since d${last})`);
430
+ return reply(lines.join("\n"));
431
+ }
432
+ const result = await broker.list(since);
433
+ if (!result.messages.length) {
434
+ return reply(since === undefined ? `No unread messages${result.latest ? ` (latest d${result.latest})` : ""}.` : `No messages for you newer than d${since}.`);
435
+ }
436
+ const more = result.more ? `\n(${result.more} more${since === undefined ? " unread; list again" : `; list since d${result.messages.at(-1)!.id}`})` : "";
437
+ return reply(result.messages.map(messageLine).join("\n") + more);
438
+ } catch (error) {
439
+ return failure(error instanceof Error ? error.message : String(error));
217
440
  }
218
- const questionId = cleanText(params.questionId ?? "", 24);
219
- if (!questionId) return { content: [{ type: "text", text: "questionId is required for an answer." }], details: {}, isError: true };
220
- const added = await append(path, me, { kind: "answer", questionId, text }, (paper) =>
221
- paper.entries.some((item) => item.kind === "question" && item.id === questionId) ? undefined : "Unknown discussion question: " + questionId);
222
- if (typeof added === "string") return { content: [{ type: "text", text: added }], details: {}, isError: true };
223
- const { entry, paper } = added;
224
- return { content: [{ type: "text", text: `Answered ${questionId} as ${entry.id}.` + piggyback(path, paper, me, questionId) }], details: { path, entry } };
225
441
  }
226
442
  });
443
+
444
+ // At a natural boundary only: a hidden notice joins the turn that is starting anyway. Discussion
445
+ // traffic never sends a message or triggers a turn, so agents cannot wake each other into a loop.
446
+ let notified: number | undefined;
447
+ pi.on?.("before_agent_start", async () => {
448
+ const broker = transport();
449
+ if (!broker) return;
450
+ let state: UnreadResult;
451
+ // Advisory only: a broker failure surfaces on the agent's next jar_discuss call instead of delaying this turn.
452
+ try { state = await broker.unread(); } catch { return; }
453
+ // Once per newest unread message: ignored mail is not re-announced every turn.
454
+ if (!state.unread || state.newest === notified) return;
455
+ notified = state.newest;
456
+ return { message: { customType: DISCUSSION_NOTICE, display: false,
457
+ content: `You have ${plural(state.unread, "unread discussion message")}. Use jar_discuss list when relevant.` } };
458
+ });
227
459
  }