@zetaloop/chappie 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # Chappie
2
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.
3
+ Use ChatGPT to work through [Pi](https://github.com/earendil-works/pi): edit local files, run commands, call Pi extensions, exchange files and images, and move between sessions on one or more devices.
4
4
 
5
5
  ## Setup
6
6
 
7
- Install through Pi:
7
+ Install the Pi package:
8
8
 
9
9
  ```sh
10
10
  pi install npm:@zetaloop/chappie
11
11
  ```
12
12
 
13
- Add Chappie to your [otunnel](https://github.com/zetaloop/otunnel) configuration:
13
+ Run Chappie as the MCP server managed by [otunnel](https://github.com/zetaloop/otunnel):
14
14
 
15
15
  ```yaml
16
16
  mcp:
@@ -19,16 +19,36 @@ mcp:
19
19
  command: pi --chappie
20
20
  ```
21
21
 
22
- Start otunnel with this configuration and add its tunnel as a developer-mode app in ChatGPT. Run Pi in your project:
22
+ Add the tunnel as a developer-mode app in ChatGPT, then start Pi in a project:
23
23
 
24
24
  ```sh
25
25
  pi --provider chappie --model chatgpt
26
26
  ```
27
27
 
28
- Open Pi with the Chappie provider, then ask ChatGPT to call `init`. A chat without a default is paired with the first online, unbound Pi session. To resume work in a new chat or branch, use `init` with the original Pi session ID; `sessions` lists projects when the target ID is unknown.
28
+ Call `init` from ChatGPT to connect the conversation to Pi. A conversation can resume an existing task with its Pi session ID, while `sessions` can find connected sessions by device, directory, or name.
29
29
 
30
30
  ## Usage
31
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.
32
+ Chappie exposes common coding tools directly and every active Pi tool through `tools` and `call`. `chat` sends an assistant message to Pi, Pi input accompanies later tool results, and `transfer` moves files in either direction. `ask` can present a persistent question in ChatGPT when webpage questions are enabled.
33
33
 
34
- See the [tool guide](docs/tools.md) for session selection, batch calls, messages, and file and image transfers.
34
+ See the [tool guide](docs/tools.md) for session selection, Pi tools, webpage questions, and file transfer.
35
+
36
+ ## Configuration
37
+
38
+ `chappie.json` in Pi's agent directory configures Chappie.
39
+
40
+ A broker can accept Pi sessions from other devices on the local network:
41
+
42
+ ```json
43
+ { "listen": true }
44
+ ```
45
+
46
+ Remote Pi sessions connect through the broker device's mDNS name:
47
+
48
+ ```json
49
+ { "connect": "<broker>.local" }
50
+ ```
51
+
52
+ The default port is `24274`. Set `listen` to a port number or append `:port` to `connect` to use another one. Only the broker device runs otunnel; local and remote sessions appear in the same session list.
53
+
54
+ Set `ask` to `false` to disable webpage questions. Set `latestWorkflow` to `true` to let a newer otunnel workflow supersede older requests from the same ChatGPT conversation.
package/docs/tools.md CHANGED
@@ -2,62 +2,40 @@
2
2
 
3
3
  | Tool | Purpose |
4
4
  |---|---|
5
- | `init` | Connect to a Pi session and read its environment, tool catalog, skills, global `AGENTS.md`, and pending input. |
6
- | `sessions` | List connected Pi sessions and the chat's default session. |
7
- | `tools` | Read complete definitions for selected active tools. |
8
- | `chat` | Display Markdown as an assistant message in Pi. |
9
- | `call` | Run one or more tools as a Pi batch. |
5
+ | `init` | Select this ChatGPT conversation's default Pi session and read its environment. |
6
+ | `sessions` | List connected Pi sessions and the current default. |
7
+ | `tools` | Read full definitions of active Pi tools for `call`. |
8
+ | `chat` | Send an assistant message to Pi. |
9
+ | `ask` | Create a persistent question in ChatGPT. |
10
+ | `ask_assert` | Confirm that an `ask` widget loaded. |
11
+ | `call` | Run one or more Pi tools as one native batch. |
10
12
  | `read` | Read local text or images. |
11
- | `bash` | Execute a shell command. |
13
+ | `bash` | Run a shell command. |
12
14
  | `edit` | Apply text replacements. |
13
15
  | `write` | Write text to a file. |
14
- | `transfer` | Copy files between ChatGPT and Pi, or export a Pi image as a file. |
15
-
16
- ## Model environment
17
-
18
- The active model is the current ChatGPT conversation. A Pi tool that starts another `chappie/chatgpt` agent cannot create a new browser conversation, so that child waits without a model response. Subagents configured with another provider use that provider normally.
19
-
20
- Use ChatGPT's web search, connectors, and cloud tools for remote research and cloud-side work. Chappie tools operate on local files, processes, Pi extensions, and Pi user interfaces. Pi project-memory tools access their local stores; Pi context-reduction tools do not alter the current ChatGPT conversation.
21
-
22
- Use `chat` for progress or results that should appear in Pi. When a Pi user decision is needed, load the installed interactive tool definition with `tools` and invoke it through `call`.
16
+ | `transfer` | Move files between ChatGPT and Pi or export a Pi image. |
23
17
 
24
18
  ## Sessions
25
19
 
26
- For a new task without a specific target, call `init` with `{}` to reuse this chat's default or allocate the first online, unbound Pi session. Sessions register while `chappie/chatgpt` is selected. They can be blank or already contain a task; a remote operation starts a turn when Pi is idle.
27
-
28
- `sessions` lists session IDs, working directories, names, status, and `bindingCount`, the number of saved chat defaults pointing to each session. Zero means the session can be allocated automatically. The count includes closed chats; execution status describes Pi activity: `ready` accepts provider output, `executing` handles an operation, and `idle` starts a turn on the next operation.
29
-
30
- `init.selection` reports how the target was chosen: `existing` reuses this chat's default, `explicit` uses the supplied session ID, and `automatic` allocates the first online session with no saved bindings. Explicit selection also accepts sessions already used by other chats.
31
-
32
- To continue existing work in a new chat or branch, pass the Pi session ID associated with that task in the inherited context:
33
-
34
- ```json
35
- { "sessionId": "<session-id>" }
36
- ```
37
-
38
- This establishes the new chat's default, even when another chat already uses the same Pi session. For a requested project or session without a known ID, select it from `sessions` by working directory or name. When the target is absent or ambiguous, clarify the intended session before running tools. A session being the only one online does not establish that it is the requested target.
20
+ Call `init` at the start of local work. Without `sessionId`, it reuses the conversation's saved default or selects an online Pi session with no saved ChatGPT binding. Pass a Pi session ID to resume a specific task, including from another ChatGPT conversation or branch.
39
21
 
40
- The optional `sessionId` on other tools selects a session for that operation. For example, `read` can inspect another project:
22
+ `sessions` lists connected sessions with their ID, device, working directory, name, execution status, and binding count. A conversation can address another session for one operation by supplying that tool's optional `sessionId`; only `init({ sessionId })` changes the saved default.
41
23
 
42
- ```json
43
- { "path": "package.json", "sessionId": "<session-id>" }
44
- ```
24
+ Several ChatGPT conversations can use the same Pi session. One conversation can also operate on several Pi sessions explicitly. Requests already assigned to a session continue there even if the conversation later changes its default.
45
25
 
46
- `sessions({ sessionId })` filters the online list and retrieves available input when that session is connected. The call returns immediately when the selected or bound session is offline; the saved binding is still shown, and deferred results remain available.
26
+ Remote Pi sessions appear in the same list when they connect to a broker exposed through `listen` and `connect`. Their tools, global `AGENTS.md`, files, images, and Pi interfaces come from the remote device.
47
27
 
48
- 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.
28
+ ## Pi tools
49
29
 
50
- ## Tool calls
30
+ `read`, `bash`, `edit`, `write`, and `transfer` are available directly. `init` includes a short catalog of the active Pi tools; use `tools` for their complete definitions and `call` to invoke extension tools.
51
31
 
52
- `read`, `bash`, `edit`, and `write` accept Pi's tool parameters plus `sessionId`. Their descriptions provide the current schemas. The catalog in `init.tools` lists Pi's native and extension tools available through `call`. Chappie's `init`, `sessions`, `tools`, and `chat` are separate top-level MCP tools. Load complete definitions for installed extension tools before calling them:
32
+ For example:
53
33
 
54
34
  ```json
55
35
  { "names": ["ask_user", "ctx_search"] }
56
36
  ```
57
37
 
58
- Omit `names` to return every active definition. Definitions already present in the current ChatGPT context can be reused without another query.
59
-
60
- A single extension tool uses a one-item `calls` array. To request a batch:
38
+ A `call` array is one Pi tool batch:
61
39
 
62
40
  ```json
63
41
  {
@@ -68,48 +46,53 @@ A single extension tool uses a one-item `calls` array. To request a batch:
68
46
  }
69
47
  ```
70
48
 
71
- 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.
49
+ Pi controls execution inside that batch. Separate requests run in order within one Pi session, while different Pi sessions can work independently. Extension tools retain their native Pi behavior, including interactive interfaces.
50
+
51
+ `chat` creates a normal assistant message in Pi:
52
+
53
+ ```json
54
+ { "text": "Updated the parser and its callers." }
55
+ ```
72
56
 
73
- Extension tools execute through Pi, including their interactive prompts. Each batch starts with its executing `sessionId` and Pi `cwd`, followed by each tool's name, call ID, error status, and original text or image content. The directory is captured when the batch starts; a shell `cd` changes that command's working directory, while the operation stays in the same Pi session.
57
+ Pi user input consumed during the work accompanies later Chappie results, including images. Steering and follow-up follow Pi's own delivery timing.
74
58
 
75
- Every tool declares a `{ text: string }` output. `structuredContent.text` contains the complete text in result order, including Pi input, deferred results, and image references. The same text remains in `content` alongside native images and resource links.
59
+ If a request is explicitly cancelled after local work has produced results, those results can accompany a later response to the originating ChatGPT conversation. Long-running local work is better run through the environment's persistent process facilities instead of occupying one tool request.
76
60
 
77
- ChatGPT file inputs use the direct `transfer` tool. Its top-level `files` parameter lets the host prepare the files before sending them to Pi.
61
+ The active model remains the current ChatGPT conversation. Starting another `chappie/chatgpt` agent inside Pi does not create another browser conversation; tools that need another model should use a separately configured provider.
78
62
 
79
- ## Messages and interrupted calls
63
+ ## Webpage questions
80
64
 
81
- Call `chat` to display a reply in Pi:
65
+ When enabled, `ask` creates a question in ChatGPT and returns its ID immediately:
82
66
 
83
67
  ```json
84
- { "text": "Updated the parser and its callers." }
68
+ {
69
+ "header": "Export format",
70
+ "question": "Which export format should the command use?",
71
+ "context": "Both preserve the required data.",
72
+ "options": [
73
+ { "title": "JSON", "description": "Convenient for programs.", "recommended": true },
74
+ { "title": "CSV", "description": "Convenient for spreadsheets." }
75
+ ]
76
+ }
85
77
  ```
86
78
 
87
- Pi renders the supplied Markdown, including fenced code blocks, and appends the message to the session transcript. Each call completes one assistant message. Later operations start another turn when Pi is idle. The result returns the target `sessionId`, Pi `cwd`, and any new Pi input without repeating the message text.
79
+ Call `ask_assert` with the returned ID to confirm that the widget loaded:
88
80
 
89
- 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.
81
+ ```json
82
+ { "questionId": "<question-id>" }
83
+ ```
90
84
 
91
- 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, with the original session ID and working directory. `sessions` can retrieve them before another tool call. When ChatGPT stops without sending cancellation, local execution continues.
85
+ Answers, revisions, and skips arrive later as `webAnswer` in normal Chappie results. `options` can be omitted for a text answer, and `allowMultiple: true` allows several choices. `sessionId` associates the question with a Pi session without changing the conversation's default.
92
86
 
93
- Host request deadlines include time spent in the queue. Use local facilities such as tmux for work intended to outlive one call.
87
+ Questions remain available after the assistant response and across broker restarts. Pi's own interactive tools remain ordinary Pi tools and can be invoked through `call`.
94
88
 
95
89
  ## Files
96
90
 
97
- `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`.
91
+ `transfer.paths` always names paths or image references on the Pi side. Relative paths resolve from the selected Pi session's working directory; absolute paths and `~/` are accepted.
98
92
 
99
93
  ### ChatGPT to Pi
100
94
 
101
- Supply matching `paths` and `files` arrays:
102
-
103
- ```json
104
- {
105
- "paths": ["assets/reference.png"],
106
- "files": ["/mnt/data/reference.png"]
107
- }
108
- ```
109
-
110
- `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.
111
-
112
- Multiple files are matched by array position:
95
+ Pair Pi destinations with ChatGPT files:
113
96
 
114
97
  ```json
115
98
  {
@@ -118,7 +101,9 @@ Multiple files are matched by array position:
118
101
  }
119
102
  ```
120
103
 
121
- Chappie creates parent directories and streams each file into its destination. Existing targets produce an error. To replace a file:
104
+ The ChatGPT host turns the cloud paths or attachment references into downloadable file objects before the call reaches Chappie. Chappie creates parent directories and writes each file directly to its destination.
105
+
106
+ Existing targets produce an error by default. Use `overwrite: true` when replacement is intended:
122
107
 
123
108
  ```json
124
109
  {
@@ -128,26 +113,22 @@ Chappie creates parent directories and streams each file into its destination. E
128
113
  }
129
114
  ```
130
115
 
131
- 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.
116
+ A failed or cancelled transfer removes the incomplete destination opened by that operation. Successful members of a multi-file transfer remain in place.
132
117
 
133
118
  ### Pi to ChatGPT
134
119
 
135
- Omit `files` to export existing files:
120
+ Omit `files` to export existing Pi files:
136
121
 
137
122
  ```json
138
123
  { "paths": ["build/output.zip", "renders/preview.png"] }
139
124
  ```
140
125
 
141
- 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.
126
+ Chappie returns MCP resource links. ChatGPT retrieves the bytes when it materializes those resources, which can require user confirmation. A resource remains associated with the Pi session that exported it, so that Pi process and source file need to remain available until the bytes are read.
142
127
 
143
- 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.
144
-
145
- For a directory, create an archive using a Pi tool and export that file.
128
+ For a directory, create an archive with a Pi tool and export the resulting file.
146
129
 
147
130
  ### Images
148
131
 
149
- `read` sends images directly to ChatGPT for viewing. Images from Pi tools and user messages also include a `piImage` field containing a `chappie://` reference.
150
-
151
- 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
+ `read` and Pi tool results send images directly to ChatGPT for visual inspection. Chappie also returns a `chappie://` image reference with Pi images. Pass that reference to `transfer.paths` when the same bytes are needed as a file in ChatGPT's cloud environment.
152
133
 
153
- To transfer the original image file, use its local path. Pi may resize or convert images for viewing.
134
+ Use the original local path with `transfer` when the original image file is required; Pi can resize or convert images used only for display.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@zetaloop/chappie",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Connect ChatGPT to Pi",
5
5
  "devDependencies": {
6
6
  "@earendil-works/pi-ai": "^0.85.1",
7
7
  "@earendil-works/pi-coding-agent": "^0.85.1",
8
+ "@earendil-works/pi-tui": "^0.85.1",
8
9
  "@types/node": "^26.5.1",
9
10
  "typebox": "^1.3.30",
10
11
  "typescript": "^7.0.2"
@@ -34,6 +35,7 @@
34
35
  "peerDependencies": {
35
36
  "@earendil-works/pi-ai": "*",
36
37
  "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
37
39
  "typebox": "*"
38
40
  },
39
41
  "dependencies": {
package/src/broker.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
2
  import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
5
- import {
6
- type DeliveryRecord,
7
- type ResolvedDelivery,
8
- resolveDelivery,
9
- } from "./delivery.ts";
3
+ import { readConfig } from "./config.ts";
4
+ import type { DeliveryRecord } from "./delivery.ts";
10
5
  import {
11
6
  type BrokerMessage,
12
7
  IpcServer,
@@ -17,6 +12,13 @@ import {
17
12
  type SessionMessage,
18
13
  type SessionResult,
19
14
  } from "./ipc.ts";
15
+ import {
16
+ type Question,
17
+ type QuestionAnswer,
18
+ type QuestionInput,
19
+ type QuestionRecord,
20
+ questionView,
21
+ } from "./questions.ts";
20
22
  import { type ResourceData, resourceSessionId } from "./resources.ts";
21
23
  import { State } from "./state.ts";
22
24
  import type { ToolInput } from "./tools.ts";
@@ -34,6 +36,11 @@ interface PendingRequest {
34
36
  onAbort(): void;
35
37
  }
36
38
 
39
+ interface Workflow {
40
+ id: string;
41
+ controller: AbortController;
42
+ }
43
+
37
44
  interface ChangeWaiter {
38
45
  resolve(): void;
39
46
  reject(error: Error): void;
@@ -72,6 +79,9 @@ export class Broker {
72
79
  readonly #sessions = new Map<string, RegisteredSession>();
73
80
  readonly #pending = new Map<number, PendingRequest>();
74
81
  readonly #waiters = new Set<ChangeWaiter>();
82
+ readonly #workflows = new Map<string, Workflow>();
83
+ #ask = true;
84
+ #latestWorkflow = false;
75
85
  #nextRequestId = 1;
76
86
 
77
87
  constructor(agentDir: string) {
@@ -85,12 +95,19 @@ export class Broker {
85
95
  }
86
96
 
87
97
  async start(): Promise<void> {
98
+ const config = await readConfig(this.#agentDir);
99
+ this.#ask = config.ask ?? true;
100
+ this.#latestWorkflow = config.latestWorkflow ?? false;
88
101
  await this.#state.load();
89
- await this.#ipc.start();
102
+ await this.#ipc.start(config.listen ?? false);
90
103
  }
91
104
 
92
105
  async close(): Promise<void> {
93
106
  const error = new Error("Chappie broker ended");
107
+ for (const workflow of this.#workflows.values()) {
108
+ workflow.controller.abort(error);
109
+ }
110
+ this.#workflows.clear();
94
111
  for (const [id, pending] of this.#pending) {
95
112
  this.#finishRequest(id, pending);
96
113
  pending.reject(error);
@@ -104,6 +121,39 @@ export class Broker {
104
121
  await this.#ipc.close();
105
122
  }
106
123
 
124
+ async workflow(
125
+ chatId: string | undefined,
126
+ requestId: unknown,
127
+ signal: AbortSignal,
128
+ ): Promise<AbortSignal> {
129
+ if (!this.#latestWorkflow || !chatId) return signal;
130
+ const id =
131
+ typeof requestId === "string"
132
+ ? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
133
+ .exec(requestId)?.[0]
134
+ .toLowerCase()
135
+ : undefined;
136
+ if (!id) return signal;
137
+ signal.throwIfAborted();
138
+ const latest = this.#state.workflow(chatId);
139
+ const superseded = new Error(
140
+ "A newer workflow is handling this chat. Stop this workflow.",
141
+ );
142
+ // UUIDv7 puts the creation timestamp first, including across broker restarts.
143
+ if (latest && id < latest) throw superseded;
144
+ let workflow = this.#workflows.get(chatId);
145
+ if (!workflow || workflow.id !== id) {
146
+ const previous = workflow;
147
+ workflow = { id, controller: new AbortController() };
148
+ this.#workflows.set(chatId, workflow);
149
+ previous?.controller.abort(superseded);
150
+ if (latest !== id) await this.#state.setWorkflow(chatId, id);
151
+ }
152
+ const combined = AbortSignal.any([signal, workflow.controller.signal]);
153
+ combined.throwIfAborted();
154
+ return combined;
155
+ }
156
+
107
157
  listSessions(
108
158
  sessionId?: string,
109
159
  ): (SessionDescription & { bindingCount: number })[] {
@@ -116,6 +166,10 @@ export class Broker {
116
166
  }));
117
167
  }
118
168
 
169
+ get askEnabled(): boolean {
170
+ return this.#ask;
171
+ }
172
+
119
173
  binding(chatId: string): string | undefined {
120
174
  return this.#state.binding(chatId);
121
175
  }
@@ -125,14 +179,19 @@ export class Broker {
125
179
  sessionId: string | undefined,
126
180
  signal: AbortSignal,
127
181
  ): Promise<InitializedSession> {
182
+ const previous = this.#state.binding(chatId);
128
183
  const { sessionId: target, selection } = await this.#selectSession(
129
184
  chatId,
130
185
  sessionId,
131
186
  signal,
132
187
  true,
133
188
  );
134
- const { inspection, inputs } = await this.#inspect(target, signal);
135
- const globalAgents = await this.#readGlobalAgents();
189
+ if (previous === target)
190
+ await this.#notify(target, `ChatGPT ${chatId.slice(-4)} joined`);
191
+ const { inspection, inputs, globalAgents } = await this.#inspect(
192
+ target,
193
+ signal,
194
+ );
136
195
  await this.#ackInputs(target, inputs);
137
196
  return {
138
197
  selection,
@@ -247,6 +306,88 @@ export class Broker {
247
306
  return inputs;
248
307
  }
249
308
 
309
+ async ask(
310
+ chatId: string,
311
+ sessionId: string | undefined,
312
+ input: QuestionInput,
313
+ signal: AbortSignal,
314
+ ): Promise<Question> {
315
+ const { sessionId: target } = await this.#selectSession(
316
+ chatId,
317
+ sessionId,
318
+ signal,
319
+ false,
320
+ );
321
+ const session = this.#sessions.get(target);
322
+ if (!session) throw new Error(`Pi session ${target} is offline`);
323
+ const question: QuestionRecord = {
324
+ ...input,
325
+ id: randomUUID(),
326
+ chatId,
327
+ sessionId: target,
328
+ cwd: session.description.cwd,
329
+ delivered: false,
330
+ };
331
+ await this.#state.addQuestion(question);
332
+ void this.#notify(
333
+ target,
334
+ `ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
335
+ ).catch(() => {});
336
+ return questionView(question);
337
+ }
338
+
339
+ async assertQuestion(
340
+ chatId: string,
341
+ id: string,
342
+ signal: AbortSignal,
343
+ ): Promise<Question> {
344
+ for (;;) {
345
+ signal.throwIfAborted();
346
+ const question = this.#state.question(chatId, id);
347
+ if (question.loaded) return questionView(question);
348
+ await this.#waitForChange(signal);
349
+ }
350
+ }
351
+
352
+ async answer(
353
+ chatId: string,
354
+ id: string,
355
+ answer?: QuestionAnswer,
356
+ loaded = false,
357
+ ): Promise<Question> {
358
+ let question = this.#state.question(chatId, id);
359
+ if ((loaded || answer) && !question.loaded) {
360
+ question = { ...question, loaded: true };
361
+ await this.#state.addQuestion(question);
362
+ this.#notifyChange();
363
+ }
364
+ if (answer) {
365
+ const previous = question.answer;
366
+ question = await this.#state.answer(chatId, id, answer);
367
+ if (question.answer !== previous) {
368
+ const response = [
369
+ ...(question.answer?.selections ?? []).map(
370
+ (index) => question.options[index]?.title,
371
+ ),
372
+ question.answer?.text,
373
+ ]
374
+ .filter(Boolean)
375
+ .join(", ");
376
+ const message = question.answer?.skipped
377
+ ? `Skipped in ChatGPT ${chatId.slice(-4)}: ${question.question}`
378
+ : previous
379
+ ? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
380
+ : `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
381
+ void this.#notify(question.sessionId, message).catch(() => {});
382
+ }
383
+ }
384
+ return questionView(question);
385
+ }
386
+
387
+ answers(chatId: string): QuestionRecord[] {
388
+ return this.#state.answers(chatId);
389
+ }
390
+
250
391
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
251
392
  const sessionId = resourceSessionId(uri);
252
393
  await this.#waitForSession(sessionId, signal);
@@ -259,20 +400,16 @@ export class Broker {
259
400
  throw new Error("Pi session returned no resource");
260
401
  }
261
402
 
262
- async deliveries(chatId: string): Promise<ResolvedDelivery[]> {
263
- return Promise.all(this.#state.deliveries(chatId).map(resolveDelivery));
403
+ deliveries(chatId: string): DeliveryRecord[] {
404
+ return this.#state.deliveries(chatId);
264
405
  }
265
406
 
266
- async acknowledgeDeliveries(
407
+ acknowledge(
267
408
  deliveries: DeliveryRecord[],
409
+ answers: QuestionRecord[],
268
410
  signal: AbortSignal,
269
411
  ): Promise<void> {
270
- if (deliveries.length === 0) return;
271
- if (signal.aborted) throw abortError(signal);
272
- await this.#state.removeDeliveries(deliveries.map(({ id }) => id));
273
- if (!signal.aborted) return;
274
- await this.#state.restoreDeliveries(deliveries);
275
- throw abortError(signal);
412
+ return this.#state.acknowledge(deliveries, answers, signal);
276
413
  }
277
414
 
278
415
  async #receive(
@@ -281,16 +418,20 @@ export class Broker {
281
418
  ): Promise<void> {
282
419
  switch (message.type) {
283
420
  case "sync": {
421
+ const registered =
422
+ this.#sessions.get(message.session.id)?.peer === peer;
284
423
  this.#sessions.set(message.session.id, {
285
424
  description: message.session,
286
425
  peer,
287
426
  });
288
- this.#notifyChange();
289
427
  await peer.send({
290
428
  type: "synced",
291
429
  id: message.id,
292
430
  sessionId: message.session.id,
293
431
  });
432
+ if (!registered)
433
+ await this.#notify(message.session.id, "Chappie connected");
434
+ this.#notifyChange();
294
435
  break;
295
436
  }
296
437
  case "unregister": {
@@ -328,8 +469,7 @@ export class Broker {
328
469
  if (requestedId) {
329
470
  await this.#waitForSession(requestedId, signal);
330
471
  if (bindRequested && this.#state.binding(chatId) !== requestedId) {
331
- await this.#state.setBinding(chatId, requestedId);
332
- this.#notifyChange();
472
+ await this.#bind(chatId, requestedId);
333
473
  }
334
474
  return { sessionId: requestedId, selection: "explicit" };
335
475
  }
@@ -346,14 +486,32 @@ export class Broker {
346
486
  (sessionId) => !occupied.has(sessionId),
347
487
  );
348
488
  if (candidate) {
349
- await this.#state.setBinding(chatId, candidate);
350
- this.#notifyChange();
489
+ await this.#bind(chatId, candidate);
351
490
  return { sessionId: candidate, selection: "automatic" };
352
491
  }
353
492
  await this.#waitForChange(signal);
354
493
  }
355
494
  }
356
495
 
496
+ async #bind(chatId: string, sessionId: string): Promise<void> {
497
+ const previous = this.#state.binding(chatId);
498
+ await this.#state.setBinding(chatId, sessionId);
499
+ this.#notifyChange();
500
+ if (previous) {
501
+ await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
502
+ if (this.#state.chats(previous).length === 0) {
503
+ await this.#notify(previous, "Ready for ChatGPT");
504
+ }
505
+ }
506
+ await this.#notify(sessionId, `ChatGPT ${chatId.slice(-4)} joined`);
507
+ }
508
+
509
+ async #notify(sessionId: string, message: string): Promise<void> {
510
+ await this.#sessions
511
+ .get(sessionId)
512
+ ?.peer.send({ type: "notice", sessionId, message });
513
+ }
514
+
357
515
  async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
358
516
  while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
359
517
  }
@@ -395,7 +553,14 @@ export class Broker {
395
553
  const onAbort = (): void => {
396
554
  const pending = this.#pending.get(id);
397
555
  if (!pending) return;
398
- void pending.peer.send({ type: "cancel", id, sessionId }).catch(() => {});
556
+ void pending.peer
557
+ .send({
558
+ type: "cancel",
559
+ id,
560
+ sessionId,
561
+ reason: abortError(signal).message,
562
+ })
563
+ .catch(() => {});
399
564
  this.#finishRequest(id, pending);
400
565
  pending.reject(abortError(signal));
401
566
  };
@@ -463,19 +628,12 @@ export class Broker {
463
628
  this.#sessions.delete(sessionId);
464
629
  this.#notifyChange();
465
630
  }
466
-
467
- async #readGlobalAgents(): Promise<string | undefined> {
468
- try {
469
- return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
470
- } catch (error) {
471
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
472
- throw error;
473
- }
474
- }
475
631
  }
476
632
 
477
633
  function abortError(signal: AbortSignal): Error {
478
634
  return signal.reason instanceof Error
479
635
  ? signal.reason
480
- : new Error("Request cancelled");
636
+ : new Error(
637
+ typeof signal.reason === "string" ? signal.reason : "Request cancelled",
638
+ );
481
639
  }