@zetaloop/chappie 0.3.2 → 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,43 +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 = workflowId(requestId);
131
- if (!id) return signal;
238
+ ) {
239
+ if (!this.#sync) throw new Error("Session synchronization is disabled");
132
240
  signal.throwIfAborted();
133
- const latest = this.#state.workflow(chatId);
134
- const superseded = new Error(
135
- "A newer workflow is handling this chat. Stop this workflow.",
136
- );
137
- // UUIDv7 puts the creation timestamp first, including across broker restarts.
138
- if (latest && id < latest) throw superseded;
139
- let workflow = this.#workflows.get(chatId);
140
- if (!workflow || workflow.id !== id) {
141
- const previous = workflow;
142
- workflow = { id, controller: new AbortController() };
143
- this.#workflows.set(chatId, workflow);
144
- previous?.controller.abort(superseded);
145
- 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
+ };
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
+ );
146
309
  }
147
- const combined = AbortSignal.any([signal, workflow.controller.signal]);
148
- combined.throwIfAborted();
149
- return combined;
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
+ };
150
347
  }
151
348
 
152
- listSessions(
153
- sessionId?: string,
154
- ): (SessionDescription & { bindingCount: number })[] {
349
+ listSessions(sessionId?: string): (SessionDescription & {
350
+ bindingCount: number;
351
+ sync?: { locked: boolean; verified: boolean };
352
+ })[] {
155
353
  const counts = this.#state.bindingCounts();
156
354
  return [...this.#sessions.values()]
157
355
  .filter(({ description }) => !sessionId || description.id === sessionId)
158
356
  .map(({ description }) => ({
159
357
  ...description,
160
358
  bindingCount: counts.get(description.id) ?? 0,
359
+ ...(this.#sync ? { sync: this.syncState(description.id) } : {}),
161
360
  }));
162
361
  }
163
362
 
@@ -175,20 +374,19 @@ export class Broker {
175
374
  requestId: unknown,
176
375
  signal: AbortSignal,
177
376
  ): Promise<InitializedSession> {
178
- const { sessionId: target, selection } = await this.#selectSession(
179
- chatId,
180
- sessionId,
181
- requestId,
182
- signal,
183
- true,
184
- );
377
+ const {
378
+ sessionId: target,
379
+ selection,
380
+ initialization,
381
+ } = await this.#selectSession(chatId, sessionId, requestId, signal, true);
185
382
  const { inspection, inputs, globalAgents } = await this.#inspect(
186
383
  target,
187
384
  signal,
188
385
  );
189
- await this.#ackInputs(target, inputs);
386
+ await this.#ackInputs(target, inputs, signal);
190
387
  return {
191
388
  selection,
389
+ ...(initialization ? { initialization } : {}),
192
390
  ...inspection,
193
391
  tools: inspection.tools.map(({ name, description }) => ({
194
392
  name,
@@ -206,20 +404,34 @@ export class Broker {
206
404
  requestId: unknown,
207
405
  signal: AbortSignal,
208
406
  ): Promise<ChatResult> {
209
- const { sessionId: target } = await this.#selectSession(
407
+ const { sessionId: target, initialization } = await this.#selectSession(
210
408
  chatId,
211
409
  sessionId,
212
410
  requestId,
213
411
  signal,
412
+ false,
413
+ true,
214
414
  );
215
415
  const result = await this.#request(
216
416
  target,
217
- (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
+ }),
218
424
  signal,
219
425
  );
220
426
  if ("message" in result) {
221
- await this.#ackInputs(target, result.inputs);
222
- 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
+ };
223
435
  }
224
436
  throw new Error("Pi session returned no assistant message");
225
437
  }
@@ -231,17 +443,18 @@ export class Broker {
231
443
  requestId: unknown,
232
444
  signal: AbortSignal,
233
445
  ): Promise<InspectedSession> {
234
- const { sessionId: target } = await this.#selectSession(
446
+ const { sessionId: target, initialization } = await this.#selectSession(
235
447
  chatId,
236
448
  sessionId,
237
449
  requestId,
238
450
  signal,
239
451
  );
240
452
  const { inspection, inputs } = await this.#inspect(target, signal);
241
- await this.#ackInputs(target, inputs);
453
+ await this.#ackInputs(target, inputs, signal);
242
454
  const selected = names ? new Set(names) : undefined;
243
455
  return {
244
456
  ...inspection,
457
+ ...(initialization ? { initialization } : {}),
245
458
  tools: selected
246
459
  ? inspection.tools.filter(({ name }) => selected.has(name))
247
460
  : inspection.tools,
@@ -256,7 +469,7 @@ export class Broker {
256
469
  requestId: unknown,
257
470
  signal: AbortSignal,
258
471
  ): Promise<CallResult> {
259
- const { sessionId: target } = await this.#selectSession(
472
+ const { sessionId: target, initialization } = await this.#selectSession(
260
473
  chatId,
261
474
  sessionId,
262
475
  requestId,
@@ -273,16 +486,17 @@ export class Broker {
273
486
  (id) => ({
274
487
  type: "call",
275
488
  id,
276
- chatId,
489
+ ...source(chatId, requestId),
277
490
  sessionId: target,
278
491
  calls: toolCalls,
279
492
  }),
280
493
  signal,
281
494
  );
282
495
  if ("toolResults" in result) {
283
- await this.#ackInputs(target, result.inputs);
496
+ await this.#ackInputs(target, result.inputs, signal);
284
497
  return {
285
498
  sessionId: target,
499
+ ...(initialization ? { initialization } : {}),
286
500
  cwd: result.cwd,
287
501
  toolResults: result.toolResults,
288
502
  inputs: result.inputs,
@@ -291,19 +505,42 @@ export class Broker {
291
505
  throw new Error("Pi session returned no tool results");
292
506
  }
293
507
 
294
- async inputs(
508
+ async history(
295
509
  chatId: string,
296
510
  sessionId: string | undefined,
511
+ range: HistoryRange,
297
512
  requestId: unknown,
298
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
+
533
+ async inputs(
534
+ chatId: string,
535
+ sessionId: string | undefined,
536
+ signal: AbortSignal,
299
537
  ): Promise<SessionInput[]> {
300
- const boundId = this.#state.binding(chatId);
301
- const target = sessionId ?? boundId;
302
- if (!target || !this.#sessions.has(target)) return [];
303
- signal.throwIfAborted();
304
- if (target === boundId) await this.#join(chatId, target, requestId);
538
+ const target = sessionId ?? this.#state.binding(chatId);
539
+ if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
540
+ return [];
305
541
  const { inputs } = await this.#inspect(target, signal);
306
- await this.#ackInputs(target, inputs);
542
+ if (!this.deliveryAllowed(signal)) return [];
543
+ await this.#ackInputs(target, inputs, signal);
307
544
  return inputs;
308
545
  }
309
546
 
@@ -313,13 +550,14 @@ export class Broker {
313
550
  input: QuestionInput,
314
551
  requestId: unknown,
315
552
  signal: AbortSignal,
316
- ): Promise<Question> {
317
- const { sessionId: target } = await this.#selectSession(
553
+ ): Promise<Question & { initialization?: Initialization }> {
554
+ const { sessionId: target, initialization } = await this.#selectSession(
318
555
  chatId,
319
556
  sessionId,
320
557
  requestId,
321
558
  signal,
322
559
  );
560
+ this.#requireUnlocked(chatId, target, signal);
323
561
  const session = this.#sessions.get(target);
324
562
  if (!session) throw new Error(`Pi session ${target} is offline`);
325
563
  const question: QuestionRecord = {
@@ -331,11 +569,19 @@ export class Broker {
331
569
  delivered: false,
332
570
  };
333
571
  await this.#state.addQuestion(question);
572
+ const activity = source(chatId, requestId);
334
573
  void this.#notify(
335
574
  target,
336
- `ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
575
+ `${chatLabel(activity)} asked: ${question.question}`,
576
+ {
577
+ event: "asked",
578
+ ...activity,
579
+ },
337
580
  ).catch(() => {});
338
- return questionView(question);
581
+ return {
582
+ ...questionView(question),
583
+ ...(initialization ? { initialization } : {}),
584
+ };
339
585
  }
340
586
 
341
587
  async assertQuestion(
@@ -346,6 +592,7 @@ export class Broker {
346
592
  for (;;) {
347
593
  signal.throwIfAborted();
348
594
  const question = this.#state.question(chatId, id);
595
+ this.#requireUnlocked(chatId, question.sessionId, signal);
349
596
  if (question.loaded) return questionView(question);
350
597
  await this.#waitForChange(signal);
351
598
  }
@@ -380,14 +627,20 @@ export class Broker {
380
627
  : previous
381
628
  ? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
382
629
  : `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
383
- 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(() => {});
384
634
  }
385
635
  }
386
636
  return questionView(question);
387
637
  }
388
638
 
389
639
  answers(chatId: string): QuestionRecord[] {
390
- 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));
391
644
  }
392
645
 
393
646
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
@@ -403,7 +656,10 @@ export class Broker {
403
656
  }
404
657
 
405
658
  deliveries(chatId: string): DeliveryRecord[] {
406
- 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));
407
663
  }
408
664
 
409
665
  acknowledge(
@@ -432,7 +688,9 @@ export class Broker {
432
688
  sessionId: message.session.id,
433
689
  });
434
690
  if (!registered)
435
- await this.#notify(message.session.id, "Chappie connected");
691
+ await this.#notify(message.session.id, "Chappie connected", {
692
+ event: "connected",
693
+ });
436
694
  this.#notifyChange();
437
695
  break;
438
696
  }
@@ -465,40 +723,48 @@ export class Broker {
465
723
  requestId: unknown,
466
724
  signal: AbortSignal,
467
725
  bindRequested = false,
726
+ communication = false,
468
727
  ): Promise<{
469
728
  sessionId: string;
470
729
  selection: InitializedSession["selection"];
730
+ initialization?: Initialization;
471
731
  }> {
472
- if (requestedId) {
473
- await this.#waitForSession(requestedId, signal);
474
- signal.throwIfAborted();
475
- const boundId = this.#state.binding(chatId);
476
- if (bindRequested || !boundId || boundId === requestedId) {
477
- await this.#join(chatId, requestedId, requestId, bindRequested);
478
- }
479
- return { sessionId: requestedId, selection: "explicit" };
480
- }
481
-
482
732
  for (;;) {
483
733
  signal.throwIfAborted();
484
734
  const boundId = this.#state.binding(chatId);
485
- if (boundId) {
486
- await this.#waitForSession(boundId, signal);
487
- signal.throwIfAborted();
488
- if (bindRequested || this.#state.binding(chatId) === boundId) {
489
- await this.#join(chatId, boundId, requestId, bindRequested);
490
- }
491
- return { sessionId: boundId, selection: "existing" };
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
+ );
492
741
  }
493
- const occupied = this.#state.bindingCounts();
494
- const candidate = [...this.#sessions.keys()].find(
495
- (sessionId) => !occupied.has(sessionId),
496
- );
497
- if (candidate) {
498
- await this.#join(chatId, candidate, requestId, bindRequested);
499
- return { sessionId: candidate, selection: "automatic" };
742
+ if (!communication) this.#requireUnlocked(chatId, target, signal);
743
+ if (!target) {
744
+ await this.#waitForChange(signal);
745
+ continue;
500
746
  }
501
- await this.#waitForChange(signal);
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
+ };
502
768
  }
503
769
  }
504
770
 
@@ -506,28 +772,94 @@ export class Broker {
506
772
  chatId: string,
507
773
  sessionId: string,
508
774
  requestId: unknown,
775
+ signal: AbortSignal,
509
776
  explicit = false,
510
- ): Promise<void> {
777
+ ): Promise<Initialization> {
778
+ this.#requireUnlocked(chatId, sessionId, signal);
779
+ const operation = this.#operations.get(signal);
780
+ if (operation) this.#observe(operation, sessionId);
511
781
  const previous = this.#state.binding(chatId);
512
- const workflow = workflowId(requestId);
513
- const joined = await this.#state.join(chatId, sessionId, workflow);
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);
514
798
  if (previous !== sessionId) {
515
799
  this.#notifyChange();
516
800
  if (previous)
517
- await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
801
+ await this.#notify(previous, `${chatLabel(activity)} left`, {
802
+ ...activity,
803
+ event: "left",
804
+ });
518
805
  }
519
- if (joined || explicit) {
520
- await this.#notify(
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
+ }
820
+
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(
521
838
  sessionId,
522
- `ChatGPT ${chatId.slice(-4)}${workflow ? ` (${workflow.slice(-4)})` : ""} joined`,
839
+ this.#syncRevisions.get(sessionId) ?? 0,
523
840
  );
524
841
  }
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
+ );
525
853
  }
526
854
 
527
- async #notify(sessionId: string, message: string): Promise<void> {
855
+ async #notify(
856
+ sessionId: string,
857
+ message: string,
858
+ activity: Activity = {},
859
+ ): Promise<void> {
528
860
  await this.#sessions
529
861
  .get(sessionId)
530
- ?.peer.send({ type: "notice", sessionId, message });
862
+ ?.peer.send({ type: "notice", sessionId, message, activity });
531
863
  }
532
864
 
533
865
  async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
@@ -547,7 +879,12 @@ export class Broker {
547
879
  throw new Error("Pi session returned no inspection");
548
880
  }
549
881
 
550
- 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();
551
888
  if (inputs.length === 0) return;
552
889
  const session = this.#sessions.get(sessionId);
553
890
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
@@ -563,6 +900,12 @@ export class Broker {
563
900
  message: (id: number) => BrokerMessage,
564
901
  signal: AbortSignal,
565
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
+ }
566
909
  const session = this.#sessions.get(sessionId);
567
910
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
568
911
  if (signal.aborted) throw abortError(signal);
@@ -591,12 +934,10 @@ export class Broker {
591
934
  };
592
935
  this.#pending.set(id, pending);
593
936
  signal.addEventListener("abort", onAbort, { once: true });
594
- try {
595
- await session.peer.send(message(id));
596
- } catch (error) {
937
+ void session.peer.send(message(id)).catch((error: unknown) => {
597
938
  this.#finishRequest(id, pending);
598
- throw error;
599
- }
939
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
940
+ });
600
941
  return completion.promise;
601
942
  }
602
943
 
@@ -648,12 +989,8 @@ export class Broker {
648
989
  }
649
990
  }
650
991
 
651
- function workflowId(requestId: unknown): string | undefined {
652
- return typeof requestId === "string"
653
- ? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
654
- .exec(requestId)?.[0]
655
- .toLowerCase()
656
- : undefined;
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.`;
657
994
  }
658
995
 
659
996
  function abortError(signal: AbortSignal): Error {