@zetaloop/chappie 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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;
@@ -42,6 +49,7 @@ interface ChangeWaiter {
42
49
  }
43
50
 
44
51
  export interface InitializedSession extends Omit<SessionInspection, "tools"> {
52
+ selection: "existing" | "explicit" | "automatic";
45
53
  globalAgents?: string;
46
54
  inputs: SessionInput[];
47
55
  tools: { name: string; description: string }[];
@@ -53,11 +61,13 @@ export interface InspectedSession extends SessionInspection {
53
61
 
54
62
  export interface ChatResult {
55
63
  sessionId: string;
64
+ cwd: string;
56
65
  inputs: SessionInput[];
57
66
  }
58
67
 
59
68
  export interface CallResult {
60
69
  sessionId: string;
70
+ cwd: string;
61
71
  toolResults: ToolResultMessage[];
62
72
  inputs: SessionInput[];
63
73
  }
@@ -69,6 +79,9 @@ export class Broker {
69
79
  readonly #sessions = new Map<string, RegisteredSession>();
70
80
  readonly #pending = new Map<number, PendingRequest>();
71
81
  readonly #waiters = new Set<ChangeWaiter>();
82
+ readonly #workflows = new Map<string, Workflow>();
83
+ #ask = true;
84
+ #latestWorkflow = false;
72
85
  #nextRequestId = 1;
73
86
 
74
87
  constructor(agentDir: string) {
@@ -82,12 +95,19 @@ export class Broker {
82
95
  }
83
96
 
84
97
  async start(): Promise<void> {
98
+ const config = await readConfig(this.#agentDir);
99
+ this.#ask = config.ask ?? true;
100
+ this.#latestWorkflow = config.latestWorkflow ?? false;
85
101
  await this.#state.load();
86
- await this.#ipc.start();
102
+ await this.#ipc.start(config.listen ?? false);
87
103
  }
88
104
 
89
105
  async close(): Promise<void> {
90
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();
91
111
  for (const [id, pending] of this.#pending) {
92
112
  this.#finishRequest(id, pending);
93
113
  pending.reject(error);
@@ -101,12 +121,53 @@ export class Broker {
101
121
  await this.#ipc.close();
102
122
  }
103
123
 
104
- listSessions(sessionId?: string): SessionDescription[] {
105
- if (sessionId) {
106
- const session = this.#sessions.get(sessionId);
107
- return session ? [session.description] : [];
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);
108
151
  }
109
- return [...this.#sessions.values()].map(({ description }) => description);
152
+ const combined = AbortSignal.any([signal, workflow.controller.signal]);
153
+ combined.throwIfAborted();
154
+ return combined;
155
+ }
156
+
157
+ listSessions(
158
+ sessionId?: string,
159
+ ): (SessionDescription & { bindingCount: number })[] {
160
+ const counts = this.#state.bindingCounts();
161
+ return [...this.#sessions.values()]
162
+ .filter(({ description }) => !sessionId || description.id === sessionId)
163
+ .map(({ description }) => ({
164
+ ...description,
165
+ bindingCount: counts.get(description.id) ?? 0,
166
+ }));
167
+ }
168
+
169
+ get askEnabled(): boolean {
170
+ return this.#ask;
110
171
  }
111
172
 
112
173
  binding(chatId: string): string | undefined {
@@ -118,11 +179,22 @@ export class Broker {
118
179
  sessionId: string | undefined,
119
180
  signal: AbortSignal,
120
181
  ): Promise<InitializedSession> {
121
- const target = await this.#selectSession(chatId, sessionId, signal, true);
122
- const { inspection, inputs } = await this.#inspect(target, signal);
123
- const globalAgents = await this.#readGlobalAgents();
182
+ const previous = this.#state.binding(chatId);
183
+ const { sessionId: target, selection } = await this.#selectSession(
184
+ chatId,
185
+ sessionId,
186
+ signal,
187
+ true,
188
+ );
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
+ );
124
195
  await this.#ackInputs(target, inputs);
125
196
  return {
197
+ selection,
126
198
  ...inspection,
127
199
  tools: inspection.tools.map(({ name, description }) => ({
128
200
  name,
@@ -139,7 +211,12 @@ export class Broker {
139
211
  text: string,
140
212
  signal: AbortSignal,
141
213
  ): Promise<ChatResult> {
142
- const target = await this.#selectSession(chatId, sessionId, signal, false);
214
+ const { sessionId: target } = await this.#selectSession(
215
+ chatId,
216
+ sessionId,
217
+ signal,
218
+ false,
219
+ );
143
220
  const result = await this.#request(
144
221
  target,
145
222
  (id) => ({ type: "chat", id, chatId, sessionId: target, text }),
@@ -147,7 +224,7 @@ export class Broker {
147
224
  );
148
225
  if ("message" in result) {
149
226
  await this.#ackInputs(target, result.inputs);
150
- return { sessionId: target, inputs: result.inputs };
227
+ return { sessionId: target, cwd: result.cwd, inputs: result.inputs };
151
228
  }
152
229
  throw new Error("Pi session returned no assistant message");
153
230
  }
@@ -158,7 +235,12 @@ export class Broker {
158
235
  names: string[] | undefined,
159
236
  signal: AbortSignal,
160
237
  ): Promise<InspectedSession> {
161
- const target = await this.#selectSession(chatId, sessionId, signal, false);
238
+ const { sessionId: target } = await this.#selectSession(
239
+ chatId,
240
+ sessionId,
241
+ signal,
242
+ false,
243
+ );
162
244
  const { inspection, inputs } = await this.#inspect(target, signal);
163
245
  await this.#ackInputs(target, inputs);
164
246
  const selected = names ? new Set(names) : undefined;
@@ -177,7 +259,12 @@ export class Broker {
177
259
  calls: ToolInput[],
178
260
  signal: AbortSignal,
179
261
  ): Promise<CallResult> {
180
- const target = await this.#selectSession(chatId, sessionId, signal, false);
262
+ const { sessionId: target } = await this.#selectSession(
263
+ chatId,
264
+ sessionId,
265
+ signal,
266
+ false,
267
+ );
181
268
  const toolCalls: ToolCall[] = calls.map((call) => ({
182
269
  type: "toolCall",
183
270
  id: `chappie-${randomUUID()}`,
@@ -199,6 +286,7 @@ export class Broker {
199
286
  await this.#ackInputs(target, result.inputs);
200
287
  return {
201
288
  sessionId: target,
289
+ cwd: result.cwd,
202
290
  toolResults: result.toolResults,
203
291
  inputs: result.inputs,
204
292
  };
@@ -218,6 +306,88 @@ export class Broker {
218
306
  return inputs;
219
307
  }
220
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
+
221
391
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
222
392
  const sessionId = resourceSessionId(uri);
223
393
  await this.#waitForSession(sessionId, signal);
@@ -230,20 +400,16 @@ export class Broker {
230
400
  throw new Error("Pi session returned no resource");
231
401
  }
232
402
 
233
- async deliveries(chatId: string): Promise<ResolvedDelivery[]> {
234
- return Promise.all(this.#state.deliveries(chatId).map(resolveDelivery));
403
+ deliveries(chatId: string): DeliveryRecord[] {
404
+ return this.#state.deliveries(chatId);
235
405
  }
236
406
 
237
- async acknowledgeDeliveries(
407
+ acknowledge(
238
408
  deliveries: DeliveryRecord[],
409
+ answers: QuestionRecord[],
239
410
  signal: AbortSignal,
240
411
  ): Promise<void> {
241
- if (deliveries.length === 0) return;
242
- if (signal.aborted) throw abortError(signal);
243
- await this.#state.removeDeliveries(deliveries.map(({ id }) => id));
244
- if (!signal.aborted) return;
245
- await this.#state.restoreDeliveries(deliveries);
246
- throw abortError(signal);
412
+ return this.#state.acknowledge(deliveries, answers, signal);
247
413
  }
248
414
 
249
415
  async #receive(
@@ -252,16 +418,20 @@ export class Broker {
252
418
  ): Promise<void> {
253
419
  switch (message.type) {
254
420
  case "sync": {
421
+ const registered =
422
+ this.#sessions.get(message.session.id)?.peer === peer;
255
423
  this.#sessions.set(message.session.id, {
256
424
  description: message.session,
257
425
  peer,
258
426
  });
259
- this.#notifyChange();
260
427
  await peer.send({
261
428
  type: "synced",
262
429
  id: message.id,
263
430
  sessionId: message.session.id,
264
431
  });
432
+ if (!registered)
433
+ await this.#notify(message.session.id, "Chappie connected");
434
+ this.#notifyChange();
265
435
  break;
266
436
  }
267
437
  case "unregister": {
@@ -292,36 +462,56 @@ export class Broker {
292
462
  requestedId: string | undefined,
293
463
  signal: AbortSignal,
294
464
  bindRequested: boolean,
295
- ): Promise<string> {
465
+ ): Promise<{
466
+ sessionId: string;
467
+ selection: InitializedSession["selection"];
468
+ }> {
296
469
  if (requestedId) {
297
470
  await this.#waitForSession(requestedId, signal);
298
471
  if (bindRequested && this.#state.binding(chatId) !== requestedId) {
299
- await this.#state.setBinding(chatId, requestedId);
300
- this.#notifyChange();
472
+ await this.#bind(chatId, requestedId);
301
473
  }
302
- return requestedId;
474
+ return { sessionId: requestedId, selection: "explicit" };
303
475
  }
304
476
 
305
477
  const boundId = this.#state.binding(chatId);
306
478
  if (boundId) {
307
479
  await this.#waitForSession(boundId, signal);
308
- return boundId;
480
+ return { sessionId: boundId, selection: "existing" };
309
481
  }
310
482
 
311
483
  for (;;) {
312
- const occupied = this.#state.boundSessions();
484
+ const occupied = this.#state.bindingCounts();
313
485
  const candidate = [...this.#sessions.keys()].find(
314
486
  (sessionId) => !occupied.has(sessionId),
315
487
  );
316
488
  if (candidate) {
317
- await this.#state.setBinding(chatId, candidate);
318
- this.#notifyChange();
319
- return candidate;
489
+ await this.#bind(chatId, candidate);
490
+ return { sessionId: candidate, selection: "automatic" };
320
491
  }
321
492
  await this.#waitForChange(signal);
322
493
  }
323
494
  }
324
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
+
325
515
  async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
326
516
  while (!this.#sessions.has(sessionId)) await this.#waitForChange(signal);
327
517
  }
@@ -363,7 +553,14 @@ export class Broker {
363
553
  const onAbort = (): void => {
364
554
  const pending = this.#pending.get(id);
365
555
  if (!pending) return;
366
- 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(() => {});
367
564
  this.#finishRequest(id, pending);
368
565
  pending.reject(abortError(signal));
369
566
  };
@@ -431,19 +628,12 @@ export class Broker {
431
628
  this.#sessions.delete(sessionId);
432
629
  this.#notifyChange();
433
630
  }
434
-
435
- async #readGlobalAgents(): Promise<string | undefined> {
436
- try {
437
- return await readFile(join(this.#agentDir, "AGENTS.md"), "utf8");
438
- } catch (error) {
439
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
440
- throw error;
441
- }
442
- }
443
631
  }
444
632
 
445
633
  function abortError(signal: AbortSignal): Error {
446
634
  return signal.reason instanceof Error
447
635
  ? signal.reason
448
- : new Error("Request cancelled");
636
+ : new Error(
637
+ typeof signal.reason === "string" ? signal.reason : "Request cancelled",
638
+ );
449
639
  }
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,
@@ -9,74 +8,46 @@ export interface DeliveryRecord {
9
8
  id: string;
10
9
  chatId: string;
11
10
  sessionId: string;
12
- sessionFile?: string;
13
- toolCallIds: string[];
14
- inlineResults?: ToolResultMessage[];
15
- error?: string;
16
- }
17
-
18
- export interface ResolvedDelivery extends DeliveryRecord {
11
+ cwd: string;
19
12
  toolResults: ToolResultMessage[];
13
+ error?: string;
20
14
  }
21
15
 
22
- export async function resolveDelivery(
23
- delivery: DeliveryRecord,
24
- ): Promise<ResolvedDelivery> {
25
- let toolResults = delivery.inlineResults ?? [];
26
- if (delivery.sessionFile && delivery.toolCallIds.length > 0) {
27
- const expected = new Set(delivery.toolCallIds);
28
- const found = new Map<string, ToolResultMessage>();
29
- for (const line of (await readFile(delivery.sessionFile, "utf8")).split(
30
- "\n",
31
- )) {
32
- if (!line) continue;
33
- const entry = JSON.parse(line) as {
34
- type?: string;
35
- message?: ToolResultMessage;
36
- };
37
- if (
38
- entry.type === "message" &&
39
- entry.message?.role === "toolResult" &&
40
- expected.has(entry.message.toolCallId)
41
- ) {
42
- found.set(entry.message.toolCallId, entry.message);
43
- }
44
- }
45
- toolResults = delivery.toolCallIds.flatMap((id) => {
46
- const result = found.get(id);
47
- return result ? [result] : [];
48
- });
49
- }
50
- 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
+ ]);
51
38
  }
52
39
 
53
- export function deliveryContent(deliveries: ResolvedDelivery[]) {
40
+ export function deliveryContent(deliveries: DeliveryRecord[]) {
54
41
  return deliveries.flatMap((delivery) => [
55
42
  {
56
43
  type: "text" as const,
57
44
  text: JSON.stringify({
58
45
  deferredResult: delivery.id,
59
46
  sessionId: delivery.sessionId,
47
+ cwd: delivery.cwd,
60
48
  error: delivery.error,
61
49
  }),
62
50
  },
63
- ...delivery.toolResults.flatMap((result) => [
64
- {
65
- type: "text" as const,
66
- text: JSON.stringify({
67
- toolCallId: result.toolCallId,
68
- toolName: result.toolName,
69
- isError: result.isError,
70
- }),
71
- },
72
- ...contentWithImageReferences(delivery.sessionId, result.content),
73
- ...resourceDescriptors(result.details).map((resource) => ({
74
- type: "resource_link" as const,
75
- uri: resource.uri,
76
- name: resource.name,
77
- mimeType: resource.mimeType,
78
- size: resource.size,
79
- })),
80
- ]),
51
+ ...toolResultsContent(delivery.toolResults, delivery.sessionId),
81
52
  ]);
82
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,11 +1,15 @@
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 first online, unbound Pi session; 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 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
+
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.
2
4
 
3
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.
4
6
 
5
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.
6
8
 
7
- Use chat to send progress and final messages that should appear in Pi. Use read, bash, edit, and write directly. init lists active tools with short descriptions; call tools with their names to load complete definitions before using other Pi tools through call. 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
+
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.
8
12
 
9
- 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.
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.
10
14
 
11
- 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. Host request deadlines include queueing and execution; use local persistent-process facilities for work intended to outlive one request.
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.