@zetaloop/chappie 0.4.0 → 0.5.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,4 +1,4 @@
1
- import { randomBytes, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
3
3
  import { type Activity, chatLabel, source } from "./activity.ts";
4
4
  import { readConfig } from "./config.ts";
@@ -25,8 +25,8 @@ import { type ResourceData, resourceSessionId } from "./resources.ts";
25
25
  import { State } from "./state.ts";
26
26
  import type { ToolInput } from "./tools.ts";
27
27
 
28
- const exitInstructions =
29
- "Send one final chat message stating your task, entry time, and that this execution is ending; keep all codes private. Then end this ChatGPT response immediately with no further tool calls. Do not wait for unlock, poll history, reinitialize, or resume this task after release.";
28
+ const observerInstructions =
29
+ "This ChatGPT conversation recently initialized or resumed work in this Pi session. A parallel execution is already continuing the task. Participate as an observer for this task: read history with observer: true, follow new entries with after and wait: true, and think independently. Leave execution and Pi communication to the ongoing work. Once its completion is recorded, explain the actual results in ChatGPT and finish your response. Continue observing this task rather than reinitializing to take over.";
30
30
 
31
31
  interface RegisteredSession {
32
32
  description: SessionDescription;
@@ -41,23 +41,6 @@ interface PendingRequest {
41
41
  onAbort(): void;
42
42
  }
43
43
 
44
- interface InitializationGrant {
45
- sessionId: string;
46
- code: string;
47
- previousCode: string | undefined;
48
- committed: boolean;
49
- }
50
-
51
- interface Operation {
52
- chatId: string;
53
- sessionId: string | undefined;
54
- controller: AbortController;
55
- communication: boolean;
56
- coordinating: boolean;
57
- revisions: Map<string, number>;
58
- initialization?: InitializationGrant;
59
- }
60
-
61
44
  interface ChangeWaiter {
62
45
  resolve(): void;
63
46
  reject(error: Error): void;
@@ -66,7 +49,6 @@ interface ChangeWaiter {
66
49
  }
67
50
 
68
51
  export interface Initialization {
69
- code?: string;
70
52
  sessionId: string;
71
53
  instructions: string;
72
54
  }
@@ -74,7 +56,7 @@ export interface Initialization {
74
56
  export interface InitializedSession extends Omit<SessionInspection, "tools"> {
75
57
  selection: "existing" | "explicit" | "automatic";
76
58
  initialization?: Initialization;
77
- globalAgents?: string;
59
+ globalAgents?: { path: string };
78
60
  inputs: SessionInput[];
79
61
  tools: { name: string; description: string }[];
80
62
  }
@@ -102,12 +84,12 @@ export class Broker {
102
84
  readonly #sessions = new Map<string, RegisteredSession>();
103
85
  readonly #pending = new Map<number, PendingRequest>();
104
86
  readonly #waiters = new Set<ChangeWaiter>();
105
- readonly #operations = new Map<AbortSignal, Operation>();
106
- readonly #locks = new Map<string, boolean>();
107
- readonly #startingLocks = new Set<string>();
108
- readonly #syncRevisions = new Map<string, number>();
87
+ readonly #cooldowns = new Map<string, number>();
88
+ readonly #relays = new Map<
89
+ JsonLinePeer<SessionMessage, BrokerMessage>,
90
+ Map<number, AbortController>
91
+ >();
109
92
  #ask = true;
110
- #sync = false;
111
93
  #nextRequestId = 1;
112
94
 
113
95
  constructor(agentDir: string) {
@@ -123,20 +105,17 @@ export class Broker {
123
105
  async start(): Promise<void> {
124
106
  const config = await readConfig(this.#agentDir);
125
107
  this.#ask = config.ask ?? true;
126
- this.#sync = config.sync ?? false;
127
108
  await this.#state.load();
128
109
  await this.#ipc.start(config.listen ?? false);
129
110
  }
130
111
 
131
112
  async close(): Promise<void> {
132
113
  const error = new Error("Chappie broker ended");
133
- for (const operation of this.#operations.values()) {
134
- operation.controller.abort(error);
114
+ this.#cooldowns.clear();
115
+ for (const relays of this.#relays.values()) {
116
+ for (const controller of relays.values()) controller.abort(error);
135
117
  }
136
- this.#operations.clear();
137
- this.#locks.clear();
138
- this.#startingLocks.clear();
139
- this.#syncRevisions.clear();
118
+ this.#relays.clear();
140
119
  for (const [id, pending] of this.#pending) {
141
120
  this.#finishRequest(id, pending);
142
121
  pending.reject(error);
@@ -150,205 +129,8 @@ export class Broker {
150
129
  await this.#ipc.close();
151
130
  }
152
131
 
153
- async run<T>(
154
- chatId: string,
155
- sessionId: string | undefined,
156
- signal: AbortSignal,
157
- callback: (signal: AbortSignal) => Promise<T>,
158
- communication = false,
159
- ): Promise<T> {
160
- if (!communication) this.#requireUnlocked(chatId, sessionId, signal);
161
- if (!this.#sync) return callback(signal);
162
- const controller = new AbortController();
163
- const combined = AbortSignal.any([signal, controller.signal]);
164
- const operation: Operation = {
165
- chatId,
166
- sessionId,
167
- controller,
168
- communication,
169
- coordinating: false,
170
- revisions: new Map(),
171
- };
172
- this.#operations.set(combined, operation);
173
- this.#observe(operation, this.#state.binding(chatId));
174
- this.#observe(operation, sessionId);
175
- try {
176
- const result = await callback(combined);
177
- combined.throwIfAborted();
178
- const initialization = operation.initialization;
179
- if (initialization) {
180
- initialization.previousCode = this.#state.code(
181
- initialization.sessionId,
182
- );
183
- initialization.committed = true;
184
- await this.#state.setCode(
185
- initialization.sessionId,
186
- initialization.code,
187
- );
188
- combined.throwIfAborted();
189
- }
190
- return result;
191
- } catch (error) {
192
- await this.#rollbackInitialization(operation);
193
- throw error;
194
- } finally {
195
- this.#operations.delete(combined);
196
- }
197
- }
198
-
199
- deliveryAllowed(signal: AbortSignal): boolean {
200
- const operation = this.#operations.get(signal);
201
- if (!operation?.communication) return true;
202
- if (operation.coordinating) return false;
203
- for (const [sessionId, revision] of operation.revisions) {
204
- if (
205
- this.#locks.has(sessionId) ||
206
- (this.#syncRevisions.get(sessionId) ?? 0) !== revision
207
- )
208
- return false;
209
- }
210
- return true;
211
- }
212
-
213
- get syncEnabled(): boolean {
214
- return this.#sync;
215
- }
216
-
217
- syncState(sessionId: string) {
218
- return {
219
- locked: this.#locks.has(sessionId),
220
- verified: this.#locks.get(sessionId) ?? false,
221
- };
222
- }
223
-
224
- synchronizing(chatId?: string, sessionId?: string): string | undefined {
225
- const boundId = chatId ? this.#state.binding(chatId) : undefined;
226
- if (boundId && this.#locks.has(boundId)) return boundId;
227
- if (sessionId && this.#locks.has(sessionId)) return sessionId;
228
- return undefined;
229
- }
230
-
231
- async sync(
232
- chatId: string,
233
- sessionId: string | undefined,
234
- action: "start" | "verify" | "release",
235
- code: string | undefined,
236
- requestId: unknown,
237
- signal: AbortSignal,
238
- ) {
239
- if (!this.#sync) throw new Error("Session synchronization is disabled");
240
- signal.throwIfAborted();
241
- const target = sessionId ?? this.#state.binding(chatId);
242
- if (!target) throw new Error("Specify a Pi sessionId to synchronize");
243
- const locked = this.synchronizing(chatId);
244
- if (locked && locked !== target) throw new Error(syncMessage(locked));
245
- const activity = source(chatId, requestId);
246
- const result = { sessionId: target };
247
- if (action === "start") {
248
- if (!this.#locks.has(target)) {
249
- this.#locks.set(target, false);
250
- this.#startingLocks.add(target);
251
- this.#syncRevisions.set(
252
- target,
253
- (this.#syncRevisions.get(target) ?? 0) + 1,
254
- );
255
- const error = new Error(syncMessage(target));
256
- const rollbacks: Promise<void>[] = [];
257
- for (const operation of this.#operations.values()) {
258
- if (
259
- operation.sessionId !== target &&
260
- this.#state.binding(operation.chatId) !== target
261
- )
262
- continue;
263
- if (operation.initialization)
264
- rollbacks.push(this.#rollbackInitialization(operation));
265
- if (!operation.communication || operation.initialization)
266
- operation.controller.abort(error);
267
- }
268
- this.#notifyChange();
269
- try {
270
- await Promise.all(rollbacks);
271
- if (!this.#state.code(target)) {
272
- this.#locks.delete(target);
273
- this.#notifyChange();
274
- throw new Error(
275
- "Initialize this Pi session before starting synchronization",
276
- );
277
- }
278
- await this.#notify(
279
- target,
280
- `${chatLabel(activity)} started synchronization`,
281
- { ...activity, event: "sync_started" },
282
- );
283
- } finally {
284
- this.#startingLocks.delete(target);
285
- }
286
- }
287
- return {
288
- ...result,
289
- ...this.syncState(target),
290
- instructions: syncMessage(target),
291
- };
292
- }
293
- if (this.#startingLocks.has(target))
294
- throw new Error("Synchronization is still starting; retry this action");
295
- if (!this.#locks.has(target))
296
- throw new Error("This Pi session is not synchronizing");
297
- const current = this.#state.code(target);
298
- if (!current)
299
- throw new Error("This Pi session has no synchronization code");
300
- if (code !== current) {
301
- await this.#notify(
302
- target,
303
- `${chatLabel(activity)} could not verify synchronization`,
304
- { ...activity, event: "sync_rejected" },
305
- );
306
- throw new Error(
307
- `The sync code is missing or outdated. This execution is a stale, accidental duplicate. ${exitInstructions}`,
308
- );
309
- }
310
- if (action === "verify") {
311
- let renewed: string | undefined;
312
- if (!this.#locks.get(target)) {
313
- this.#locks.set(target, true);
314
- renewed = randomBytes(6).toString("hex");
315
- await this.#state.setCode(target, renewed);
316
- await this.#notify(
317
- target,
318
- `${chatLabel(activity)} verified synchronization`,
319
- { ...activity, event: "sync_verified" },
320
- );
321
- }
322
- return {
323
- ...result,
324
- ...this.syncState(target),
325
- ...(renewed ? { code: renewed } : {}),
326
- instructions:
327
- "This is the surviving execution. Keep the verified code private in this ChatGPT context. Use chat and history to identify every observed conflicting execution and require its final exit. Confirm they have ended their responses, rather than only paused ordinary tools. Verification, idle Pi status, or a quiet history page alone does not establish their exit. Release with this code only after explicit final exit statements and resolution of conflicting activity; ordinary tools remain locked until then.",
328
- };
329
- }
330
- if (!this.#locks.get(target))
331
- throw new Error(
332
- "Verify the initialization code before releasing synchronization",
333
- );
334
- this.#locks.delete(target);
335
- this.#notifyChange();
336
- await this.#notify(
337
- target,
338
- `${chatLabel(activity)} released synchronization`,
339
- { ...activity, event: "sync_released" },
340
- );
341
- return {
342
- ...result,
343
- ...this.syncState(target),
344
- instructions:
345
- "Synchronization released. The surviving execution may continue the current task. Executions with rejected or unavailable codes remain finished.",
346
- };
347
- }
348
-
349
132
  listSessions(sessionId?: string): (SessionDescription & {
350
133
  bindingCount: number;
351
- sync?: { locked: boolean; verified: boolean };
352
134
  })[] {
353
135
  const counts = this.#state.bindingCounts();
354
136
  return [...this.#sessions.values()]
@@ -356,7 +138,6 @@ export class Broker {
356
138
  .map(({ description }) => ({
357
139
  ...description,
358
140
  bindingCount: counts.get(description.id) ?? 0,
359
- ...(this.#sync ? { sync: this.syncState(description.id) } : {}),
360
141
  }));
361
142
  }
362
143
 
@@ -409,8 +190,6 @@ export class Broker {
409
190
  sessionId,
410
191
  requestId,
411
192
  signal,
412
- false,
413
- true,
414
193
  );
415
194
  const result = await this.#request(
416
195
  target,
@@ -424,7 +203,7 @@ export class Broker {
424
203
  signal,
425
204
  );
426
205
  if ("message" in result) {
427
- const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
206
+ const inputs = result.inputs;
428
207
  await this.#ackInputs(target, inputs, signal);
429
208
  return {
430
209
  sessionId: target,
@@ -536,10 +315,8 @@ export class Broker {
536
315
  signal: AbortSignal,
537
316
  ): Promise<SessionInput[]> {
538
317
  const target = sessionId ?? this.#state.binding(chatId);
539
- if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
540
- return [];
318
+ if (!target || !this.#sessions.has(target)) return [];
541
319
  const { inputs } = await this.#inspect(target, signal);
542
- if (!this.deliveryAllowed(signal)) return [];
543
320
  await this.#ackInputs(target, inputs, signal);
544
321
  return inputs;
545
322
  }
@@ -557,7 +334,7 @@ export class Broker {
557
334
  requestId,
558
335
  signal,
559
336
  );
560
- this.#requireUnlocked(chatId, target, signal);
337
+ signal.throwIfAborted();
561
338
  const session = this.#sessions.get(target);
562
339
  if (!session) throw new Error(`Pi session ${target} is offline`);
563
340
  const question: QuestionRecord = {
@@ -589,12 +366,35 @@ export class Broker {
589
366
  id: string,
590
367
  signal: AbortSignal,
591
368
  ): Promise<Question> {
592
- for (;;) {
369
+ const timeout = AbortSignal.timeout(10_000);
370
+ const combined = AbortSignal.any([signal, timeout]);
371
+ try {
372
+ for (;;) {
373
+ signal.throwIfAborted();
374
+ const question = this.#state.question(chatId, id);
375
+ if (question.loaded) return questionView(question);
376
+ await this.#waitForChange(combined);
377
+ }
378
+ } catch (error) {
593
379
  signal.throwIfAborted();
380
+ if (!timeout.aborted) throw error;
594
381
  const question = this.#state.question(chatId, id);
595
- this.#requireUnlocked(chatId, question.sessionId, signal);
596
382
  if (question.loaded) return questionView(question);
597
- await this.#waitForChange(signal);
383
+ if (!question.answer) {
384
+ await this.#state.answer(chatId, id, {
385
+ selections: [],
386
+ text: "",
387
+ skipped: true,
388
+ });
389
+ void this.#notify(
390
+ question.sessionId,
391
+ `Question skipped after display timeout: ${question.question}`,
392
+ { event: "skipped", chatId },
393
+ ).catch(() => {});
394
+ }
395
+ throw new Error(
396
+ "Question widget did not load within 10 seconds. The question was automatically skipped. Use an installed Pi interactive tool through call if an answer is needed.",
397
+ );
598
398
  }
599
399
  }
600
400
 
@@ -606,6 +406,7 @@ export class Broker {
606
406
  ): Promise<Question> {
607
407
  let question = this.#state.question(chatId, id);
608
408
  if ((loaded || answer) && !question.loaded) {
409
+ this.#cooldown(chatId, question.sessionId);
609
410
  question = { ...question, loaded: true };
610
411
  await this.#state.addQuestion(question);
611
412
  this.#notifyChange();
@@ -637,29 +438,30 @@ export class Broker {
637
438
  }
638
439
 
639
440
  answers(chatId: string): QuestionRecord[] {
640
- if (this.synchronizing(chatId)) return [];
641
- return this.#state
642
- .answers(chatId)
643
- .filter((question) => !this.#locks.has(question.sessionId));
441
+ return this.#state.answers(chatId);
644
442
  }
645
443
 
646
444
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
647
- const sessionId = resourceSessionId(uri);
445
+ const requested = new URL(uri);
446
+ const chatId = requested.searchParams.get("chatId");
447
+ requested.search = "";
448
+ const sessionId = resourceSessionId(requested.href);
449
+ if (chatId) this.#cooldown(chatId, sessionId);
648
450
  await this.#waitForSession(sessionId, signal);
649
451
  const result = await this.#request(
650
452
  sessionId,
651
- (id) => ({ type: "readResource", id, sessionId, uri }),
453
+ (id) => ({ type: "readResource", id, sessionId, uri: requested.href }),
652
454
  signal,
653
455
  );
654
- if ("resource" in result) return result.resource;
456
+ if ("resource" in result) {
457
+ if (chatId) this.#cooldown(chatId, sessionId);
458
+ return { ...result.resource, uri };
459
+ }
655
460
  throw new Error("Pi session returned no resource");
656
461
  }
657
462
 
658
463
  deliveries(chatId: string): DeliveryRecord[] {
659
- if (this.synchronizing(chatId)) return [];
660
- return this.#state
661
- .deliveries(chatId)
662
- .filter((delivery) => !this.#locks.has(delivery.sessionId));
464
+ return this.#state.deliveries(chatId);
663
465
  }
664
466
 
665
467
  acknowledge(
@@ -675,6 +477,39 @@ export class Broker {
675
477
  message: SessionMessage,
676
478
  ): Promise<void> {
677
479
  switch (message.type) {
480
+ case "request": {
481
+ let relays = this.#relays.get(peer);
482
+ if (!relays) {
483
+ relays = new Map();
484
+ this.#relays.set(peer, relays);
485
+ }
486
+ const controller = new AbortController();
487
+ relays.set(message.id, controller);
488
+ void this.#request(
489
+ message.request.sessionId,
490
+ (id) => ({ ...message.request, id }),
491
+ controller.signal,
492
+ )
493
+ .then(
494
+ (result) =>
495
+ peer.send({ ...result, type: "response", id: message.id }),
496
+ (error: unknown) =>
497
+ peer.send({
498
+ type: "response",
499
+ id: message.id,
500
+ error: error instanceof Error ? error.message : String(error),
501
+ }),
502
+ )
503
+ .finally(() => relays.delete(message.id))
504
+ .catch(() => {});
505
+ break;
506
+ }
507
+ case "cancelRequest":
508
+ this.#relays
509
+ .get(peer)
510
+ ?.get(message.id)
511
+ ?.abort(new Error("Transfer cancelled"));
512
+ break;
678
513
  case "sync": {
679
514
  const registered =
680
515
  this.#sessions.get(message.session.id)?.peer === peer;
@@ -723,7 +558,6 @@ export class Broker {
723
558
  requestId: unknown,
724
559
  signal: AbortSignal,
725
560
  bindRequested = false,
726
- communication = false,
727
561
  ): Promise<{
728
562
  sessionId: string;
729
563
  selection: InitializedSession["selection"];
@@ -735,25 +569,16 @@ export class Broker {
735
569
  let target = requestedId ?? boundId;
736
570
  if (!target) {
737
571
  const occupied = this.#state.bindingCounts();
738
- target = [...this.#sessions.keys()].find(
739
- (id) => !occupied.has(id) && !this.#locks.has(id),
740
- );
572
+ target = [...this.#sessions.keys()].find((id) => !occupied.has(id));
741
573
  }
742
- if (!communication) this.#requireUnlocked(chatId, target, signal);
743
574
  if (!target) {
744
575
  await this.#waitForChange(signal);
745
576
  continue;
746
577
  }
747
578
  await this.#waitForSession(target, signal);
748
579
  signal.throwIfAborted();
749
- if (!communication) this.#requireUnlocked(chatId, target, signal);
750
- const current = this.#state.binding(chatId);
751
- const initialize =
752
- bindRequested ||
753
- !current ||
754
- (current === target && this.#sync && !this.#state.code(target));
755
580
  const initialization =
756
- initialize && !this.synchronizing(chatId, target)
581
+ bindRequested || !this.#state.binding(chatId)
757
582
  ? await this.#join(chatId, target, requestId, signal, bindRequested)
758
583
  : undefined;
759
584
  return {
@@ -775,25 +600,13 @@ export class Broker {
775
600
  signal: AbortSignal,
776
601
  explicit = false,
777
602
  ): Promise<Initialization> {
778
- this.#requireUnlocked(chatId, sessionId, signal);
779
- const operation = this.#operations.get(signal);
780
- if (operation) this.#observe(operation, sessionId);
603
+ signal.throwIfAborted();
781
604
  const previous = this.#state.binding(chatId);
782
- const code = this.#sync ? randomBytes(6).toString("hex") : undefined;
605
+ const key = JSON.stringify([chatId, sessionId]);
606
+ const observer = (this.#cooldowns.get(key) ?? 0) > Date.now();
607
+ if (!observer) this.#cooldown(chatId, sessionId);
783
608
  await this.#state.bind(chatId, sessionId);
784
- this.#requireUnlocked(chatId, sessionId, signal);
785
- if (code) {
786
- if (!operation)
787
- throw new Error(
788
- "Initialization is outside a tracked Chappie operation",
789
- );
790
- operation.initialization = {
791
- sessionId,
792
- code,
793
- previousCode: undefined,
794
- committed: false,
795
- };
796
- }
609
+ signal.throwIfAborted();
797
610
  const activity = source(chatId, requestId);
798
611
  if (previous !== sessionId) {
799
612
  this.#notifyChange();
@@ -803,7 +616,7 @@ export class Broker {
803
616
  event: "left",
804
617
  });
805
618
  }
806
- this.#requireUnlocked(chatId, sessionId, signal);
619
+ signal.throwIfAborted();
807
620
  await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
808
621
  ...activity,
809
622
  event: "joined",
@@ -811,45 +624,16 @@ export class Broker {
811
624
  });
812
625
  return {
813
626
  sessionId,
814
- ...(code ? { code } : {}),
815
- instructions: code
816
- ? `${historyInstructions} Retain this initialization code in this ChatGPT context for sync verification; Pi history and chat messages contain only coordination details.`
817
- : historyInstructions,
627
+ instructions: observer ? observerInstructions : historyInstructions,
818
628
  };
819
629
  }
820
630
 
821
- #requireUnlocked(
822
- chatId: string,
823
- sessionId: string | undefined,
824
- signal: AbortSignal,
825
- ): void {
826
- signal.throwIfAborted();
827
- const operation = this.#operations.get(signal);
828
- if (operation) this.#observe(operation, sessionId);
829
- const locked = this.synchronizing(chatId, sessionId);
830
- if (locked) throw new Error(syncMessage(locked));
831
- }
832
-
833
- #observe(operation: Operation, sessionId: string | undefined): void {
834
- if (!sessionId) return;
835
- operation.sessionId ??= sessionId;
836
- if (!operation.revisions.has(sessionId)) {
837
- operation.revisions.set(
838
- sessionId,
839
- this.#syncRevisions.get(sessionId) ?? 0,
840
- );
631
+ #cooldown(chatId: string, sessionId: string): void {
632
+ const now = Date.now();
633
+ for (const [key, expires] of this.#cooldowns) {
634
+ if (expires <= now) this.#cooldowns.delete(key);
841
635
  }
842
- if (this.#locks.has(sessionId)) operation.coordinating = true;
843
- }
844
-
845
- async #rollbackInitialization(operation: Operation): Promise<void> {
846
- const initialization = operation.initialization;
847
- if (!initialization?.committed) return;
848
- initialization.committed = false;
849
- await this.#state.setCode(
850
- initialization.sessionId,
851
- initialization.previousCode,
852
- );
636
+ this.#cooldowns.set(JSON.stringify([chatId, sessionId]), now + 10_000);
853
637
  }
854
638
 
855
639
  async #notify(
@@ -900,12 +684,6 @@ export class Broker {
900
684
  message: (id: number) => BrokerMessage,
901
685
  signal: AbortSignal,
902
686
  ): Promise<SessionResult> {
903
- const operation = this.#operations.get(signal);
904
- if (operation) {
905
- this.#observe(operation, sessionId);
906
- if (!operation.communication)
907
- this.#requireUnlocked(operation.chatId, sessionId, signal);
908
- }
909
687
  const session = this.#sessions.get(sessionId);
910
688
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
911
689
  if (signal.aborted) throw abortError(signal);
@@ -973,6 +751,10 @@ export class Broker {
973
751
  }
974
752
 
975
753
  #removePeer(peer: JsonLinePeer<SessionMessage, BrokerMessage>): void {
754
+ for (const controller of this.#relays.get(peer)?.values() ?? []) {
755
+ controller.abort(new Error("Pi session disconnected"));
756
+ }
757
+ this.#relays.delete(peer);
976
758
  for (const [sessionId, session] of this.#sessions) {
977
759
  if (session.peer === peer) this.#removeSession(sessionId);
978
760
  }
@@ -989,10 +771,6 @@ export class Broker {
989
771
  }
990
772
  }
991
773
 
992
- function syncMessage(sessionId: string): string {
993
- return `Pi session ${sessionId} is synchronizing to end duplicate executions. Ordinary tools and initialization are locked for this session and its bound ChatGPT conversations. Verify with the code returned by your own most recent initialization. With a missing or rejected code, this execution is an accidental duplicate: ${exitInstructions} The verified execution must confirm the conflicting executions' final exits before releasing the lock.`;
994
- }
995
-
996
774
  function abortError(signal: AbortSignal): Error {
997
775
  return signal.reason instanceof Error
998
776
  ? signal.reason
package/src/config.ts CHANGED
@@ -6,7 +6,6 @@ const configSchema = z.object({
6
6
  ask: z.boolean().optional(),
7
7
  connect: z.string().min(1).optional(),
8
8
  listen: z.union([z.boolean(), z.number().int().min(1).max(65535)]).optional(),
9
- sync: z.boolean().optional(),
10
9
  });
11
10
 
12
11
  export async function readConfig(agentDir: string) {
package/src/history.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import * as z from "zod";
3
- import type { Source } from "./activity.ts";
3
+ import type { Activity, Source } from "./activity.ts";
4
4
  import { toolResultsContent } from "./delivery.ts";
5
5
  import type { ProviderOutput } from "./provider.ts";
6
6
  import { contentWithImageReferences, rememberImages } from "./resources.ts";
@@ -22,6 +22,16 @@ export const historyInput = z.object({
22
22
  .min(1)
23
23
  .optional()
24
24
  .describe("Read entries after this entry ID, starting with the earliest"),
25
+ wait: z
26
+ .boolean()
27
+ .optional()
28
+ .describe(
29
+ "Wait up to 30 seconds for new entries when caught up; before reads return immediately",
30
+ ),
31
+ observer: z
32
+ .boolean()
33
+ .optional()
34
+ .describe("Read work progress as an observer"),
25
35
  });
26
36
 
27
37
  export type HistoryRange = z.infer<typeof historyInput>;
@@ -48,7 +58,10 @@ export function historyResult(
48
58
  case "custom_message":
49
59
  return entry.customType !== "chappie.request";
50
60
  case "custom":
51
- return entry.customType === "chappie.notice";
61
+ return (
62
+ entry.customType === "chappie.notice" &&
63
+ (entry.data as Activity | undefined)?.event !== "history"
64
+ );
52
65
  case "compaction":
53
66
  case "branch_summary":
54
67
  return true;
package/src/index.ts CHANGED
@@ -34,6 +34,9 @@ export default async function chappie(pi: ExtensionAPI): Promise<void> {
34
34
  const config = await readConfig(agentDir);
35
35
  const session = new LocalSession(pi, agentDir, config.connect);
36
36
  session.install();
37
- pi.registerTool(transfer);
37
+ pi.registerTool({
38
+ ...transfer,
39
+ execute: (...args) => session.transfer(...args),
40
+ });
38
41
  pi.registerProvider(createChappieProvider((output) => session.start(output)));
39
42
  }