@zetaloop/chappie 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zetaloop
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # Chappie
2
+
3
+ Use ChatGPT to edit files, run commands, and work with [Pi](https://github.com/earendil-works/pi) extensions. Supports multiple Pi sessions, images, and two-way file transfers.
4
+
5
+ ## Setup
6
+
7
+ Install through Pi:
8
+
9
+ ```sh
10
+ pi install npm:@zetaloop/chappie
11
+ ```
12
+
13
+ Add Chappie to your [otunnel](https://github.com/zetaloop/otunnel) configuration:
14
+
15
+ ```yaml
16
+ mcp:
17
+ commands:
18
+ - channel: main
19
+ command: pi --chappie
20
+ ```
21
+
22
+ Start otunnel with this configuration and add its tunnel as a developer-mode app in ChatGPT. Run Pi in your project:
23
+
24
+ ```sh
25
+ pi --provider chappie --model chatgpt
26
+ ```
27
+
28
+ Send a task in Pi, then ask ChatGPT to call `init`. Chappie pairs the chat with a ready Pi session.
29
+
30
+ ## Usage
31
+
32
+ Ask ChatGPT to work on the task using Chappie's tools. `chat` sends replies to Pi, and new Pi messages accompany subsequent tool results. Interactive tools display their prompts in Pi.
33
+
34
+ See the [tool guide](docs/tools.md) for session selection, batch calls, messages, and file and image transfers.
package/docs/tools.md ADDED
@@ -0,0 +1,133 @@
1
+ # Tools
2
+
3
+ | Tool | Purpose |
4
+ |---|---|
5
+ | `init` | Connect to a Pi session and read its environment, tools, skills, global `AGENTS.md`, and pending input. |
6
+ | `sessions` | List connected Pi sessions and the chat's default session. |
7
+ | `tools` | Read the selected session's active tool definitions. |
8
+ | `chat` | Send an assistant message to Pi. |
9
+ | `call` | Run one or more tools as a Pi batch. |
10
+ | `read` | Read local text or images. |
11
+ | `bash` | Execute a shell command. |
12
+ | `edit` | Apply text replacements. |
13
+ | `write` | Write text to a file. |
14
+ | `transfer` | Copy files between ChatGPT and Pi, or export a Pi image as a file. |
15
+
16
+ ## Sessions
17
+
18
+ Call `init` with `{}` to reuse the chat's session or pair with the next ready, unbound Pi session. Either side can arrive first. Sending a task in Pi starts its Chappie provider request.
19
+
20
+ `sessions` lists session IDs, working directories, names, and status. `ready` means the provider is accepting output, `executing` means Pi is handling an operation, and `idle` means the next operation will start a turn.
21
+
22
+ Use an ID from that list to select a default with `init`:
23
+
24
+ ```json
25
+ { "sessionId": "<session-id>" }
26
+ ```
27
+
28
+ The optional `sessionId` on other tools selects a session for that operation. For example, `read` can inspect another project:
29
+
30
+ ```json
31
+ { "path": "package.json", "sessionId": "<session-id>" }
32
+ ```
33
+
34
+ `sessions({ sessionId })` shows the selected session and retrieves available input and deferred results. It can be called while Pi is executing a tool batch.
35
+
36
+ Several chats can select the same Pi session, and one chat can address several sessions. Defaults are saved in `chappie.state.json` under Pi's agent directory. An existing binding waits for its Pi session to reconnect; `init` with another ID selects a different target.
37
+
38
+ ## Tool calls
39
+
40
+ `read`, `bash`, `edit`, and `write` accept Pi's tool parameters plus `sessionId`. Their descriptions provide the current schemas. For installed extension tools, call `tools` and use the returned name and parameters in `call`.
41
+
42
+ A single tool uses a one-item `calls` array. To request a batch:
43
+
44
+ ```json
45
+ {
46
+ "calls": [
47
+ { "name": "read", "arguments": { "path": "package.json" } },
48
+ { "name": "read", "arguments": { "path": "src/index.ts" } }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ Each batch returns its results together. Pi determines how its tools run within the batch. Separate calls run in order within one Pi session; different sessions can work independently.
54
+
55
+ Extension tools execute through Pi, including their interactive prompts. Results include each tool's name, call ID, error status, and original text or image content.
56
+
57
+ ChatGPT file inputs use the direct `transfer` tool. Its top-level `files` parameter lets the host prepare the files before sending them to Pi.
58
+
59
+ ## Messages and interrupted calls
60
+
61
+ Call `chat` to display a reply in Pi:
62
+
63
+ ```json
64
+ { "text": "Updated the parser and its callers." }
65
+ ```
66
+
67
+ Each call completes one assistant message. Later operations start another turn when Pi is idle. Use `chat` for text that should appear in Pi.
68
+
69
+ User messages consumed by Pi accompany later Chappie replies, including images. Steering is delivered when Pi consumes it; follow-up uses Pi's normal follow-up timing.
70
+
71
+ Explicit cancellation removes a queued request or asks Pi to stop its active batch. Available results from that batch accompany a later reply to the originating chat. `sessions` can retrieve them before another tool call. When ChatGPT stops without sending cancellation, local execution continues.
72
+
73
+ Host request deadlines include time spent in the queue. Use local facilities such as tmux for work intended to outlive one call.
74
+
75
+ ## Files
76
+
77
+ `transfer.paths` names files on the Pi machine. Relative paths resolve from the selected session's working directory. Absolute paths and `~/` work too, including Windows paths such as `C:/Tmp/report.zip`.
78
+
79
+ ### ChatGPT to Pi
80
+
81
+ Supply matching `paths` and `files` arrays:
82
+
83
+ ```json
84
+ {
85
+ "paths": ["assets/reference.png"],
86
+ "files": ["/mnt/data/reference.png"]
87
+ }
88
+ ```
89
+
90
+ `files` contains actual cloud paths or attachment references available to ChatGPT. The host converts them into file objects with download URLs before Chappie receives the call.
91
+
92
+ Multiple files are matched by array position:
93
+
94
+ ```json
95
+ {
96
+ "paths": ["assets/reference.png", "data/input.csv"],
97
+ "files": ["/mnt/data/reference.png", "/mnt/data/input.csv"]
98
+ }
99
+ ```
100
+
101
+ Chappie creates parent directories and streams each file into its destination. Existing targets produce an error. To replace a file:
102
+
103
+ ```json
104
+ {
105
+ "paths": ["assets/reference.png"],
106
+ "files": ["/mnt/data/reference.png"],
107
+ "overwrite": true
108
+ }
109
+ ```
110
+
111
+ Overwriting truncates the existing file. A failed or canceled download removes the incomplete target opened by that operation, including an overwritten target. Successful files in a batch remain in place; the result reports each file's byte count or error.
112
+
113
+ ### Pi to ChatGPT
114
+
115
+ Omit `files` to export existing files:
116
+
117
+ ```json
118
+ { "paths": ["build/output.zip", "renders/preview.png"] }
119
+ ```
120
+
121
+ The result contains resource links with file names, types, and sizes. ChatGPT retrieves the bytes and handles attachment creation and cloud-container access. This may prompt for confirmation.
122
+
123
+ Each resource refers to its original Pi session, even after the chat selects another default. Keep that Pi process and the source files available while ChatGPT reads them. Files are read when requested. After starting a new Pi process, export again to obtain a fresh reference.
124
+
125
+ For a directory, create an archive using a Pi tool and export that file.
126
+
127
+ ### Images
128
+
129
+ `read` sends images directly to ChatGPT for viewing. Images from Pi tools and user messages also include a `piImage` field containing a `chappie://` reference.
130
+
131
+ To analyze one in ChatGPT's cloud container, pass the returned reference in `transfer.paths`. This exports the image bytes held by Pi as a file. Include the image's owning `sessionId` when another session is selected.
132
+
133
+ To transfer the original image file, use its local path. Pi may resize or convert images for viewing.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@zetaloop/chappie",
3
+ "version": "0.1.0",
4
+ "description": "Connect ChatGPT to Pi",
5
+ "devDependencies": {
6
+ "@earendil-works/pi-ai": "^0.85.1",
7
+ "@earendil-works/pi-coding-agent": "^0.85.1",
8
+ "@types/node": "^26.5.1",
9
+ "typebox": "^1.3.30",
10
+ "typescript": "^7.0.2"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "chatgpt",
15
+ "mcp"
16
+ ],
17
+ "author": "zetaloop",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "engines": {
21
+ "node": ">=26",
22
+ "pnpm": ">=12"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ]
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "typebox": "*"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/server": "^2.0.0",
36
+ "mime": "^4.1.0",
37
+ "zod": "^4.6.4"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "docs"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/zetaloop/chappie.git"
49
+ },
50
+ "scripts": {
51
+ "format": "biome check --write .",
52
+ "check": "biome check . && tsc"
53
+ }
54
+ }
package/src/broker.ts ADDED
@@ -0,0 +1,448 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type {
5
+ AssistantMessage,
6
+ ToolCall,
7
+ ToolResultMessage,
8
+ } from "@earendil-works/pi-ai";
9
+ import {
10
+ type DeliveryRecord,
11
+ type ResolvedDelivery,
12
+ resolveDelivery,
13
+ } from "./delivery.ts";
14
+ import {
15
+ type BrokerMessage,
16
+ IpcServer,
17
+ type JsonLinePeer,
18
+ type SessionDescription,
19
+ type SessionInput,
20
+ type SessionInspection,
21
+ type SessionMessage,
22
+ type SessionResult,
23
+ } from "./ipc.ts";
24
+ import { type ResourceData, resourceSessionId } from "./resources.ts";
25
+ import { State } from "./state.ts";
26
+ import type { ToolInput } from "./tools.ts";
27
+
28
+ interface RegisteredSession {
29
+ description: SessionDescription;
30
+ peer: JsonLinePeer<SessionMessage, BrokerMessage>;
31
+ }
32
+
33
+ interface PendingRequest {
34
+ peer: JsonLinePeer<SessionMessage, BrokerMessage>;
35
+ resolve(result: SessionResult): void;
36
+ reject(error: Error): void;
37
+ signal: AbortSignal;
38
+ onAbort(): void;
39
+ }
40
+
41
+ interface ChangeWaiter {
42
+ resolve(): void;
43
+ reject(error: Error): void;
44
+ signal: AbortSignal;
45
+ onAbort(): void;
46
+ }
47
+
48
+ export interface InitializedSession extends SessionInspection {
49
+ globalAgents?: string;
50
+ inputs: SessionInput[];
51
+ }
52
+
53
+ export interface InspectedSession extends SessionInspection {
54
+ inputs: SessionInput[];
55
+ }
56
+
57
+ export interface ChatResult {
58
+ message: AssistantMessage;
59
+ inputs: SessionInput[];
60
+ }
61
+
62
+ export interface CallResult {
63
+ sessionId: string;
64
+ toolResults: ToolResultMessage[];
65
+ inputs: SessionInput[];
66
+ }
67
+
68
+ export class Broker {
69
+ readonly #agentDir: string;
70
+ readonly #ipc: IpcServer;
71
+ readonly #state: State;
72
+ readonly #sessions = new Map<string, RegisteredSession>();
73
+ readonly #ready = new Set<string>();
74
+ readonly #pending = new Map<number, PendingRequest>();
75
+ readonly #waiters = new Set<ChangeWaiter>();
76
+ #nextRequestId = 1;
77
+
78
+ constructor(agentDir: string) {
79
+ this.#agentDir = agentDir;
80
+ this.#state = new State(agentDir);
81
+ this.#ipc = new IpcServer(
82
+ agentDir,
83
+ (peer, message) => this.#receive(peer, message),
84
+ (peer) => this.#removePeer(peer),
85
+ );
86
+ }
87
+
88
+ async start(): Promise<void> {
89
+ await this.#state.load();
90
+ await this.#ipc.start();
91
+ }
92
+
93
+ async close(): Promise<void> {
94
+ const error = new Error("Chappie broker ended");
95
+ for (const [id, pending] of this.#pending) {
96
+ this.#finishRequest(id, pending);
97
+ pending.reject(error);
98
+ }
99
+ for (const waiter of this.#waiters) {
100
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
101
+ waiter.reject(error);
102
+ }
103
+ this.#waiters.clear();
104
+ this.#sessions.clear();
105
+ this.#ready.clear();
106
+ await this.#ipc.close();
107
+ }
108
+
109
+ listSessions(sessionId?: string): SessionDescription[] {
110
+ if (sessionId) {
111
+ const session = this.#sessions.get(sessionId);
112
+ return session ? [session.description] : [];
113
+ }
114
+ return [...this.#sessions.values()].map(({ description }) => description);
115
+ }
116
+
117
+ binding(chatId: string): string | undefined {
118
+ return this.#state.binding(chatId);
119
+ }
120
+
121
+ async initialize(
122
+ chatId: string,
123
+ sessionId: string | undefined,
124
+ signal: AbortSignal,
125
+ ): Promise<InitializedSession> {
126
+ const target = await this.#selectSession(chatId, sessionId, signal, true);
127
+ const { inspection, inputs } = await this.#inspect(target, signal);
128
+ const globalAgents = await this.#readGlobalAgents();
129
+ await this.#ackInputs(target, inputs);
130
+ return { ...inspection, inputs, ...(globalAgents ? { globalAgents } : {}) };
131
+ }
132
+
133
+ async chat(
134
+ chatId: string,
135
+ sessionId: string | undefined,
136
+ text: string,
137
+ signal: AbortSignal,
138
+ ): Promise<ChatResult> {
139
+ const target = await this.#selectSession(chatId, sessionId, signal, false);
140
+ const result = await this.#request(
141
+ target,
142
+ (id) => ({ type: "chat", id, chatId, sessionId: target, text }),
143
+ signal,
144
+ );
145
+ if ("message" in result) {
146
+ await this.#ackInputs(target, result.inputs);
147
+ return { message: result.message, inputs: result.inputs };
148
+ }
149
+ throw new Error("Pi session returned no assistant message");
150
+ }
151
+
152
+ async tools(
153
+ chatId: string,
154
+ sessionId: string | undefined,
155
+ signal: AbortSignal,
156
+ ): Promise<InspectedSession> {
157
+ const target = await this.#selectSession(chatId, sessionId, signal, false);
158
+ const { inspection, inputs } = await this.#inspect(target, signal);
159
+ await this.#ackInputs(target, inputs);
160
+ return { ...inspection, inputs };
161
+ }
162
+
163
+ async call(
164
+ chatId: string,
165
+ sessionId: string | undefined,
166
+ calls: ToolInput[],
167
+ signal: AbortSignal,
168
+ ): Promise<CallResult> {
169
+ const target = await this.#selectSession(chatId, sessionId, signal, false);
170
+ const toolCalls: ToolCall[] = calls.map((call) => ({
171
+ type: "toolCall",
172
+ id: `chappie-${randomUUID()}`,
173
+ name: call.name,
174
+ arguments: call.arguments,
175
+ }));
176
+ const result = await this.#request(
177
+ target,
178
+ (id) => ({
179
+ type: "call",
180
+ id,
181
+ chatId,
182
+ sessionId: target,
183
+ calls: toolCalls,
184
+ }),
185
+ signal,
186
+ );
187
+ if ("toolResults" in result) {
188
+ await this.#ackInputs(target, result.inputs);
189
+ return {
190
+ sessionId: target,
191
+ toolResults: result.toolResults,
192
+ inputs: result.inputs,
193
+ };
194
+ }
195
+ throw new Error("Pi session returned no tool results");
196
+ }
197
+
198
+ async inputs(
199
+ chatId: string,
200
+ sessionId: string | undefined,
201
+ signal: AbortSignal,
202
+ ): Promise<SessionInput[]> {
203
+ const target = sessionId ?? this.#state.binding(chatId);
204
+ if (!target) return [];
205
+ await this.#waitForSession(target, signal);
206
+ const { inputs } = await this.#inspect(target, signal);
207
+ await this.#ackInputs(target, inputs);
208
+ return inputs;
209
+ }
210
+
211
+ async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
212
+ const sessionId = resourceSessionId(uri);
213
+ await this.#waitForSession(sessionId, signal);
214
+ const result = await this.#request(
215
+ sessionId,
216
+ (id) => ({ type: "readResource", id, sessionId, uri }),
217
+ signal,
218
+ );
219
+ if ("resource" in result) return result.resource;
220
+ throw new Error("Pi session returned no resource");
221
+ }
222
+
223
+ async deliveries(chatId: string): Promise<ResolvedDelivery[]> {
224
+ return Promise.all(this.#state.deliveries(chatId).map(resolveDelivery));
225
+ }
226
+
227
+ async acknowledgeDeliveries(
228
+ deliveries: DeliveryRecord[],
229
+ signal: AbortSignal,
230
+ ): Promise<void> {
231
+ if (deliveries.length === 0) return;
232
+ if (signal.aborted) throw abortError(signal);
233
+ await this.#state.removeDeliveries(deliveries.map(({ id }) => id));
234
+ if (!signal.aborted) return;
235
+ await this.#state.restoreDeliveries(deliveries);
236
+ throw abortError(signal);
237
+ }
238
+
239
+ async #receive(
240
+ peer: JsonLinePeer<SessionMessage, BrokerMessage>,
241
+ message: SessionMessage,
242
+ ): Promise<void> {
243
+ switch (message.type) {
244
+ case "sync": {
245
+ const previous = this.#sessions.get(message.session.id);
246
+ this.#sessions.set(message.session.id, {
247
+ description: message.session,
248
+ peer,
249
+ });
250
+ if (message.session.status === "ready") {
251
+ if (previous?.description.status !== "ready")
252
+ this.#ready.add(message.session.id);
253
+ } else {
254
+ this.#ready.delete(message.session.id);
255
+ }
256
+ this.#notifyChange();
257
+ await peer.send({
258
+ type: "synced",
259
+ id: message.id,
260
+ sessionId: message.session.id,
261
+ });
262
+ break;
263
+ }
264
+ case "unregister": {
265
+ const session = this.#sessions.get(message.sessionId);
266
+ if (session?.peer === peer) this.#removeSession(message.sessionId);
267
+ break;
268
+ }
269
+ case "delivery":
270
+ await this.#state.addDelivery(message.delivery);
271
+ await peer.send({ type: "stored", id: message.delivery.id });
272
+ break;
273
+ case "result": {
274
+ const pending = this.#pending.get(message.id);
275
+ if (!pending || pending.peer !== peer) break;
276
+ this.#finishRequest(message.id, pending);
277
+ if ("error" in message) pending.reject(new Error(message.error));
278
+ else {
279
+ const { type: _type, id: _id, ...result } = message;
280
+ pending.resolve(result);
281
+ }
282
+ break;
283
+ }
284
+ }
285
+ }
286
+
287
+ async #selectSession(
288
+ chatId: string,
289
+ requestedId: string | undefined,
290
+ signal: AbortSignal,
291
+ bindRequested: boolean,
292
+ ): Promise<string> {
293
+ if (requestedId) {
294
+ await this.#waitForSession(requestedId, signal);
295
+ if (bindRequested && this.#state.binding(chatId) !== requestedId) {
296
+ await this.#state.setBinding(chatId, requestedId);
297
+ this.#notifyChange();
298
+ }
299
+ return requestedId;
300
+ }
301
+
302
+ const boundId = this.#state.binding(chatId);
303
+ if (boundId) {
304
+ await this.#waitForSession(boundId, signal);
305
+ return boundId;
306
+ }
307
+
308
+ for (;;) {
309
+ const occupied = this.#state.boundSessions();
310
+ const candidate = [...this.#ready].find(
311
+ (sessionId) =>
312
+ this.#sessions.has(sessionId) && !occupied.has(sessionId),
313
+ );
314
+ if (candidate) {
315
+ await this.#state.setBinding(chatId, candidate);
316
+ this.#notifyChange();
317
+ return candidate;
318
+ }
319
+ await this.#waitForChange(signal);
320
+ }
321
+ }
322
+
323
+ async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
324
+ while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
325
+ }
326
+
327
+ async #inspect(
328
+ sessionId: string,
329
+ signal: AbortSignal,
330
+ ): Promise<Extract<SessionResult, { inspection: SessionInspection }>> {
331
+ const result = await this.#request(
332
+ sessionId,
333
+ (id) => ({ type: "inspect", id, sessionId }),
334
+ signal,
335
+ );
336
+ if ("inspection" in result) return result;
337
+ throw new Error("Pi session returned no inspection");
338
+ }
339
+
340
+ async #ackInputs(sessionId: string, inputs: SessionInput[]): Promise<void> {
341
+ if (inputs.length === 0) return;
342
+ const session = this.#sessions.get(sessionId);
343
+ if (!session) throw new Error(`Pi session ${sessionId} is offline`);
344
+ await session.peer.send({
345
+ type: "ackInputs",
346
+ sessionId,
347
+ ids: inputs.map(({ id }) => id),
348
+ });
349
+ }
350
+
351
+ async #request(
352
+ sessionId: string,
353
+ message: (id: number) => BrokerMessage,
354
+ signal: AbortSignal,
355
+ ): Promise<SessionResult> {
356
+ const session = this.#sessions.get(sessionId);
357
+ if (!session) throw new Error(`Pi session ${sessionId} is offline`);
358
+ if (signal.aborted) throw abortError(signal);
359
+ const id = this.#nextRequestId++;
360
+ const completion = Promise.withResolvers<SessionResult>();
361
+ const onAbort = (): void => {
362
+ const pending = this.#pending.get(id);
363
+ if (!pending) return;
364
+ void pending.peer.send({ type: "cancel", id, sessionId }).catch(() => {});
365
+ this.#finishRequest(id, pending);
366
+ pending.reject(abortError(signal));
367
+ };
368
+ const pending: PendingRequest = {
369
+ peer: session.peer,
370
+ resolve: completion.resolve,
371
+ reject: completion.reject,
372
+ signal,
373
+ onAbort,
374
+ };
375
+ this.#pending.set(id, pending);
376
+ signal.addEventListener("abort", onAbort, { once: true });
377
+ try {
378
+ await session.peer.send(message(id));
379
+ } catch (error) {
380
+ this.#finishRequest(id, pending);
381
+ throw error;
382
+ }
383
+ return completion.promise;
384
+ }
385
+
386
+ #waitForChange(signal: AbortSignal): Promise<void> {
387
+ if (signal.aborted) return Promise.reject(abortError(signal));
388
+ const completion = Promise.withResolvers<void>();
389
+ const onAbort = (): void => {
390
+ this.#waiters.delete(waiter);
391
+ waiter.reject(abortError(signal));
392
+ };
393
+ const waiter: ChangeWaiter = {
394
+ resolve: completion.resolve,
395
+ reject: completion.reject,
396
+ signal,
397
+ onAbort,
398
+ };
399
+ this.#waiters.add(waiter);
400
+ signal.addEventListener("abort", onAbort, { once: true });
401
+ return completion.promise;
402
+ }
403
+
404
+ #notifyChange(): void {
405
+ for (const waiter of this.#waiters) {
406
+ this.#waiters.delete(waiter);
407
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
408
+ waiter.resolve();
409
+ }
410
+ }
411
+
412
+ #finishRequest(id: number, pending: PendingRequest): void {
413
+ this.#pending.delete(id);
414
+ pending.signal.removeEventListener("abort", pending.onAbort);
415
+ }
416
+
417
+ #removePeer(peer: JsonLinePeer<SessionMessage, BrokerMessage>): void {
418
+ for (const [sessionId, session] of this.#sessions) {
419
+ if (session.peer === peer) this.#removeSession(sessionId);
420
+ }
421
+ for (const [id, pending] of this.#pending) {
422
+ if (pending.peer !== peer) continue;
423
+ this.#finishRequest(id, pending);
424
+ pending.reject(new Error("Pi session disconnected"));
425
+ }
426
+ }
427
+
428
+ #removeSession(sessionId: string): void {
429
+ this.#sessions.delete(sessionId);
430
+ this.#ready.delete(sessionId);
431
+ this.#notifyChange();
432
+ }
433
+
434
+ async #readGlobalAgents(): Promise<string | undefined> {
435
+ try {
436
+ return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
437
+ } catch (error) {
438
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
439
+ throw error;
440
+ }
441
+ }
442
+ }
443
+
444
+ function abortError(signal: AbortSignal): Error {
445
+ return signal.reason instanceof Error
446
+ ? signal.reason
447
+ : new Error("Request cancelled");
448
+ }