@zetaloop/chappie 0.1.0 → 0.2.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
@@ -25,7 +25,7 @@ Start otunnel with this configuration and add its tunnel as a developer-mode app
25
25
  pi --provider chappie --model chatgpt
26
26
  ```
27
27
 
28
- Send a task in Pi, then ask ChatGPT to call `init`. Chappie pairs the chat with a ready Pi session.
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.
29
29
 
30
30
  ## Usage
31
31
 
package/docs/tools.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  | Tool | Purpose |
4
4
  |---|---|
5
- | `init` | Connect to a Pi session and read its environment, tools, skills, global `AGENTS.md`, and pending input. |
5
+ | `init` | Connect to a Pi session and read its environment, tool catalog, skills, global `AGENTS.md`, and pending input. |
6
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. |
7
+ | `tools` | Read complete definitions for selected active tools. |
8
+ | `chat` | Display Markdown as an assistant message in Pi. |
9
9
  | `call` | Run one or more tools as a Pi batch. |
10
10
  | `read` | Read local text or images. |
11
11
  | `bash` | Execute a shell command. |
@@ -13,33 +13,51 @@
13
13
  | `write` | Write text to a file. |
14
14
  | `transfer` | Copy files between ChatGPT and Pi, or export a Pi image as a file. |
15
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`.
23
+
16
24
  ## Sessions
17
25
 
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.
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.
19
29
 
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.
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.
21
31
 
22
- Use an ID from that list to select a default with `init`:
32
+ To continue existing work in a new chat or branch, pass the Pi session ID associated with that task in the inherited context:
23
33
 
24
34
  ```json
25
35
  { "sessionId": "<session-id>" }
26
36
  ```
27
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.
39
+
28
40
  The optional `sessionId` on other tools selects a session for that operation. For example, `read` can inspect another project:
29
41
 
30
42
  ```json
31
43
  { "path": "package.json", "sessionId": "<session-id>" }
32
44
  ```
33
45
 
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.
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.
35
47
 
36
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.
37
49
 
38
50
  ## Tool calls
39
51
 
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`.
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:
41
53
 
42
- A single tool uses a one-item `calls` array. To request a batch:
54
+ ```json
55
+ { "names": ["ask_user", "ctx_search"] }
56
+ ```
57
+
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:
43
61
 
44
62
  ```json
45
63
  {
@@ -52,7 +70,9 @@ A single tool uses a one-item `calls` array. To request a batch:
52
70
 
53
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.
54
72
 
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.
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.
74
+
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.
56
76
 
57
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.
58
78
 
@@ -64,11 +84,11 @@ Call `chat` to display a reply in Pi:
64
84
  { "text": "Updated the parser and its callers." }
65
85
  ```
66
86
 
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.
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.
68
88
 
69
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.
70
90
 
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.
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.
72
92
 
73
93
  Host request deadlines include time spent in the queue. Use local facilities such as tmux for work intended to outlive one call.
74
94
 
package/package.json CHANGED
@@ -1,54 +1,55 @@
1
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
- }
2
+ "name": "@zetaloop/chappie",
3
+ "version": "0.2.1",
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
+ "scripts": {
13
+ "format": "biome check --write .",
14
+ "check": "biome check . && tsc"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "chatgpt",
19
+ "mcp"
20
+ ],
21
+ "author": "zetaloop",
22
+ "license": "MIT",
23
+ "packageManager": "pnpm@12.4.1",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=26",
27
+ "pnpm": ">=12"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/server": "^2.0.0",
41
+ "mime": "^4.1.0",
42
+ "zod": "^4.6.4"
43
+ },
44
+ "files": [
45
+ "src",
46
+ "docs"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/zetaloop/chappie.git"
54
+ }
55
+ }
package/src/broker.ts CHANGED
@@ -1,11 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import type {
5
- AssistantMessage,
6
- ToolCall,
7
- ToolResultMessage,
8
- } from "@earendil-works/pi-ai";
4
+ import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
9
5
  import {
10
6
  type DeliveryRecord,
11
7
  type ResolvedDelivery,
@@ -45,9 +41,11 @@ interface ChangeWaiter {
45
41
  onAbort(): void;
46
42
  }
47
43
 
48
- export interface InitializedSession extends SessionInspection {
44
+ export interface InitializedSession extends Omit<SessionInspection, "tools"> {
45
+ selection: "existing" | "explicit" | "automatic";
49
46
  globalAgents?: string;
50
47
  inputs: SessionInput[];
48
+ tools: { name: string; description: string }[];
51
49
  }
52
50
 
53
51
  export interface InspectedSession extends SessionInspection {
@@ -55,12 +53,14 @@ export interface InspectedSession extends SessionInspection {
55
53
  }
56
54
 
57
55
  export interface ChatResult {
58
- message: AssistantMessage;
56
+ sessionId: string;
57
+ cwd: string;
59
58
  inputs: SessionInput[];
60
59
  }
61
60
 
62
61
  export interface CallResult {
63
62
  sessionId: string;
63
+ cwd: string;
64
64
  toolResults: ToolResultMessage[];
65
65
  inputs: SessionInput[];
66
66
  }
@@ -70,7 +70,6 @@ export class Broker {
70
70
  readonly #ipc: IpcServer;
71
71
  readonly #state: State;
72
72
  readonly #sessions = new Map<string, RegisteredSession>();
73
- readonly #ready = new Set<string>();
74
73
  readonly #pending = new Map<number, PendingRequest>();
75
74
  readonly #waiters = new Set<ChangeWaiter>();
76
75
  #nextRequestId = 1;
@@ -102,16 +101,19 @@ export class Broker {
102
101
  }
103
102
  this.#waiters.clear();
104
103
  this.#sessions.clear();
105
- this.#ready.clear();
106
104
  await this.#ipc.close();
107
105
  }
108
106
 
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);
107
+ listSessions(
108
+ sessionId?: string,
109
+ ): (SessionDescription & { bindingCount: number })[] {
110
+ const counts = this.#state.bindingCounts();
111
+ return [...this.#sessions.values()]
112
+ .filter(({ description }) => !sessionId || description.id === sessionId)
113
+ .map(({ description }) => ({
114
+ ...description,
115
+ bindingCount: counts.get(description.id) ?? 0,
116
+ }));
115
117
  }
116
118
 
117
119
  binding(chatId: string): string | undefined {
@@ -123,11 +125,25 @@ export class Broker {
123
125
  sessionId: string | undefined,
124
126
  signal: AbortSignal,
125
127
  ): Promise<InitializedSession> {
126
- const target = await this.#selectSession(chatId, sessionId, signal, true);
128
+ const { sessionId: target, selection } = await this.#selectSession(
129
+ chatId,
130
+ sessionId,
131
+ signal,
132
+ true,
133
+ );
127
134
  const { inspection, inputs } = await this.#inspect(target, signal);
128
135
  const globalAgents = await this.#readGlobalAgents();
129
136
  await this.#ackInputs(target, inputs);
130
- return { ...inspection, inputs, ...(globalAgents ? { globalAgents } : {}) };
137
+ return {
138
+ selection,
139
+ ...inspection,
140
+ tools: inspection.tools.map(({ name, description }) => ({
141
+ name,
142
+ description: description.split("\n", 1)[0] ?? description,
143
+ })),
144
+ inputs,
145
+ ...(globalAgents ? { globalAgents } : {}),
146
+ };
131
147
  }
132
148
 
133
149
  async chat(
@@ -136,7 +152,12 @@ export class Broker {
136
152
  text: string,
137
153
  signal: AbortSignal,
138
154
  ): Promise<ChatResult> {
139
- const target = await this.#selectSession(chatId, sessionId, signal, false);
155
+ const { sessionId: target } = await this.#selectSession(
156
+ chatId,
157
+ sessionId,
158
+ signal,
159
+ false,
160
+ );
140
161
  const result = await this.#request(
141
162
  target,
142
163
  (id) => ({ type: "chat", id, chatId, sessionId: target, text }),
@@ -144,7 +165,7 @@ export class Broker {
144
165
  );
145
166
  if ("message" in result) {
146
167
  await this.#ackInputs(target, result.inputs);
147
- return { message: result.message, inputs: result.inputs };
168
+ return { sessionId: target, cwd: result.cwd, inputs: result.inputs };
148
169
  }
149
170
  throw new Error("Pi session returned no assistant message");
150
171
  }
@@ -152,12 +173,25 @@ export class Broker {
152
173
  async tools(
153
174
  chatId: string,
154
175
  sessionId: string | undefined,
176
+ names: string[] | undefined,
155
177
  signal: AbortSignal,
156
178
  ): Promise<InspectedSession> {
157
- const target = await this.#selectSession(chatId, sessionId, signal, false);
179
+ const { sessionId: target } = await this.#selectSession(
180
+ chatId,
181
+ sessionId,
182
+ signal,
183
+ false,
184
+ );
158
185
  const { inspection, inputs } = await this.#inspect(target, signal);
159
186
  await this.#ackInputs(target, inputs);
160
- return { ...inspection, inputs };
187
+ const selected = names ? new Set(names) : undefined;
188
+ return {
189
+ ...inspection,
190
+ tools: selected
191
+ ? inspection.tools.filter(({ name }) => selected.has(name))
192
+ : inspection.tools,
193
+ inputs,
194
+ };
161
195
  }
162
196
 
163
197
  async call(
@@ -166,7 +200,12 @@ export class Broker {
166
200
  calls: ToolInput[],
167
201
  signal: AbortSignal,
168
202
  ): Promise<CallResult> {
169
- const target = await this.#selectSession(chatId, sessionId, signal, false);
203
+ const { sessionId: target } = await this.#selectSession(
204
+ chatId,
205
+ sessionId,
206
+ signal,
207
+ false,
208
+ );
170
209
  const toolCalls: ToolCall[] = calls.map((call) => ({
171
210
  type: "toolCall",
172
211
  id: `chappie-${randomUUID()}`,
@@ -188,6 +227,7 @@ export class Broker {
188
227
  await this.#ackInputs(target, result.inputs);
189
228
  return {
190
229
  sessionId: target,
230
+ cwd: result.cwd,
191
231
  toolResults: result.toolResults,
192
232
  inputs: result.inputs,
193
233
  };
@@ -201,8 +241,7 @@ export class Broker {
201
241
  signal: AbortSignal,
202
242
  ): Promise<SessionInput[]> {
203
243
  const target = sessionId ?? this.#state.binding(chatId);
204
- if (!target) return [];
205
- await this.#waitForSession(target, signal);
244
+ if (!target || !this.#sessions.has(target)) return [];
206
245
  const { inputs } = await this.#inspect(target, signal);
207
246
  await this.#ackInputs(target, inputs);
208
247
  return inputs;
@@ -242,17 +281,10 @@ export class Broker {
242
281
  ): Promise<void> {
243
282
  switch (message.type) {
244
283
  case "sync": {
245
- const previous = this.#sessions.get(message.session.id);
246
284
  this.#sessions.set(message.session.id, {
247
285
  description: message.session,
248
286
  peer,
249
287
  });
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
288
  this.#notifyChange();
257
289
  await peer.send({
258
290
  type: "synced",
@@ -289,32 +321,34 @@ export class Broker {
289
321
  requestedId: string | undefined,
290
322
  signal: AbortSignal,
291
323
  bindRequested: boolean,
292
- ): Promise<string> {
324
+ ): Promise<{
325
+ sessionId: string;
326
+ selection: InitializedSession["selection"];
327
+ }> {
293
328
  if (requestedId) {
294
329
  await this.#waitForSession(requestedId, signal);
295
330
  if (bindRequested && this.#state.binding(chatId) !== requestedId) {
296
331
  await this.#state.setBinding(chatId, requestedId);
297
332
  this.#notifyChange();
298
333
  }
299
- return requestedId;
334
+ return { sessionId: requestedId, selection: "explicit" };
300
335
  }
301
336
 
302
337
  const boundId = this.#state.binding(chatId);
303
338
  if (boundId) {
304
339
  await this.#waitForSession(boundId, signal);
305
- return boundId;
340
+ return { sessionId: boundId, selection: "existing" };
306
341
  }
307
342
 
308
343
  for (;;) {
309
- const occupied = this.#state.boundSessions();
310
- const candidate = [...this.#ready].find(
311
- (sessionId) =>
312
- this.#sessions.has(sessionId) && !occupied.has(sessionId),
344
+ const occupied = this.#state.bindingCounts();
345
+ const candidate = [...this.#sessions.keys()].find(
346
+ (sessionId) => !occupied.has(sessionId),
313
347
  );
314
348
  if (candidate) {
315
349
  await this.#state.setBinding(chatId, candidate);
316
350
  this.#notifyChange();
317
- return candidate;
351
+ return { sessionId: candidate, selection: "automatic" };
318
352
  }
319
353
  await this.#waitForChange(signal);
320
354
  }
@@ -427,7 +461,6 @@ export class Broker {
427
461
 
428
462
  #removeSession(sessionId: string): void {
429
463
  this.#sessions.delete(sessionId);
430
- this.#ready.delete(sessionId);
431
464
  this.#notifyChange();
432
465
  }
433
466
 
package/src/delivery.ts CHANGED
@@ -9,6 +9,7 @@ export interface DeliveryRecord {
9
9
  id: string;
10
10
  chatId: string;
11
11
  sessionId: string;
12
+ cwd: string;
12
13
  sessionFile?: string;
13
14
  toolCallIds: string[];
14
15
  inlineResults?: ToolResultMessage[];
@@ -57,6 +58,7 @@ export function deliveryContent(deliveries: ResolvedDelivery[]) {
57
58
  text: JSON.stringify({
58
59
  deferredResult: delivery.id,
59
60
  sessionId: delivery.sessionId,
61
+ cwd: delivery.cwd,
60
62
  error: delivery.error,
61
63
  }),
62
64
  },
@@ -1,7 +1,13 @@
1
- Chappie connects this ChatGPT conversation to local Pi sessions. Call init before starting work. Omitting sessionId reuses the current binding or pairs with the next Pi session waiting for ChatGPT; specifying sessionId on init selects that Pi session as the new default. A sessionId on any other tool affects only that operation. Use sessions to inspect connected sessions without changing the binding.
1
+ Chappie connects this ChatGPT conversation to local Pi sessions. To continue existing work, call init with the Pi sessionId associated with that task in the context. A new chat or branch establishes its own binding to that ID. For a requested project or session without a known ID, use sessions to match its cwd or name, then init with the chosen ID. If the intended target is absent or ambiguous, resolve the selection with the user before starting work. Pi sessions register while the chappie/chatgpt provider is selected.
2
2
 
3
- Use chat to send one complete assistant message to Pi. Use read, bash, edit, and write directly. Use tools to inspect other active Pi tools and call to execute one or more of them as one native Pi batch. Separate calls remain separate Pi turns; use a call array when tools should share a batch.
3
+ For work without a specific target, init without sessionId reuses the current binding or allocates the first online session with bindingCount zero. These sessions may be blank or already contain a task. Saved bindings persist across broker restarts; explicit init(sessionId) can share a session already used by another chat. init reports its selection as existing, explicit, or automatic. A sessionId on any other tool affects only that operation.
4
+
5
+ The active model is this existing ChatGPT conversation. A Pi tool that starts another chappie/chatgpt agent has no ChatGPT conversation to attach to and will wait indefinitely. Subagents targeting another configured model keep that provider's normal behavior.
6
+
7
+ Prefer ChatGPT's web search, connectors, and cloud tools for remote research and cloud-side work. Use Chappie for local files, processes, Pi extensions, and Pi user interfaces. Pi project-memory tools operate on their local stores; Pi context-reduction tools do not change this ChatGPT conversation.
8
+
9
+ Use chat to display Markdown progress and final messages in Pi, including code examples. Use read, bash, edit, and write directly. init.tools lists Pi's native and extension tools for call; Chappie's init, sessions, tools, and chat are separate top-level MCP tools. Use tools with names to load complete definitions before calling other Pi tools. Use an installed interactive tool through call when input is needed in Pi. Separate calls remain separate Pi turns; use a call array when tools should share a batch.
4
10
 
5
11
  Use transfer with paths and files to copy ChatGPT files into Pi. Omit files to expose existing Pi paths or chappie:// image references as MCP resources. Relative paths use the Pi working directory, existing targets require overwrite: true, and resource materialization may require host confirmation.
6
12
 
7
- Tool replies may contain user input consumed by Pi or results from an earlier explicitly cancelled request. Continue from those results instead of repeating completed work. Use the local persistent-process facilities for operations that need to outlive one tool request.
13
+ Tool replies identify the executing sessionId and Pi cwd. A shell command can access another directory while its session and transcript remain the same. Replies provide their complete text in structuredContent.text, including user input consumed by Pi and results from an earlier explicitly cancelled request. Images and file resources accompany the text as native content blocks. Continue from those results instead of repeating completed work. Host request deadlines include queueing and execution; use local persistent-process facilities for work intended to outlive one request.
package/src/ipc.ts CHANGED
@@ -44,9 +44,10 @@ export interface SessionInput {
44
44
 
45
45
  export type SessionResult =
46
46
  | { inspection: SessionInspection; inputs: SessionInput[] }
47
- | { message: AssistantMessage; inputs: SessionInput[] }
47
+ | { message: AssistantMessage; cwd: string; inputs: SessionInput[] }
48
48
  | {
49
49
  message: AssistantMessage;
50
+ cwd: string;
50
51
  toolResults: ToolResultMessage[];
51
52
  inputs: SessionInput[];
52
53
  }
package/src/server.ts CHANGED
@@ -16,6 +16,14 @@ const instructions = readFileSync(
16
16
  "utf8",
17
17
  ).trim();
18
18
 
19
+ const outputSchema = z.object({
20
+ text: z
21
+ .string()
22
+ .describe(
23
+ "Complete text output, including Pi user input and deferred results. Images and file resources accompany it as native content blocks.",
24
+ ),
25
+ });
26
+
19
27
  interface RequestContext {
20
28
  mcpReq: {
21
29
  _meta?: Record<string, unknown>;
@@ -36,14 +44,20 @@ export function createServer(broker: Broker): McpServer {
36
44
  "init",
37
45
  {
38
46
  title: "Connect to Pi",
39
- description: "Connect this ChatGPT conversation to a Pi session.",
47
+ description:
48
+ "Bind this chat to a Pi session. To resume work or a ChatGPT branch, pass that task's Pi sessionId from context; use sessions to locate a requested target when its ID is unknown. Omit sessionId to reuse this chat's default or allocate the first online, unbound session. Returns the selection source.",
49
+ outputSchema,
40
50
  inputSchema: z.object({
41
51
  sessionId: z
42
52
  .string()
43
53
  .optional()
44
- .describe("Pi session to select explicitly"),
54
+ .describe(
55
+ "Pi session ID to set as this chat's default, including an already-bound session when resuming work",
56
+ ),
45
57
  }),
46
58
  annotations: {
59
+ readOnlyHint: false,
60
+ destructiveHint: false,
47
61
  openWorldHint: false,
48
62
  },
49
63
  },
@@ -62,27 +76,38 @@ export function createServer(broker: Broker): McpServer {
62
76
  "chat",
63
77
  {
64
78
  title: "Reply in Pi",
65
- description: "Send one complete assistant message to a Pi session.",
79
+ description:
80
+ "Display the supplied Markdown in Pi and append it to the session transcript. Code blocks are displayed as text.",
81
+ outputSchema,
66
82
  inputSchema: z.object({
67
- text: z.string().min(1).describe("Assistant message to display in Pi"),
83
+ text: z
84
+ .string()
85
+ .min(1)
86
+ .describe("Markdown message, including prose and code examples"),
68
87
  sessionId: z
69
88
  .string()
70
89
  .optional()
71
90
  .describe("Pi session for this operation only"),
72
91
  }),
73
92
  annotations: {
93
+ readOnlyHint: false,
94
+ destructiveHint: false,
74
95
  openWorldHint: false,
75
96
  },
76
97
  },
77
98
  async (args, context) => {
78
99
  const chatId = requireChatId(context);
79
- const { message, inputs } = await broker.chat(
100
+ const { sessionId, cwd, inputs } = await broker.chat(
80
101
  chatId,
81
102
  args.sessionId,
82
103
  args.text,
83
104
  context.mcpReq.signal,
84
105
  );
85
- return finishResult(broker, context, textResult({ message }, inputs));
106
+ return finishResult(
107
+ broker,
108
+ context,
109
+ textResult({ sessionId, cwd }, inputs),
110
+ );
86
111
  },
87
112
  );
88
113
 
@@ -90,8 +115,15 @@ export function createServer(broker: Broker): McpServer {
90
115
  "tools",
91
116
  {
92
117
  title: "Pi tools",
93
- description: "List the tools currently active in a Pi session.",
118
+ description:
119
+ "Return complete definitions for active Pi tools invoked through call. Provide names to inspect selected tools. Chappie's own MCP controls, including init, sessions, and chat, are exposed separately.",
120
+ outputSchema,
94
121
  inputSchema: z.object({
122
+ names: z
123
+ .array(z.string())
124
+ .min(1)
125
+ .optional()
126
+ .describe("Tool names to describe; omit to return every active tool"),
95
127
  sessionId: z
96
128
  .string()
97
129
  .optional()
@@ -99,6 +131,7 @@ export function createServer(broker: Broker): McpServer {
99
131
  }),
100
132
  annotations: {
101
133
  readOnlyHint: true,
134
+ destructiveHint: false,
102
135
  idempotentHint: true,
103
136
  openWorldHint: false,
104
137
  },
@@ -107,6 +140,7 @@ export function createServer(broker: Broker): McpServer {
107
140
  const { inputs, ...inspected } = await broker.tools(
108
141
  requireChatId(context),
109
142
  args.sessionId,
143
+ args.names,
110
144
  context.mcpReq.signal,
111
145
  );
112
146
  return finishResult(
@@ -124,7 +158,9 @@ export function createServer(broker: Broker): McpServer {
124
158
  "call",
125
159
  {
126
160
  title: "Call Pi tools",
127
- description: "Execute one or more tools as a native Pi tool batch.",
161
+ description:
162
+ "Execute one or more active Pi tools as one native batch. Arguments must match definitions returned by tools.",
163
+ outputSchema,
128
164
  inputSchema: z.object({
129
165
  calls: z
130
166
  .array(
@@ -140,6 +176,8 @@ export function createServer(broker: Broker): McpServer {
140
176
  .describe("Pi session for this operation only"),
141
177
  }),
142
178
  annotations: {
179
+ readOnlyHint: false,
180
+ destructiveHint: true,
143
181
  openWorldHint: true,
144
182
  },
145
183
  },
@@ -153,7 +191,12 @@ export function createServer(broker: Broker): McpServer {
153
191
  return finishResult(
154
192
  broker,
155
193
  context,
156
- toolResult(result.toolResults, result.sessionId, result.inputs),
194
+ toolResult(
195
+ result.toolResults,
196
+ result.sessionId,
197
+ result.cwd,
198
+ result.inputs,
199
+ ),
157
200
  );
158
201
  },
159
202
  );
@@ -164,11 +207,13 @@ export function createServer(broker: Broker): McpServer {
164
207
  {
165
208
  title: tool.name,
166
209
  description: tool.description,
210
+ outputSchema,
167
211
  inputSchema: tool.inputSchema,
168
212
  annotations: {
169
213
  readOnlyHint: tool.name === "read",
214
+ destructiveHint: tool.name !== "read",
170
215
  idempotentHint: tool.name === "read",
171
- openWorldHint: tool.name === "bash",
216
+ openWorldHint: tool.name === "bash" || tool.name === "transfer",
172
217
  },
173
218
  ...(tool.fileParams
174
219
  ? { _meta: { "openai/fileParams": tool.fileParams } }
@@ -190,7 +235,12 @@ export function createServer(broker: Broker): McpServer {
190
235
  return finishResult(
191
236
  broker,
192
237
  context,
193
- toolResult(result.toolResults, result.sessionId, result.inputs),
238
+ toolResult(
239
+ result.toolResults,
240
+ result.sessionId,
241
+ result.cwd,
242
+ result.inputs,
243
+ ),
194
244
  );
195
245
  },
196
246
  );
@@ -201,15 +251,17 @@ export function createServer(broker: Broker): McpServer {
201
251
  {
202
252
  title: "Local sessions",
203
253
  description:
204
- "List connected Pi sessions and the current conversation binding.",
254
+ "List online Pi sessions, their saved binding counts, and this conversation's default. A zero bindingCount permits automatic pairing; status describes execution. Offline sessions do not delay the listing.",
255
+ outputSchema,
205
256
  inputSchema: z.object({
206
257
  sessionId: z
207
258
  .string()
208
259
  .optional()
209
- .describe("Return only this Pi session when it is online"),
260
+ .describe("Return this Pi session when it is online"),
210
261
  }),
211
262
  annotations: {
212
263
  readOnlyHint: true,
264
+ destructiveHint: false,
213
265
  idempotentHint: true,
214
266
  openWorldHint: false,
215
267
  },
@@ -226,7 +278,7 @@ export function createServer(broker: Broker): McpServer {
226
278
  },
227
279
  inputs,
228
280
  );
229
- return chatId ? finishResult(broker, context, result) : result;
281
+ return finishResult(broker, context, result);
230
282
  },
231
283
  );
232
284
 
@@ -268,19 +320,19 @@ function textResult(
268
320
  };
269
321
  }
270
322
 
271
- async function finishResult<T extends { content: object[] }>(
272
- broker: Broker,
273
- context: RequestContext,
274
- result: T,
275
- ): Promise<T> {
276
- const chatId = requireChatId(context);
277
- const deliveries = await broker.deliveries(chatId);
278
- const completed = {
279
- ...result,
280
- content: [...result.content, ...deliveryContent(deliveries)],
281
- } as T;
323
+ async function finishResult<
324
+ T extends { content: ReturnType<typeof toolResult>["content"] },
325
+ >(broker: Broker, context: RequestContext, result: T) {
326
+ const chatId = requestChatId(context);
327
+ const deliveries = chatId ? await broker.deliveries(chatId) : [];
328
+ const content = [...result.content, ...deliveryContent(deliveries)];
329
+ const structuredContent = {
330
+ text: content
331
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
332
+ .join("\n"),
333
+ };
282
334
  await broker.acknowledgeDeliveries(deliveries, context.mcpReq.signal);
283
- return completed;
335
+ return { ...result, content, structuredContent };
284
336
  }
285
337
 
286
338
  function requestChatId(context: RequestContext): string | undefined {
package/src/session.ts CHANGED
@@ -35,6 +35,7 @@ interface StoreRequest {
35
35
 
36
36
  interface ActiveRequest {
37
37
  request: RemoteRequest;
38
+ session: SessionDescription;
38
39
  message: AssistantMessage;
39
40
  completed: boolean;
40
41
  cancelled: boolean;
@@ -298,6 +299,7 @@ export class LocalSession {
298
299
  this.#queue.shift();
299
300
  this.#active = {
300
301
  request,
302
+ session: this.#description(),
301
303
  message: output.message,
302
304
  completed: false,
303
305
  cancelled: false,
@@ -342,7 +344,7 @@ export class LocalSession {
342
344
  this.#context = context;
343
345
  const active = this.#active;
344
346
  if (!active || message !== active.message) return;
345
- const sessionId = context.sessionManager.getSessionId();
347
+ const sessionId = active.session.id;
346
348
  for (const result of toolResults) rememberImages(sessionId, result.content);
347
349
  active.completed = true;
348
350
  active.toolResults = toolResults;
@@ -354,44 +356,36 @@ export class LocalSession {
354
356
  this.#collectInputs();
355
357
  const inputs = this.#inputs();
356
358
  if (active.cancelled) {
357
- const context = this.#context;
358
- if (context) {
359
- const sessionFile = context.sessionManager.getSessionFile();
360
- const delivery: DeliveryRecord = {
361
- id: randomUUID(),
362
- chatId: active.request.chatId,
363
- sessionId: context.sessionManager.getSessionId(),
364
- toolCallIds:
365
- active.request.type === "call"
366
- ? active.request.calls.map(({ id }) => id)
367
- : [],
368
- ...(sessionFile
369
- ? { sessionFile }
370
- : { inlineResults: active.toolResults }),
371
- ...(active.message.errorMessage
372
- ? { error: active.message.errorMessage }
373
- : { error: "Request cancelled" }),
374
- };
375
- this.#deliveries.set(delivery.id, delivery);
376
- await this.#flushDeliveries().catch(() => {});
377
- }
359
+ const sessionFile = active.session.sessionFile;
360
+ const delivery: DeliveryRecord = {
361
+ id: randomUUID(),
362
+ chatId: active.request.chatId,
363
+ sessionId: active.session.id,
364
+ cwd: active.session.cwd,
365
+ toolCallIds:
366
+ active.request.type === "call"
367
+ ? active.request.calls.map(({ id }) => id)
368
+ : [],
369
+ ...(sessionFile
370
+ ? { sessionFile }
371
+ : { inlineResults: active.toolResults }),
372
+ ...(active.message.errorMessage
373
+ ? { error: active.message.errorMessage }
374
+ : { error: "Request cancelled" }),
375
+ };
376
+ this.#deliveries.set(delivery.id, delivery);
377
+ await this.#flushDeliveries().catch(() => {});
378
378
  } else {
379
- await this.#connection?.send(
380
- active.request.type === "call"
381
- ? {
382
- type: "result",
383
- id: active.request.id,
384
- message: active.message,
385
- toolResults: active.toolResults,
386
- inputs,
387
- }
388
- : {
389
- type: "result",
390
- id: active.request.id,
391
- message: active.message,
392
- inputs,
393
- },
394
- );
379
+ await this.#connection?.send({
380
+ type: "result",
381
+ id: active.request.id,
382
+ cwd: active.session.cwd,
383
+ message: active.message,
384
+ inputs,
385
+ ...(active.request.type === "call"
386
+ ? { toolResults: active.toolResults }
387
+ : {}),
388
+ });
395
389
  }
396
390
  this.#active = undefined;
397
391
  this.#status = "idle";
package/src/state.ts CHANGED
@@ -40,8 +40,12 @@ export class State {
40
40
  return this.#bindings.get(chatId);
41
41
  }
42
42
 
43
- boundSessions(): Set<string> {
44
- return new Set(this.#bindings.values());
43
+ bindingCounts(): Map<string, number> {
44
+ const counts = new Map<string, number>();
45
+ for (const sessionId of this.#bindings.values()) {
46
+ counts.set(sessionId, (counts.get(sessionId) ?? 0) + 1);
47
+ }
48
+ return counts;
45
49
  }
46
50
 
47
51
  setBinding(chatId: string, sessionId: string): Promise<void> {
package/src/tools.ts CHANGED
@@ -38,10 +38,12 @@ export const directTools = definitions.map((definition) => ({
38
38
  export function toolResult(
39
39
  toolResults: ToolResultMessage[],
40
40
  sessionId: string,
41
+ cwd: string,
41
42
  inputs: SessionInput[] = [],
42
43
  ) {
43
44
  return {
44
45
  content: [
46
+ { type: "text" as const, text: JSON.stringify({ sessionId, cwd }) },
45
47
  ...toolResults.flatMap((result) => [
46
48
  {
47
49
  type: "text" as const,
@@ -70,7 +72,7 @@ export function inputContent(inputs: SessionInput[]) {
70
72
  return inputs.flatMap(({ id, sessionId, message }) => [
71
73
  {
72
74
  type: "text" as const,
73
- text: JSON.stringify({ piInput: id }),
75
+ text: JSON.stringify({ piInput: id, sessionId }),
74
76
  },
75
77
  ...(typeof message.content === "string"
76
78
  ? [{ type: "text" as const, text: message.content }]
package/src/transfer.ts CHANGED
@@ -18,8 +18,8 @@ interface TransferDetails {
18
18
  }
19
19
 
20
20
  export const transferFile = Type.Object({
21
- file_id: Type.String(),
22
- download_url: Type.String(),
21
+ file_id: Type.String({ description: "Host file identifier" }),
22
+ download_url: Type.String({ description: "Host-provided download URL" }),
23
23
  file_name: Type.Optional(Type.String()),
24
24
  mime_type: Type.Optional(Type.String()),
25
25
  });
@@ -28,10 +28,20 @@ export const transfer = {
28
28
  name: "transfer",
29
29
  label: "transfer",
30
30
  description:
31
- "Transfer files between ChatGPT and the current Pi session. Provide files to write them to paths; omit files to export existing paths or Chappie image references.",
31
+ "Copy ChatGPT files into Pi paths, or export Pi paths and Chappie image references as MCP resources. The paths field always names Pi-side sources or destinations.",
32
32
  parameters: Type.Object({
33
- paths: Type.Array(Type.String(), { minItems: 1 }),
34
- files: Type.Optional(Type.Array(transferFile, { minItems: 1 })),
33
+ paths: Type.Array(Type.String(), {
34
+ minItems: 1,
35
+ description:
36
+ "Pi paths to import into or export from; chappie:// image references can be exported",
37
+ }),
38
+ files: Type.Optional(
39
+ Type.Array(transferFile, {
40
+ minItems: 1,
41
+ description:
42
+ "ChatGPT files matched to paths by index; omit to export Pi paths",
43
+ }),
44
+ ),
35
45
  overwrite: Type.Optional(
36
46
  Type.Boolean({ description: "Overwrite existing target files" }),
37
47
  ),