@zetaloop/chappie 0.3.1 → 0.4.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,7 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
3
+ import { type Activity, chatLabel, source } from "./activity.ts";
3
4
  import { readConfig } from "./config.ts";
4
5
  import type { DeliveryRecord } from "./delivery.ts";
6
+ import { type HistoryRange, historyInstructions } from "./history.ts";
5
7
  import {
6
8
  type BrokerMessage,
7
9
  IpcServer,
@@ -23,6 +25,9 @@ import { type ResourceData, resourceSessionId } from "./resources.ts";
23
25
  import { State } from "./state.ts";
24
26
  import type { ToolInput } from "./tools.ts";
25
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.";
30
+
26
31
  interface RegisteredSession {
27
32
  description: SessionDescription;
28
33
  peer: JsonLinePeer<SessionMessage, BrokerMessage>;
@@ -36,9 +41,21 @@ interface PendingRequest {
36
41
  onAbort(): void;
37
42
  }
38
43
 
39
- interface Workflow {
40
- id: string;
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;
41
54
  controller: AbortController;
55
+ communication: boolean;
56
+ coordinating: boolean;
57
+ revisions: Map<string, number>;
58
+ initialization?: InitializationGrant;
42
59
  }
43
60
 
44
61
  interface ChangeWaiter {
@@ -48,28 +65,34 @@ interface ChangeWaiter {
48
65
  onAbort(): void;
49
66
  }
50
67
 
68
+ export interface Initialization {
69
+ code?: string;
70
+ sessionId: string;
71
+ instructions: string;
72
+ }
73
+
51
74
  export interface InitializedSession extends Omit<SessionInspection, "tools"> {
52
75
  selection: "existing" | "explicit" | "automatic";
76
+ initialization?: Initialization;
53
77
  globalAgents?: string;
54
78
  inputs: SessionInput[];
55
79
  tools: { name: string; description: string }[];
56
80
  }
57
81
 
58
82
  export interface InspectedSession extends SessionInspection {
83
+ initialization?: Initialization;
59
84
  inputs: SessionInput[];
60
85
  }
61
86
 
62
87
  export interface ChatResult {
88
+ initialization?: Initialization;
63
89
  sessionId: string;
64
90
  cwd: string;
65
91
  inputs: SessionInput[];
66
92
  }
67
93
 
68
- export interface CallResult {
69
- sessionId: string;
70
- cwd: string;
94
+ export interface CallResult extends ChatResult {
71
95
  toolResults: ToolResultMessage[];
72
- inputs: SessionInput[];
73
96
  }
74
97
 
75
98
  export class Broker {
@@ -79,9 +102,12 @@ export class Broker {
79
102
  readonly #sessions = new Map<string, RegisteredSession>();
80
103
  readonly #pending = new Map<number, PendingRequest>();
81
104
  readonly #waiters = new Set<ChangeWaiter>();
82
- readonly #workflows = new Map<string, Workflow>();
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>();
83
109
  #ask = true;
84
- #latestWorkflow = false;
110
+ #sync = false;
85
111
  #nextRequestId = 1;
86
112
 
87
113
  constructor(agentDir: string) {
@@ -97,17 +123,20 @@ export class Broker {
97
123
  async start(): Promise<void> {
98
124
  const config = await readConfig(this.#agentDir);
99
125
  this.#ask = config.ask ?? true;
100
- this.#latestWorkflow = config.latestWorkflow ?? false;
126
+ this.#sync = config.sync ?? false;
101
127
  await this.#state.load();
102
128
  await this.#ipc.start(config.listen ?? false);
103
129
  }
104
130
 
105
131
  async close(): Promise<void> {
106
132
  const error = new Error("Chappie broker ended");
107
- for (const workflow of this.#workflows.values()) {
108
- workflow.controller.abort(error);
133
+ for (const operation of this.#operations.values()) {
134
+ operation.controller.abort(error);
109
135
  }
110
- this.#workflows.clear();
136
+ this.#operations.clear();
137
+ this.#locks.clear();
138
+ this.#startingLocks.clear();
139
+ this.#syncRevisions.clear();
111
140
  for (const [id, pending] of this.#pending) {
112
141
  this.#finishRequest(id, pending);
113
142
  pending.reject(error);
@@ -121,48 +150,213 @@ export class Broker {
121
150
  await this.#ipc.close();
122
151
  }
123
152
 
124
- async workflow(
125
- chatId: string | undefined,
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,
126
236
  requestId: unknown,
127
237
  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;
238
+ ) {
239
+ if (!this.#sync) throw new Error("Session synchronization is disabled");
137
240
  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);
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
+ };
151
292
  }
152
- const combined = AbortSignal.any([signal, workflow.controller.signal]);
153
- combined.throwIfAborted();
154
- return combined;
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
+ };
155
347
  }
156
348
 
157
- listSessions(
158
- sessionId?: string,
159
- ): (SessionDescription & { bindingCount: number })[] {
349
+ listSessions(sessionId?: string): (SessionDescription & {
350
+ bindingCount: number;
351
+ sync?: { locked: boolean; verified: boolean };
352
+ })[] {
160
353
  const counts = this.#state.bindingCounts();
161
354
  return [...this.#sessions.values()]
162
355
  .filter(({ description }) => !sessionId || description.id === sessionId)
163
356
  .map(({ description }) => ({
164
357
  ...description,
165
358
  bindingCount: counts.get(description.id) ?? 0,
359
+ ...(this.#sync ? { sync: this.syncState(description.id) } : {}),
166
360
  }));
167
361
  }
168
362
 
@@ -177,24 +371,22 @@ export class Broker {
177
371
  async initialize(
178
372
  chatId: string,
179
373
  sessionId: string | undefined,
374
+ requestId: unknown,
180
375
  signal: AbortSignal,
181
376
  ): Promise<InitializedSession> {
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`);
377
+ const {
378
+ sessionId: target,
379
+ selection,
380
+ initialization,
381
+ } = await this.#selectSession(chatId, sessionId, requestId, signal, true);
191
382
  const { inspection, inputs, globalAgents } = await this.#inspect(
192
383
  target,
193
384
  signal,
194
385
  );
195
- await this.#ackInputs(target, inputs);
386
+ await this.#ackInputs(target, inputs, signal);
196
387
  return {
197
388
  selection,
389
+ ...(initialization ? { initialization } : {}),
198
390
  ...inspection,
199
391
  tools: inspection.tools.map(({ name, description }) => ({
200
392
  name,
@@ -209,22 +401,37 @@ export class Broker {
209
401
  chatId: string,
210
402
  sessionId: string | undefined,
211
403
  text: string,
404
+ requestId: unknown,
212
405
  signal: AbortSignal,
213
406
  ): Promise<ChatResult> {
214
- const { sessionId: target } = await this.#selectSession(
407
+ const { sessionId: target, initialization } = await this.#selectSession(
215
408
  chatId,
216
409
  sessionId,
410
+ requestId,
217
411
  signal,
218
412
  false,
413
+ true,
219
414
  );
220
415
  const result = await this.#request(
221
416
  target,
222
- (id) => ({ type: "chat", id, chatId, sessionId: target, text }),
417
+ (id) => ({
418
+ type: "chat",
419
+ id,
420
+ ...source(chatId, requestId),
421
+ sessionId: target,
422
+ text,
423
+ }),
223
424
  signal,
224
425
  );
225
426
  if ("message" in result) {
226
- await this.#ackInputs(target, result.inputs);
227
- return { sessionId: target, cwd: result.cwd, inputs: result.inputs };
427
+ const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
428
+ await this.#ackInputs(target, inputs, signal);
429
+ return {
430
+ sessionId: target,
431
+ cwd: result.cwd,
432
+ inputs,
433
+ ...(initialization ? { initialization } : {}),
434
+ };
228
435
  }
229
436
  throw new Error("Pi session returned no assistant message");
230
437
  }
@@ -233,19 +440,21 @@ export class Broker {
233
440
  chatId: string,
234
441
  sessionId: string | undefined,
235
442
  names: string[] | undefined,
443
+ requestId: unknown,
236
444
  signal: AbortSignal,
237
445
  ): Promise<InspectedSession> {
238
- const { sessionId: target } = await this.#selectSession(
446
+ const { sessionId: target, initialization } = await this.#selectSession(
239
447
  chatId,
240
448
  sessionId,
449
+ requestId,
241
450
  signal,
242
- false,
243
451
  );
244
452
  const { inspection, inputs } = await this.#inspect(target, signal);
245
- await this.#ackInputs(target, inputs);
453
+ await this.#ackInputs(target, inputs, signal);
246
454
  const selected = names ? new Set(names) : undefined;
247
455
  return {
248
456
  ...inspection,
457
+ ...(initialization ? { initialization } : {}),
249
458
  tools: selected
250
459
  ? inspection.tools.filter(({ name }) => selected.has(name))
251
460
  : inspection.tools,
@@ -257,13 +466,14 @@ export class Broker {
257
466
  chatId: string,
258
467
  sessionId: string | undefined,
259
468
  calls: ToolInput[],
469
+ requestId: unknown,
260
470
  signal: AbortSignal,
261
471
  ): Promise<CallResult> {
262
- const { sessionId: target } = await this.#selectSession(
472
+ const { sessionId: target, initialization } = await this.#selectSession(
263
473
  chatId,
264
474
  sessionId,
475
+ requestId,
265
476
  signal,
266
- false,
267
477
  );
268
478
  const toolCalls: ToolCall[] = calls.map((call) => ({
269
479
  type: "toolCall",
@@ -276,16 +486,17 @@ export class Broker {
276
486
  (id) => ({
277
487
  type: "call",
278
488
  id,
279
- chatId,
489
+ ...source(chatId, requestId),
280
490
  sessionId: target,
281
491
  calls: toolCalls,
282
492
  }),
283
493
  signal,
284
494
  );
285
495
  if ("toolResults" in result) {
286
- await this.#ackInputs(target, result.inputs);
496
+ await this.#ackInputs(target, result.inputs, signal);
287
497
  return {
288
498
  sessionId: target,
499
+ ...(initialization ? { initialization } : {}),
289
500
  cwd: result.cwd,
290
501
  toolResults: result.toolResults,
291
502
  inputs: result.inputs,
@@ -294,15 +505,42 @@ export class Broker {
294
505
  throw new Error("Pi session returned no tool results");
295
506
  }
296
507
 
508
+ async history(
509
+ chatId: string,
510
+ sessionId: string | undefined,
511
+ range: HistoryRange,
512
+ requestId: unknown,
513
+ signal: AbortSignal,
514
+ ) {
515
+ const target = sessionId ?? this.#state.binding(chatId);
516
+ if (!target) throw new Error("Specify a Pi sessionId to read history");
517
+ await this.#waitForSession(target, signal);
518
+ const result = await this.#request(
519
+ target,
520
+ (id) => ({
521
+ type: "history",
522
+ id,
523
+ sessionId: target,
524
+ range,
525
+ ...source(chatId, requestId),
526
+ }),
527
+ signal,
528
+ );
529
+ if ("history" in result) return { sessionId: target, ...result };
530
+ throw new Error("Pi session returned no history");
531
+ }
532
+
297
533
  async inputs(
298
534
  chatId: string,
299
535
  sessionId: string | undefined,
300
536
  signal: AbortSignal,
301
537
  ): Promise<SessionInput[]> {
302
538
  const target = sessionId ?? this.#state.binding(chatId);
303
- if (!target || !this.#sessions.has(target)) return [];
539
+ if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
540
+ return [];
304
541
  const { inputs } = await this.#inspect(target, signal);
305
- await this.#ackInputs(target, inputs);
542
+ if (!this.deliveryAllowed(signal)) return [];
543
+ await this.#ackInputs(target, inputs, signal);
306
544
  return inputs;
307
545
  }
308
546
 
@@ -310,14 +548,16 @@ export class Broker {
310
548
  chatId: string,
311
549
  sessionId: string | undefined,
312
550
  input: QuestionInput,
551
+ requestId: unknown,
313
552
  signal: AbortSignal,
314
- ): Promise<Question> {
315
- const { sessionId: target } = await this.#selectSession(
553
+ ): Promise<Question & { initialization?: Initialization }> {
554
+ const { sessionId: target, initialization } = await this.#selectSession(
316
555
  chatId,
317
556
  sessionId,
557
+ requestId,
318
558
  signal,
319
- false,
320
559
  );
560
+ this.#requireUnlocked(chatId, target, signal);
321
561
  const session = this.#sessions.get(target);
322
562
  if (!session) throw new Error(`Pi session ${target} is offline`);
323
563
  const question: QuestionRecord = {
@@ -329,11 +569,19 @@ export class Broker {
329
569
  delivered: false,
330
570
  };
331
571
  await this.#state.addQuestion(question);
572
+ const activity = source(chatId, requestId);
332
573
  void this.#notify(
333
574
  target,
334
- `ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
575
+ `${chatLabel(activity)} asked: ${question.question}`,
576
+ {
577
+ event: "asked",
578
+ ...activity,
579
+ },
335
580
  ).catch(() => {});
336
- return questionView(question);
581
+ return {
582
+ ...questionView(question),
583
+ ...(initialization ? { initialization } : {}),
584
+ };
337
585
  }
338
586
 
339
587
  async assertQuestion(
@@ -344,6 +592,7 @@ export class Broker {
344
592
  for (;;) {
345
593
  signal.throwIfAborted();
346
594
  const question = this.#state.question(chatId, id);
595
+ this.#requireUnlocked(chatId, question.sessionId, signal);
347
596
  if (question.loaded) return questionView(question);
348
597
  await this.#waitForChange(signal);
349
598
  }
@@ -378,14 +627,20 @@ export class Broker {
378
627
  : previous
379
628
  ? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
380
629
  : `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
381
- void this.#notify(question.sessionId, message).catch(() => {});
630
+ void this.#notify(question.sessionId, message, {
631
+ event: question.answer?.skipped ? "skipped" : "answered",
632
+ chatId,
633
+ }).catch(() => {});
382
634
  }
383
635
  }
384
636
  return questionView(question);
385
637
  }
386
638
 
387
639
  answers(chatId: string): QuestionRecord[] {
388
- return this.#state.answers(chatId);
640
+ if (this.synchronizing(chatId)) return [];
641
+ return this.#state
642
+ .answers(chatId)
643
+ .filter((question) => !this.#locks.has(question.sessionId));
389
644
  }
390
645
 
391
646
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
@@ -401,7 +656,10 @@ export class Broker {
401
656
  }
402
657
 
403
658
  deliveries(chatId: string): DeliveryRecord[] {
404
- return this.#state.deliveries(chatId);
659
+ if (this.synchronizing(chatId)) return [];
660
+ return this.#state
661
+ .deliveries(chatId)
662
+ .filter((delivery) => !this.#locks.has(delivery.sessionId));
405
663
  }
406
664
 
407
665
  acknowledge(
@@ -430,7 +688,9 @@ export class Broker {
430
688
  sessionId: message.session.id,
431
689
  });
432
690
  if (!registered)
433
- await this.#notify(message.session.id, "Chappie connected");
691
+ await this.#notify(message.session.id, "Chappie connected", {
692
+ event: "connected",
693
+ });
434
694
  this.#notifyChange();
435
695
  break;
436
696
  }
@@ -460,56 +720,146 @@ export class Broker {
460
720
  async #selectSession(
461
721
  chatId: string,
462
722
  requestedId: string | undefined,
723
+ requestId: unknown,
463
724
  signal: AbortSignal,
464
- bindRequested: boolean,
725
+ bindRequested = false,
726
+ communication = false,
465
727
  ): Promise<{
466
728
  sessionId: string;
467
729
  selection: InitializedSession["selection"];
730
+ initialization?: Initialization;
468
731
  }> {
469
- if (requestedId) {
470
- await this.#waitForSession(requestedId, signal);
471
- if (bindRequested && this.#state.binding(chatId) !== requestedId) {
472
- await this.#bind(chatId, requestedId);
732
+ for (;;) {
733
+ signal.throwIfAborted();
734
+ const boundId = this.#state.binding(chatId);
735
+ let target = requestedId ?? boundId;
736
+ if (!target) {
737
+ const occupied = this.#state.bindingCounts();
738
+ target = [...this.#sessions.keys()].find(
739
+ (id) => !occupied.has(id) && !this.#locks.has(id),
740
+ );
473
741
  }
474
- return { sessionId: requestedId, selection: "explicit" };
742
+ if (!communication) this.#requireUnlocked(chatId, target, signal);
743
+ if (!target) {
744
+ await this.#waitForChange(signal);
745
+ continue;
746
+ }
747
+ await this.#waitForSession(target, signal);
748
+ 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
+ const initialization =
756
+ initialize && !this.synchronizing(chatId, target)
757
+ ? await this.#join(chatId, target, requestId, signal, bindRequested)
758
+ : undefined;
759
+ return {
760
+ sessionId: target,
761
+ selection: requestedId
762
+ ? "explicit"
763
+ : boundId
764
+ ? "existing"
765
+ : "automatic",
766
+ ...(initialization ? { initialization } : {}),
767
+ };
475
768
  }
769
+ }
476
770
 
477
- const boundId = this.#state.binding(chatId);
478
- if (boundId) {
479
- await this.#waitForSession(boundId, signal);
480
- return { sessionId: boundId, selection: "existing" };
771
+ async #join(
772
+ chatId: string,
773
+ sessionId: string,
774
+ requestId: unknown,
775
+ signal: AbortSignal,
776
+ explicit = false,
777
+ ): Promise<Initialization> {
778
+ this.#requireUnlocked(chatId, sessionId, signal);
779
+ const operation = this.#operations.get(signal);
780
+ if (operation) this.#observe(operation, sessionId);
781
+ const previous = this.#state.binding(chatId);
782
+ const code = this.#sync ? randomBytes(6).toString("hex") : undefined;
783
+ 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
+ }
797
+ const activity = source(chatId, requestId);
798
+ if (previous !== sessionId) {
799
+ this.#notifyChange();
800
+ if (previous)
801
+ await this.#notify(previous, `${chatLabel(activity)} left`, {
802
+ ...activity,
803
+ event: "left",
804
+ });
481
805
  }
806
+ this.#requireUnlocked(chatId, sessionId, signal);
807
+ await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
808
+ ...activity,
809
+ event: "joined",
810
+ initialization: explicit ? "explicit" : "implicit",
811
+ });
812
+ return {
813
+ 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,
818
+ };
819
+ }
482
820
 
483
- for (;;) {
484
- const occupied = this.#state.bindingCounts();
485
- const candidate = [...this.#sessions.keys()].find(
486
- (sessionId) => !occupied.has(sessionId),
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,
487
840
  );
488
- if (candidate) {
489
- await this.#bind(chatId, candidate);
490
- return { sessionId: candidate, selection: "automatic" };
491
- }
492
- await this.#waitForChange(signal);
493
841
  }
842
+ if (this.#locks.has(sessionId)) operation.coordinating = true;
494
843
  }
495
844
 
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`);
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
+ );
507
853
  }
508
854
 
509
- async #notify(sessionId: string, message: string): Promise<void> {
855
+ async #notify(
856
+ sessionId: string,
857
+ message: string,
858
+ activity: Activity = {},
859
+ ): Promise<void> {
510
860
  await this.#sessions
511
861
  .get(sessionId)
512
- ?.peer.send({ type: "notice", sessionId, message });
862
+ ?.peer.send({ type: "notice", sessionId, message, activity });
513
863
  }
514
864
 
515
865
  async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
@@ -529,7 +879,12 @@ export class Broker {
529
879
  throw new Error("Pi session returned no inspection");
530
880
  }
531
881
 
532
- async #ackInputs(sessionId: string, inputs: SessionInput[]): Promise<void> {
882
+ async #ackInputs(
883
+ sessionId: string,
884
+ inputs: SessionInput[],
885
+ signal: AbortSignal,
886
+ ): Promise<void> {
887
+ signal.throwIfAborted();
533
888
  if (inputs.length === 0) return;
534
889
  const session = this.#sessions.get(sessionId);
535
890
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
@@ -545,6 +900,12 @@ export class Broker {
545
900
  message: (id: number) => BrokerMessage,
546
901
  signal: AbortSignal,
547
902
  ): 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
+ }
548
909
  const session = this.#sessions.get(sessionId);
549
910
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
550
911
  if (signal.aborted) throw abortError(signal);
@@ -573,12 +934,10 @@ export class Broker {
573
934
  };
574
935
  this.#pending.set(id, pending);
575
936
  signal.addEventListener("abort", onAbort, { once: true });
576
- try {
577
- await session.peer.send(message(id));
578
- } catch (error) {
937
+ void session.peer.send(message(id)).catch((error: unknown) => {
579
938
  this.#finishRequest(id, pending);
580
- throw error;
581
- }
939
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
940
+ });
582
941
  return completion.promise;
583
942
  }
584
943
 
@@ -630,6 +989,10 @@ export class Broker {
630
989
  }
631
990
  }
632
991
 
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
+
633
996
  function abortError(signal: AbortSignal): Error {
634
997
  return signal.reason instanceof Error
635
998
  ? signal.reason