@zetaloop/chappie 0.4.1 → 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 coordinationInstructions =
29
- "Report your goal and progress through chat using your initialization name. The coordinator decides who continues, their tasks, and who exits. Follow the decision through chat and history; if asked to exit, leave a handoff and end this response.";
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,8 +49,6 @@ interface ChangeWaiter {
66
49
  }
67
50
 
68
51
  export interface Initialization {
69
- name?: string;
70
- code?: string;
71
52
  sessionId: string;
72
53
  instructions: string;
73
54
  }
@@ -75,7 +56,7 @@ export interface Initialization {
75
56
  export interface InitializedSession extends Omit<SessionInspection, "tools"> {
76
57
  selection: "existing" | "explicit" | "automatic";
77
58
  initialization?: Initialization;
78
- globalAgents?: string;
59
+ globalAgents?: { path: string };
79
60
  inputs: SessionInput[];
80
61
  tools: { name: string; description: string }[];
81
62
  }
@@ -103,12 +84,12 @@ export class Broker {
103
84
  readonly #sessions = new Map<string, RegisteredSession>();
104
85
  readonly #pending = new Map<number, PendingRequest>();
105
86
  readonly #waiters = new Set<ChangeWaiter>();
106
- readonly #operations = new Map<AbortSignal, Operation>();
107
- readonly #locks = new Map<string, boolean>();
108
- readonly #startingLocks = new Set<string>();
109
- 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
+ >();
110
92
  #ask = true;
111
- #sync = false;
112
93
  #nextRequestId = 1;
113
94
 
114
95
  constructor(agentDir: string) {
@@ -124,20 +105,17 @@ export class Broker {
124
105
  async start(): Promise<void> {
125
106
  const config = await readConfig(this.#agentDir);
126
107
  this.#ask = config.ask ?? true;
127
- this.#sync = config.sync ?? false;
128
108
  await this.#state.load();
129
109
  await this.#ipc.start(config.listen ?? false);
130
110
  }
131
111
 
132
112
  async close(): Promise<void> {
133
113
  const error = new Error("Chappie broker ended");
134
- for (const operation of this.#operations.values()) {
135
- 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);
136
117
  }
137
- this.#operations.clear();
138
- this.#locks.clear();
139
- this.#startingLocks.clear();
140
- this.#syncRevisions.clear();
118
+ this.#relays.clear();
141
119
  for (const [id, pending] of this.#pending) {
142
120
  this.#finishRequest(id, pending);
143
121
  pending.reject(error);
@@ -151,205 +129,8 @@ export class Broker {
151
129
  await this.#ipc.close();
152
130
  }
153
131
 
154
- async run<T>(
155
- chatId: string,
156
- sessionId: string | undefined,
157
- signal: AbortSignal,
158
- callback: (signal: AbortSignal) => Promise<T>,
159
- communication = false,
160
- ): Promise<T> {
161
- if (!communication) this.#requireUnlocked(chatId, sessionId, signal);
162
- if (!this.#sync) return callback(signal);
163
- const controller = new AbortController();
164
- const combined = AbortSignal.any([signal, controller.signal]);
165
- const operation: Operation = {
166
- chatId,
167
- sessionId,
168
- controller,
169
- communication,
170
- coordinating: false,
171
- revisions: new Map(),
172
- };
173
- this.#operations.set(combined, operation);
174
- this.#observe(operation, this.#state.binding(chatId));
175
- this.#observe(operation, sessionId);
176
- try {
177
- const result = await callback(combined);
178
- combined.throwIfAborted();
179
- const initialization = operation.initialization;
180
- if (initialization) {
181
- initialization.previousCode = this.#state.code(
182
- initialization.sessionId,
183
- );
184
- initialization.committed = true;
185
- await this.#state.setCode(
186
- initialization.sessionId,
187
- initialization.code,
188
- );
189
- combined.throwIfAborted();
190
- }
191
- return result;
192
- } catch (error) {
193
- await this.#rollbackInitialization(operation);
194
- throw error;
195
- } finally {
196
- this.#operations.delete(combined);
197
- }
198
- }
199
-
200
- deliveryAllowed(signal: AbortSignal): boolean {
201
- const operation = this.#operations.get(signal);
202
- if (!operation?.communication) return true;
203
- if (operation.coordinating) return false;
204
- for (const [sessionId, revision] of operation.revisions) {
205
- if (
206
- this.#locks.has(sessionId) ||
207
- (this.#syncRevisions.get(sessionId) ?? 0) !== revision
208
- )
209
- return false;
210
- }
211
- return true;
212
- }
213
-
214
- get syncEnabled(): boolean {
215
- return this.#sync;
216
- }
217
-
218
- syncState(sessionId: string) {
219
- return {
220
- locked: this.#locks.has(sessionId),
221
- verified: this.#locks.get(sessionId) ?? false,
222
- };
223
- }
224
-
225
- synchronizing(chatId?: string, sessionId?: string): string | undefined {
226
- const boundId = chatId ? this.#state.binding(chatId) : undefined;
227
- if (boundId && this.#locks.has(boundId)) return boundId;
228
- if (sessionId && this.#locks.has(sessionId)) return sessionId;
229
- return undefined;
230
- }
231
-
232
- async sync(
233
- chatId: string,
234
- sessionId: string | undefined,
235
- action: "start" | "verify" | "release",
236
- code: string | undefined,
237
- requestId: unknown,
238
- signal: AbortSignal,
239
- ) {
240
- if (!this.#sync) throw new Error("Session synchronization is disabled");
241
- signal.throwIfAborted();
242
- const target = sessionId ?? this.#state.binding(chatId);
243
- if (!target) throw new Error("Specify a Pi sessionId to synchronize");
244
- const locked = this.synchronizing(chatId);
245
- if (locked && locked !== target) throw new Error(syncMessage(locked));
246
- const activity = source(chatId, requestId);
247
- const result = { sessionId: target };
248
- if (action === "start") {
249
- if (!this.#locks.has(target)) {
250
- this.#locks.set(target, false);
251
- this.#startingLocks.add(target);
252
- this.#syncRevisions.set(
253
- target,
254
- (this.#syncRevisions.get(target) ?? 0) + 1,
255
- );
256
- const error = new Error(syncMessage(target));
257
- const rollbacks: Promise<void>[] = [];
258
- for (const operation of this.#operations.values()) {
259
- if (
260
- operation.sessionId !== target &&
261
- this.#state.binding(operation.chatId) !== target
262
- )
263
- continue;
264
- if (operation.initialization)
265
- rollbacks.push(this.#rollbackInitialization(operation));
266
- if (!operation.communication || operation.initialization)
267
- operation.controller.abort(error);
268
- }
269
- this.#notifyChange();
270
- try {
271
- await Promise.all(rollbacks);
272
- if (!this.#state.code(target)) {
273
- this.#locks.delete(target);
274
- this.#notifyChange();
275
- throw new Error(
276
- "Initialize this Pi session before starting synchronization",
277
- );
278
- }
279
- await this.#notify(
280
- target,
281
- `${chatLabel(activity)} started synchronization`,
282
- { ...activity, event: "sync_started" },
283
- );
284
- } finally {
285
- this.#startingLocks.delete(target);
286
- }
287
- }
288
- return {
289
- ...result,
290
- ...this.syncState(target),
291
- instructions: syncMessage(target),
292
- };
293
- }
294
- if (this.#startingLocks.has(target))
295
- throw new Error("Synchronization is still starting; retry this action");
296
- if (!this.#locks.has(target))
297
- throw new Error("This Pi session is not synchronizing");
298
- const current = this.#state.code(target);
299
- if (!current)
300
- throw new Error("This Pi session has no synchronization code");
301
- if (code !== current) {
302
- await this.#notify(
303
- target,
304
- `${chatLabel(activity)} could not verify synchronization`,
305
- { ...activity, event: "sync_rejected" },
306
- );
307
- throw new Error(
308
- `Invalid sync code. Ordinary tools remain locked. ${coordinationInstructions}`,
309
- );
310
- }
311
- if (action === "verify") {
312
- let renewed: string | undefined;
313
- if (!this.#locks.get(target)) {
314
- this.#locks.set(target, true);
315
- renewed = randomBytes(6).toString("hex");
316
- await this.#state.setCode(target, renewed);
317
- await this.#notify(
318
- target,
319
- `${chatLabel(activity)} verified synchronization`,
320
- { ...activity, event: "sync_verified" },
321
- );
322
- }
323
- return {
324
- ...result,
325
- ...this.syncState(target),
326
- ...(renewed ? { code: renewed } : {}),
327
- instructions:
328
- "You are the coordinator. Decide who continues, their tasks, and who exits; you may retain only one execution. Keep this code private. Release after your decisions are acknowledged and requested exits are complete.",
329
- };
330
- }
331
- if (!this.#locks.get(target))
332
- throw new Error(
333
- "Verify the initialization code before releasing synchronization",
334
- );
335
- this.#locks.delete(target);
336
- this.#notifyChange();
337
- await this.#notify(
338
- target,
339
- `${chatLabel(activity)} released synchronization`,
340
- { ...activity, event: "sync_released" },
341
- );
342
- return {
343
- ...result,
344
- ...this.syncState(target),
345
- instructions:
346
- "Synchronization released. Continue as directed by the coordinator.",
347
- };
348
- }
349
-
350
132
  listSessions(sessionId?: string): (SessionDescription & {
351
133
  bindingCount: number;
352
- sync?: { locked: boolean; verified: boolean };
353
134
  })[] {
354
135
  const counts = this.#state.bindingCounts();
355
136
  return [...this.#sessions.values()]
@@ -357,7 +138,6 @@ export class Broker {
357
138
  .map(({ description }) => ({
358
139
  ...description,
359
140
  bindingCount: counts.get(description.id) ?? 0,
360
- ...(this.#sync ? { sync: this.syncState(description.id) } : {}),
361
141
  }));
362
142
  }
363
143
 
@@ -410,8 +190,6 @@ export class Broker {
410
190
  sessionId,
411
191
  requestId,
412
192
  signal,
413
- false,
414
- true,
415
193
  );
416
194
  const result = await this.#request(
417
195
  target,
@@ -425,7 +203,7 @@ export class Broker {
425
203
  signal,
426
204
  );
427
205
  if ("message" in result) {
428
- const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
206
+ const inputs = result.inputs;
429
207
  await this.#ackInputs(target, inputs, signal);
430
208
  return {
431
209
  sessionId: target,
@@ -537,10 +315,8 @@ export class Broker {
537
315
  signal: AbortSignal,
538
316
  ): Promise<SessionInput[]> {
539
317
  const target = sessionId ?? this.#state.binding(chatId);
540
- if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
541
- return [];
318
+ if (!target || !this.#sessions.has(target)) return [];
542
319
  const { inputs } = await this.#inspect(target, signal);
543
- if (!this.deliveryAllowed(signal)) return [];
544
320
  await this.#ackInputs(target, inputs, signal);
545
321
  return inputs;
546
322
  }
@@ -558,7 +334,7 @@ export class Broker {
558
334
  requestId,
559
335
  signal,
560
336
  );
561
- this.#requireUnlocked(chatId, target, signal);
337
+ signal.throwIfAborted();
562
338
  const session = this.#sessions.get(target);
563
339
  if (!session) throw new Error(`Pi session ${target} is offline`);
564
340
  const question: QuestionRecord = {
@@ -590,12 +366,35 @@ export class Broker {
590
366
  id: string,
591
367
  signal: AbortSignal,
592
368
  ): Promise<Question> {
593
- 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) {
594
379
  signal.throwIfAborted();
380
+ if (!timeout.aborted) throw error;
595
381
  const question = this.#state.question(chatId, id);
596
- this.#requireUnlocked(chatId, question.sessionId, signal);
597
382
  if (question.loaded) return questionView(question);
598
- 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
+ );
599
398
  }
600
399
  }
601
400
 
@@ -607,6 +406,7 @@ export class Broker {
607
406
  ): Promise<Question> {
608
407
  let question = this.#state.question(chatId, id);
609
408
  if ((loaded || answer) && !question.loaded) {
409
+ this.#cooldown(chatId, question.sessionId);
610
410
  question = { ...question, loaded: true };
611
411
  await this.#state.addQuestion(question);
612
412
  this.#notifyChange();
@@ -638,29 +438,30 @@ export class Broker {
638
438
  }
639
439
 
640
440
  answers(chatId: string): QuestionRecord[] {
641
- if (this.synchronizing(chatId)) return [];
642
- return this.#state
643
- .answers(chatId)
644
- .filter((question) => !this.#locks.has(question.sessionId));
441
+ return this.#state.answers(chatId);
645
442
  }
646
443
 
647
444
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
648
- 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);
649
450
  await this.#waitForSession(sessionId, signal);
650
451
  const result = await this.#request(
651
452
  sessionId,
652
- (id) => ({ type: "readResource", id, sessionId, uri }),
453
+ (id) => ({ type: "readResource", id, sessionId, uri: requested.href }),
653
454
  signal,
654
455
  );
655
- 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
+ }
656
460
  throw new Error("Pi session returned no resource");
657
461
  }
658
462
 
659
463
  deliveries(chatId: string): DeliveryRecord[] {
660
- if (this.synchronizing(chatId)) return [];
661
- return this.#state
662
- .deliveries(chatId)
663
- .filter((delivery) => !this.#locks.has(delivery.sessionId));
464
+ return this.#state.deliveries(chatId);
664
465
  }
665
466
 
666
467
  acknowledge(
@@ -676,6 +477,39 @@ export class Broker {
676
477
  message: SessionMessage,
677
478
  ): Promise<void> {
678
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;
679
513
  case "sync": {
680
514
  const registered =
681
515
  this.#sessions.get(message.session.id)?.peer === peer;
@@ -724,7 +558,6 @@ export class Broker {
724
558
  requestId: unknown,
725
559
  signal: AbortSignal,
726
560
  bindRequested = false,
727
- communication = false,
728
561
  ): Promise<{
729
562
  sessionId: string;
730
563
  selection: InitializedSession["selection"];
@@ -736,25 +569,16 @@ export class Broker {
736
569
  let target = requestedId ?? boundId;
737
570
  if (!target) {
738
571
  const occupied = this.#state.bindingCounts();
739
- target = [...this.#sessions.keys()].find(
740
- (id) => !occupied.has(id) && !this.#locks.has(id),
741
- );
572
+ target = [...this.#sessions.keys()].find((id) => !occupied.has(id));
742
573
  }
743
- if (!communication) this.#requireUnlocked(chatId, target, signal);
744
574
  if (!target) {
745
575
  await this.#waitForChange(signal);
746
576
  continue;
747
577
  }
748
578
  await this.#waitForSession(target, signal);
749
579
  signal.throwIfAborted();
750
- if (!communication) this.#requireUnlocked(chatId, target, signal);
751
- const current = this.#state.binding(chatId);
752
- const initialize =
753
- bindRequested ||
754
- !current ||
755
- (current === target && this.#sync && !this.#state.code(target));
756
580
  const initialization =
757
- initialize && !this.synchronizing(chatId, target)
581
+ bindRequested || !this.#state.binding(chatId)
758
582
  ? await this.#join(chatId, target, requestId, signal, bindRequested)
759
583
  : undefined;
760
584
  return {
@@ -776,25 +600,13 @@ export class Broker {
776
600
  signal: AbortSignal,
777
601
  explicit = false,
778
602
  ): Promise<Initialization> {
779
- this.#requireUnlocked(chatId, sessionId, signal);
780
- const operation = this.#operations.get(signal);
781
- if (operation) this.#observe(operation, sessionId);
603
+ signal.throwIfAborted();
782
604
  const previous = this.#state.binding(chatId);
783
- 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);
784
608
  await this.#state.bind(chatId, sessionId);
785
- this.#requireUnlocked(chatId, sessionId, signal);
786
- if (code) {
787
- if (!operation)
788
- throw new Error(
789
- "Initialization is outside a tracked Chappie operation",
790
- );
791
- operation.initialization = {
792
- sessionId,
793
- code,
794
- previousCode: undefined,
795
- committed: false,
796
- };
797
- }
609
+ signal.throwIfAborted();
798
610
  const activity = source(chatId, requestId);
799
611
  if (previous !== sessionId) {
800
612
  this.#notifyChange();
@@ -804,58 +616,24 @@ export class Broker {
804
616
  event: "left",
805
617
  });
806
618
  }
807
- this.#requireUnlocked(chatId, sessionId, signal);
619
+ signal.throwIfAborted();
808
620
  await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
809
621
  ...activity,
810
622
  event: "joined",
811
623
  initialization: explicit ? "explicit" : "implicit",
812
624
  });
813
- const name = activity.requestId?.match(/\/([^/]+)$/)?.[1];
814
- const instructions = name
815
- ? `${historyInstructions} Use this name when coordinating through chat.`
816
- : historyInstructions;
817
625
  return {
818
626
  sessionId,
819
- ...(name ? { name } : {}),
820
- ...(code ? { code } : {}),
821
- instructions: code
822
- ? `${instructions} Keep this code private for sync verification.`
823
- : instructions,
627
+ instructions: observer ? observerInstructions : historyInstructions,
824
628
  };
825
629
  }
826
630
 
827
- #requireUnlocked(
828
- chatId: string,
829
- sessionId: string | undefined,
830
- signal: AbortSignal,
831
- ): void {
832
- signal.throwIfAborted();
833
- const operation = this.#operations.get(signal);
834
- if (operation) this.#observe(operation, sessionId);
835
- const locked = this.synchronizing(chatId, sessionId);
836
- if (locked) throw new Error(syncMessage(locked));
837
- }
838
-
839
- #observe(operation: Operation, sessionId: string | undefined): void {
840
- if (!sessionId) return;
841
- operation.sessionId ??= sessionId;
842
- if (!operation.revisions.has(sessionId)) {
843
- operation.revisions.set(
844
- sessionId,
845
- this.#syncRevisions.get(sessionId) ?? 0,
846
- );
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);
847
635
  }
848
- if (this.#locks.has(sessionId)) operation.coordinating = true;
849
- }
850
-
851
- async #rollbackInitialization(operation: Operation): Promise<void> {
852
- const initialization = operation.initialization;
853
- if (!initialization?.committed) return;
854
- initialization.committed = false;
855
- await this.#state.setCode(
856
- initialization.sessionId,
857
- initialization.previousCode,
858
- );
636
+ this.#cooldowns.set(JSON.stringify([chatId, sessionId]), now + 10_000);
859
637
  }
860
638
 
861
639
  async #notify(
@@ -906,12 +684,6 @@ export class Broker {
906
684
  message: (id: number) => BrokerMessage,
907
685
  signal: AbortSignal,
908
686
  ): Promise<SessionResult> {
909
- const operation = this.#operations.get(signal);
910
- if (operation) {
911
- this.#observe(operation, sessionId);
912
- if (!operation.communication)
913
- this.#requireUnlocked(operation.chatId, sessionId, signal);
914
- }
915
687
  const session = this.#sessions.get(sessionId);
916
688
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
917
689
  if (signal.aborted) throw abortError(signal);
@@ -979,6 +751,10 @@ export class Broker {
979
751
  }
980
752
 
981
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);
982
758
  for (const [sessionId, session] of this.#sessions) {
983
759
  if (session.peer === peer) this.#removeSession(sessionId);
984
760
  }
@@ -995,10 +771,6 @@ export class Broker {
995
771
  }
996
772
  }
997
773
 
998
- function syncMessage(sessionId: string): string {
999
- return `Pi session ${sessionId} is synchronizing. Ordinary tools and initialization are locked. Verify with your own initialization code; keep it private. ${coordinationInstructions} Only the verified coordinator can release.`;
1000
- }
1001
-
1002
774
  function abortError(signal: AbortSignal): Error {
1003
775
  return signal.reason instanceof Error
1004
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
  }