@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/src/config.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import * as z from "zod";
4
+
5
+ const configSchema = z.object({
6
+ ask: z.boolean().optional(),
7
+ connect: z.string().min(1).optional(),
8
+ latestWorkflow: z.boolean().optional(),
9
+ listen: z.union([z.boolean(), z.number().int().min(1).max(65535)]).optional(),
10
+ });
11
+
12
+ export async function readConfig(agentDir: string) {
13
+ let contents: string;
14
+ try {
15
+ contents = await readFile(join(agentDir, "chappie.json"), "utf8");
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
18
+ throw error;
19
+ }
20
+ return configSchema.parse(JSON.parse(contents));
21
+ }
package/src/delivery.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import type { ToolResultMessage } from "@earendil-works/pi-ai";
3
2
  import {
4
3
  contentWithImageReferences,
@@ -10,48 +9,35 @@ export interface DeliveryRecord {
10
9
  chatId: string;
11
10
  sessionId: string;
12
11
  cwd: string;
13
- sessionFile?: string;
14
- toolCallIds: string[];
15
- inlineResults?: ToolResultMessage[];
16
- error?: string;
17
- }
18
-
19
- export interface ResolvedDelivery extends DeliveryRecord {
20
12
  toolResults: ToolResultMessage[];
13
+ error?: string;
21
14
  }
22
15
 
23
- export async function resolveDelivery(
24
- delivery: DeliveryRecord,
25
- ): Promise<ResolvedDelivery> {
26
- let toolResults = delivery.inlineResults ?? [];
27
- if (delivery.sessionFile && delivery.toolCallIds.length > 0) {
28
- const expected = new Set(delivery.toolCallIds);
29
- const found = new Map<string, ToolResultMessage>();
30
- for (const line of (await readFile(delivery.sessionFile, "utf8")).split(
31
- "\n",
32
- )) {
33
- if (!line) continue;
34
- const entry = JSON.parse(line) as {
35
- type?: string;
36
- message?: ToolResultMessage;
37
- };
38
- if (
39
- entry.type === "message" &&
40
- entry.message?.role === "toolResult" &&
41
- expected.has(entry.message.toolCallId)
42
- ) {
43
- found.set(entry.message.toolCallId, entry.message);
44
- }
45
- }
46
- toolResults = delivery.toolCallIds.flatMap((id) => {
47
- const result = found.get(id);
48
- return result ? [result] : [];
49
- });
50
- }
51
- return { ...delivery, toolResults };
16
+ export function toolResultsContent(
17
+ toolResults: ToolResultMessage[],
18
+ sessionId: string,
19
+ ) {
20
+ return toolResults.flatMap((result) => [
21
+ {
22
+ type: "text" as const,
23
+ text: JSON.stringify({
24
+ toolCallId: result.toolCallId,
25
+ toolName: result.toolName,
26
+ isError: result.isError,
27
+ }),
28
+ },
29
+ ...contentWithImageReferences(sessionId, result.content),
30
+ ...resourceDescriptors(result.details).map((resource) => ({
31
+ type: "resource_link" as const,
32
+ uri: resource.uri,
33
+ name: resource.name,
34
+ mimeType: resource.mimeType,
35
+ size: resource.size,
36
+ })),
37
+ ]);
52
38
  }
53
39
 
54
- export function deliveryContent(deliveries: ResolvedDelivery[]) {
40
+ export function deliveryContent(deliveries: DeliveryRecord[]) {
55
41
  return deliveries.flatMap((delivery) => [
56
42
  {
57
43
  type: "text" as const,
@@ -62,23 +48,6 @@ export function deliveryContent(deliveries: ResolvedDelivery[]) {
62
48
  error: delivery.error,
63
49
  }),
64
50
  },
65
- ...delivery.toolResults.flatMap((result) => [
66
- {
67
- type: "text" as const,
68
- text: JSON.stringify({
69
- toolCallId: result.toolCallId,
70
- toolName: result.toolName,
71
- isError: result.isError,
72
- }),
73
- },
74
- ...contentWithImageReferences(delivery.sessionId, result.content),
75
- ...resourceDescriptors(result.details).map((resource) => ({
76
- type: "resource_link" as const,
77
- uri: resource.uri,
78
- name: resource.name,
79
- mimeType: resource.mimeType,
80
- size: resource.size,
81
- })),
82
- ]),
51
+ ...toolResultsContent(delivery.toolResults, delivery.sessionId),
83
52
  ]);
84
53
  }
package/src/index.ts CHANGED
@@ -19,16 +19,21 @@ export default async function chappie(pi: ExtensionAPI): Promise<void> {
19
19
  }
20
20
  }
21
21
 
22
- const [{ createChappieProvider }, { LocalSession }, { transfer }] =
23
- await Promise.all([
24
- import("./provider.ts"),
25
- import("./session.ts"),
26
- import("./transfer.ts"),
27
- ]);
28
- const session = new LocalSession(pi, getAgentDir());
22
+ const agentDir = getAgentDir();
23
+ const [
24
+ { readConfig },
25
+ { createChappieProvider },
26
+ { LocalSession },
27
+ { transfer },
28
+ ] = await Promise.all([
29
+ import("./config.ts"),
30
+ import("./provider.ts"),
31
+ import("./session.ts"),
32
+ import("./transfer.ts"),
33
+ ]);
34
+ const config = await readConfig(agentDir);
35
+ const session = new LocalSession(pi, agentDir, config.connect);
29
36
  session.install();
30
37
  pi.registerTool(transfer);
31
- pi.registerProvider(
32
- createChappieProvider((output, context) => session.start(output, context)),
33
- );
38
+ pi.registerProvider(createChappieProvider((output) => session.start(output)));
34
39
  }
@@ -1,13 +1,15 @@
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.
1
+ Chappie connects this ChatGPT conversation to Pi sessions on one or more devices. Resume work with init using the task's Pi sessionId, including in a new chat or branch. When only a project or session name is known, find its device/cwd/name in sessions, then call init. If the target is absent or ambiguous, ask the user to resolve it. Pi sessions appear while the chappie/chatgpt provider is selected.
2
2
 
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.
3
+ init sets this chat's default Pi session. For a task without a specified target, omitting sessionId reuses that default or selects the first online session with no saved bindings. These sessions may already contain work. Defaults survive broker restarts, and several chats can share one Pi session. Other tools' sessionId affects only that call.
4
4
 
5
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
6
 
7
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
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.
9
+ Respond promptly to new Pi user input with a substantive reply, interaction, or immediate action that makes the response apparent in Pi before continuing lengthy work. Address inputs received together in one response; an immediate answer or result serves as its own acknowledgment.
10
10
 
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.
11
+ Use chat for assistant messages in Pi, including progress, explanations, and results. Use read, bash, edit, write, and transfer directly. init.tools is a Pi tool catalog; tools returns full definitions for call. Invoke Chappie's MCP tools directly. Each call array is one native Pi batch; separate calls are separate Pi turns. For Pi interaction, call an installed interactive tool.
12
12
 
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.
13
+ transfer pairs files from ChatGPT with Pi destination paths in order. Omit files to return resource links for Pi paths or chappie:// image references. Relative paths use the Pi working directory; overwrite: true replaces existing targets. The host may request confirmation when retrieving exported bytes.
14
+
15
+ Tool results identify the executing Pi sessionId and cwd. A shell command can access another directory without changing its Pi session. structuredContent.text includes the complete text, new Pi input, webAnswer, and deferred results; images and resources are native content blocks. Continue from received results rather than repeating work. Host deadlines include queueing and execution; use local persistent processes for longer work.
package/src/ipc.ts CHANGED
@@ -20,13 +20,15 @@ import type {
20
20
  import type { DeliveryRecord } from "./delivery.ts";
21
21
  import type { ResourceData } from "./resources.ts";
22
22
 
23
+ const defaultPort = 24274;
24
+
23
25
  export type SessionStatus = "idle" | "ready" | "executing";
24
26
 
25
27
  export interface SessionDescription {
26
28
  id: string;
27
29
  cwd: string;
30
+ device: string;
28
31
  name?: string;
29
- sessionFile?: string;
30
32
  status: SessionStatus;
31
33
  }
32
34
 
@@ -43,7 +45,11 @@ export interface SessionInput {
43
45
  }
44
46
 
45
47
  export type SessionResult =
46
- | { inspection: SessionInspection; inputs: SessionInput[] }
48
+ | {
49
+ inspection: SessionInspection;
50
+ inputs: SessionInput[];
51
+ globalAgents?: string;
52
+ }
47
53
  | { message: AssistantMessage; cwd: string; inputs: SessionInput[] }
48
54
  | {
49
55
  message: AssistantMessage;
@@ -63,6 +69,7 @@ export type SessionMessage =
63
69
  export type BrokerMessage =
64
70
  | { type: "synced"; id: number; sessionId: string }
65
71
  | { type: "stored"; id: string }
72
+ | { type: "notice"; sessionId: string; message: string }
66
73
  | { type: "inspect"; id: number; sessionId: string }
67
74
  | {
68
75
  type: "chat";
@@ -78,7 +85,7 @@ export type BrokerMessage =
78
85
  sessionId: string;
79
86
  calls: ToolCall[];
80
87
  }
81
- | { type: "cancel"; id: number; sessionId: string }
88
+ | { type: "cancel"; id: number; sessionId: string; reason: string }
82
89
  | { type: "readResource"; id: number; sessionId: string; uri: string }
83
90
  | { type: "ackInputs"; sessionId: string; ids: string[] };
84
91
 
@@ -172,7 +179,7 @@ export class IpcServer {
172
179
  peer: JsonLinePeer<SessionMessage, BrokerMessage>,
173
180
  ) => void;
174
181
  readonly #peers = new Set<JsonLinePeer<SessionMessage, BrokerMessage>>();
175
- #server: Server | undefined;
182
+ readonly #servers = new Set<Server>();
176
183
 
177
184
  constructor(
178
185
  agentDir: string,
@@ -187,10 +194,34 @@ export class IpcServer {
187
194
  this.#onClose = onClose;
188
195
  }
189
196
 
190
- async start(): Promise<void> {
191
- if (this.#server) return;
197
+ async start(network: boolean | number = false): Promise<void> {
198
+ if (this.#servers.size > 0) return;
192
199
  if (process.platform !== "win32") await prepareUnixSocket(this.#endpoint);
193
- const server = createServer((socket) => {
200
+ try {
201
+ const local = this.#createServer();
202
+ await listenServer(local, this.#endpoint);
203
+ this.#servers.add(local);
204
+ if (!network) return;
205
+
206
+ const remote = this.#createServer();
207
+ await listenServer(remote, network === true ? defaultPort : network);
208
+ this.#servers.add(remote);
209
+ } catch (error) {
210
+ await this.close();
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ async close(): Promise<void> {
216
+ for (const peer of this.#peers) peer.close();
217
+ this.#peers.clear();
218
+ const servers = [...this.#servers];
219
+ this.#servers.clear();
220
+ await Promise.all(servers.map(closeServer));
221
+ }
222
+
223
+ #createServer(): Server {
224
+ return createServer((socket) => {
194
225
  let peer: JsonLinePeer<SessionMessage, BrokerMessage>;
195
226
  peer = new JsonLinePeer(
196
227
  socket,
@@ -202,25 +233,6 @@ export class IpcServer {
202
233
  );
203
234
  this.#peers.add(peer);
204
235
  });
205
- await new Promise<void>((resolveListen, rejectListen) => {
206
- server.once("error", rejectListen);
207
- server.listen(this.#endpoint, () => {
208
- server.off("error", rejectListen);
209
- resolveListen();
210
- });
211
- });
212
- this.#server = server;
213
- }
214
-
215
- async close(): Promise<void> {
216
- for (const peer of this.#peers) peer.close();
217
- this.#peers.clear();
218
- const server = this.#server;
219
- this.#server = undefined;
220
- if (!server) return;
221
- await new Promise<void>((resolveClose, rejectClose) =>
222
- server.close((error) => (error ? rejectClose(error) : resolveClose())),
223
- );
224
236
  }
225
237
  }
226
238
 
@@ -232,14 +244,21 @@ interface ConnectionCallbacks {
232
244
 
233
245
  export class IpcClient {
234
246
  readonly #endpoint: string;
247
+ readonly #remote: string | undefined;
235
248
  readonly #callbacks: ConnectionCallbacks;
236
249
  #peer: JsonLinePeer<BrokerMessage, SessionMessage> | undefined;
237
250
  #opening: Promise<void> | undefined;
251
+ #controller: AbortController | undefined;
238
252
  #retry: NodeJS.Timeout | undefined;
239
253
  #closed = false;
240
254
 
241
- constructor(agentDir: string, callbacks: ConnectionCallbacks) {
255
+ constructor(
256
+ agentDir: string,
257
+ remote: string | undefined,
258
+ callbacks: ConnectionCallbacks,
259
+ ) {
242
260
  this.#endpoint = ipcEndpoint(agentDir);
261
+ this.#remote = remote;
243
262
  this.#callbacks = callbacks;
244
263
  }
245
264
 
@@ -258,8 +277,11 @@ export class IpcClient {
258
277
  if (this.#opening) return this.#opening;
259
278
  clearTimeout(this.#retry);
260
279
  this.#retry = undefined;
261
- this.#opening = this.#open().finally(() => {
280
+ const controller = new AbortController();
281
+ this.#controller = controller;
282
+ this.#opening = this.#open(controller.signal).finally(() => {
262
283
  this.#opening = undefined;
284
+ if (this.#controller === controller) this.#controller = undefined;
263
285
  });
264
286
  return this.#opening;
265
287
  }
@@ -275,24 +297,25 @@ export class IpcClient {
275
297
  this.#closed = true;
276
298
  clearTimeout(this.#retry);
277
299
  this.#retry = undefined;
300
+ this.#controller?.abort(new Error("Chappie IPC client is closed"));
301
+ this.#controller = undefined;
278
302
  this.#peer?.close();
279
303
  this.#peer = undefined;
280
304
  }
281
305
 
282
- async #open(): Promise<void> {
283
- const socket = createConnection(this.#endpoint);
284
- let failure = new Error("Chappie broker connection ended");
285
- await new Promise<void>((resolveOpen, rejectOpen) => {
286
- socket.once("connect", resolveOpen);
287
- socket.once("error", (error) => {
288
- failure = error;
289
- rejectOpen(error);
290
- });
291
- }).catch((error: unknown) => {
292
- socket.destroy();
306
+ async #open(signal: AbortSignal): Promise<void> {
307
+ let socket: Socket | undefined;
308
+ let failure = new Error("Chappie disconnected");
309
+ try {
310
+ socket = this.#remote
311
+ ? createConnection(networkEndpoint(this.#remote))
312
+ : createConnection(this.#endpoint);
313
+ await connectSocket(socket, signal);
314
+ } catch (error) {
315
+ socket?.destroy();
293
316
  this.#scheduleReconnect();
294
317
  throw error;
295
- });
318
+ }
296
319
 
297
320
  let peer: JsonLinePeer<BrokerMessage, SessionMessage>;
298
321
  peer = new JsonLinePeer(
@@ -327,6 +350,85 @@ export class IpcClient {
327
350
  }
328
351
  }
329
352
 
353
+ function listenServer(
354
+ server: Server,
355
+ endpoint: string | number,
356
+ ): Promise<void> {
357
+ return new Promise<void>((resolveListen, rejectListen) => {
358
+ const onError = (error: Error): void => {
359
+ server.off("listening", onListening);
360
+ rejectListen(error);
361
+ };
362
+ const onListening = (): void => {
363
+ server.off("error", onError);
364
+ resolveListen();
365
+ };
366
+ server.once("error", onError);
367
+ server.once("listening", onListening);
368
+ server.listen(endpoint);
369
+ });
370
+ }
371
+
372
+ function closeServer(server: Server): Promise<void> {
373
+ return new Promise<void>((resolveClose, rejectClose) =>
374
+ server.close((error) => (error ? rejectClose(error) : resolveClose())),
375
+ );
376
+ }
377
+
378
+ interface NetworkEndpoint {
379
+ host: string;
380
+ port: number;
381
+ }
382
+
383
+ function networkEndpoint(value: string): NetworkEndpoint {
384
+ const url = new URL(`tcp://${value}`);
385
+ if (url.username || url.password || url.pathname || url.search || url.hash)
386
+ throw new Error(`Invalid Chappie broker address: ${value}`);
387
+ const host = url.hostname.startsWith("[")
388
+ ? url.hostname.slice(1, -1)
389
+ : url.hostname;
390
+ if (!host) throw new Error(`Invalid Chappie broker address: ${value}`);
391
+ return { host, port: url.port ? Number(url.port) : defaultPort };
392
+ }
393
+
394
+ function connectSocket(socket: Socket, signal: AbortSignal): Promise<void> {
395
+ return new Promise<void>((resolveConnect, rejectConnect) => {
396
+ const cleanup = (): void => {
397
+ signal.removeEventListener("abort", onAbort);
398
+ socket.off("connect", onConnect);
399
+ socket.off("error", onError);
400
+ };
401
+ const onConnect = (): void => {
402
+ cleanup();
403
+ resolveConnect();
404
+ };
405
+ const onError = (error: Error): void => {
406
+ cleanup();
407
+ rejectConnect(error);
408
+ };
409
+ const onAbort = (): void => {
410
+ cleanup();
411
+ socket.destroy();
412
+ rejectConnect(abortError(signal));
413
+ };
414
+ if (signal.aborted) {
415
+ onAbort();
416
+ return;
417
+ }
418
+ signal.addEventListener("abort", onAbort, { once: true });
419
+ socket.once("connect", onConnect);
420
+ socket.once("error", onError);
421
+ });
422
+ }
423
+
424
+ function abortError(signal: AbortSignal): Error {
425
+ return signal.reason instanceof Error
426
+ ? signal.reason
427
+ : new Error(
428
+ typeof signal.reason === "string" ? signal.reason : "Request cancelled",
429
+ );
430
+ }
431
+
330
432
  async function prepareUnixSocket(endpoint: string): Promise<void> {
331
433
  try {
332
434
  await lstat(endpoint);
package/src/provider.ts CHANGED
@@ -147,19 +147,17 @@ export class ProviderOutput {
147
147
  }
148
148
 
149
149
  export function createChappieProvider(
150
- start: (output: ProviderOutput, context: Context) => Promise<void>,
150
+ start: (output: ProviderOutput) => Promise<void>,
151
151
  ) {
152
152
  const stream = (
153
153
  model: Model<Api>,
154
- context: Context,
154
+ _context: Context,
155
155
  options?: StreamOptions,
156
156
  ) => {
157
157
  const output = new ProviderOutput(model, options?.signal);
158
158
  if (!output.closed) {
159
159
  queueMicrotask(() => {
160
- void start(output, context).catch((error: unknown) =>
161
- output.fail(error),
162
- );
160
+ void start(output).catch((error: unknown) => output.fail(error));
163
161
  });
164
162
  }
165
163
  return output.stream;