@zetaloop/chappie 0.3.2 → 0.4.1

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 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.";
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,35 @@ interface ChangeWaiter {
48
65
  onAbort(): void;
49
66
  }
50
67
 
68
+ export interface Initialization {
69
+ name?: string;
70
+ code?: string;
71
+ sessionId: string;
72
+ instructions: string;
73
+ }
74
+
51
75
  export interface InitializedSession extends Omit<SessionInspection, "tools"> {
52
76
  selection: "existing" | "explicit" | "automatic";
77
+ initialization?: Initialization;
53
78
  globalAgents?: string;
54
79
  inputs: SessionInput[];
55
80
  tools: { name: string; description: string }[];
56
81
  }
57
82
 
58
83
  export interface InspectedSession extends SessionInspection {
84
+ initialization?: Initialization;
59
85
  inputs: SessionInput[];
60
86
  }
61
87
 
62
88
  export interface ChatResult {
89
+ initialization?: Initialization;
63
90
  sessionId: string;
64
91
  cwd: string;
65
92
  inputs: SessionInput[];
66
93
  }
67
94
 
68
- export interface CallResult {
69
- sessionId: string;
70
- cwd: string;
95
+ export interface CallResult extends ChatResult {
71
96
  toolResults: ToolResultMessage[];
72
- inputs: SessionInput[];
73
97
  }
74
98
 
75
99
  export class Broker {
@@ -79,9 +103,12 @@ export class Broker {
79
103
  readonly #sessions = new Map<string, RegisteredSession>();
80
104
  readonly #pending = new Map<number, PendingRequest>();
81
105
  readonly #waiters = new Set<ChangeWaiter>();
82
- readonly #workflows = new Map<string, Workflow>();
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>();
83
110
  #ask = true;
84
- #latestWorkflow = false;
111
+ #sync = false;
85
112
  #nextRequestId = 1;
86
113
 
87
114
  constructor(agentDir: string) {
@@ -97,17 +124,20 @@ export class Broker {
97
124
  async start(): Promise<void> {
98
125
  const config = await readConfig(this.#agentDir);
99
126
  this.#ask = config.ask ?? true;
100
- this.#latestWorkflow = config.latestWorkflow ?? false;
127
+ this.#sync = config.sync ?? false;
101
128
  await this.#state.load();
102
129
  await this.#ipc.start(config.listen ?? false);
103
130
  }
104
131
 
105
132
  async close(): Promise<void> {
106
133
  const error = new Error("Chappie broker ended");
107
- for (const workflow of this.#workflows.values()) {
108
- workflow.controller.abort(error);
134
+ for (const operation of this.#operations.values()) {
135
+ operation.controller.abort(error);
109
136
  }
110
- this.#workflows.clear();
137
+ this.#operations.clear();
138
+ this.#locks.clear();
139
+ this.#startingLocks.clear();
140
+ this.#syncRevisions.clear();
111
141
  for (const [id, pending] of this.#pending) {
112
142
  this.#finishRequest(id, pending);
113
143
  pending.reject(error);
@@ -121,43 +151,213 @@ export class Broker {
121
151
  await this.#ipc.close();
122
152
  }
123
153
 
124
- async workflow(
125
- chatId: string | undefined,
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,
126
237
  requestId: unknown,
127
238
  signal: AbortSignal,
128
- ): Promise<AbortSignal> {
129
- if (!this.#latestWorkflow || !chatId) return signal;
130
- const id = workflowId(requestId);
131
- if (!id) return signal;
239
+ ) {
240
+ if (!this.#sync) throw new Error("Session synchronization is disabled");
132
241
  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);
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
+ );
146
310
  }
147
- const combined = AbortSignal.any([signal, workflow.controller.signal]);
148
- combined.throwIfAborted();
149
- return combined;
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
+ };
150
348
  }
151
349
 
152
- listSessions(
153
- sessionId?: string,
154
- ): (SessionDescription & { bindingCount: number })[] {
350
+ listSessions(sessionId?: string): (SessionDescription & {
351
+ bindingCount: number;
352
+ sync?: { locked: boolean; verified: boolean };
353
+ })[] {
155
354
  const counts = this.#state.bindingCounts();
156
355
  return [...this.#sessions.values()]
157
356
  .filter(({ description }) => !sessionId || description.id === sessionId)
158
357
  .map(({ description }) => ({
159
358
  ...description,
160
359
  bindingCount: counts.get(description.id) ?? 0,
360
+ ...(this.#sync ? { sync: this.syncState(description.id) } : {}),
161
361
  }));
162
362
  }
163
363
 
@@ -175,20 +375,19 @@ export class Broker {
175
375
  requestId: unknown,
176
376
  signal: AbortSignal,
177
377
  ): Promise<InitializedSession> {
178
- const { sessionId: target, selection } = await this.#selectSession(
179
- chatId,
180
- sessionId,
181
- requestId,
182
- signal,
183
- true,
184
- );
378
+ const {
379
+ sessionId: target,
380
+ selection,
381
+ initialization,
382
+ } = await this.#selectSession(chatId, sessionId, requestId, signal, true);
185
383
  const { inspection, inputs, globalAgents } = await this.#inspect(
186
384
  target,
187
385
  signal,
188
386
  );
189
- await this.#ackInputs(target, inputs);
387
+ await this.#ackInputs(target, inputs, signal);
190
388
  return {
191
389
  selection,
390
+ ...(initialization ? { initialization } : {}),
192
391
  ...inspection,
193
392
  tools: inspection.tools.map(({ name, description }) => ({
194
393
  name,
@@ -206,20 +405,34 @@ export class Broker {
206
405
  requestId: unknown,
207
406
  signal: AbortSignal,
208
407
  ): Promise<ChatResult> {
209
- const { sessionId: target } = await this.#selectSession(
408
+ const { sessionId: target, initialization } = await this.#selectSession(
210
409
  chatId,
211
410
  sessionId,
212
411
  requestId,
213
412
  signal,
413
+ false,
414
+ true,
214
415
  );
215
416
  const result = await this.#request(
216
417
  target,
217
- (id) => ({ type: "chat", id, chatId, sessionId: target, text }),
418
+ (id) => ({
419
+ type: "chat",
420
+ id,
421
+ ...source(chatId, requestId),
422
+ sessionId: target,
423
+ text,
424
+ }),
218
425
  signal,
219
426
  );
220
427
  if ("message" in result) {
221
- await this.#ackInputs(target, result.inputs);
222
- return { sessionId: target, cwd: result.cwd, inputs: result.inputs };
428
+ const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
429
+ await this.#ackInputs(target, inputs, signal);
430
+ return {
431
+ sessionId: target,
432
+ cwd: result.cwd,
433
+ inputs,
434
+ ...(initialization ? { initialization } : {}),
435
+ };
223
436
  }
224
437
  throw new Error("Pi session returned no assistant message");
225
438
  }
@@ -231,17 +444,18 @@ export class Broker {
231
444
  requestId: unknown,
232
445
  signal: AbortSignal,
233
446
  ): Promise<InspectedSession> {
234
- const { sessionId: target } = await this.#selectSession(
447
+ const { sessionId: target, initialization } = await this.#selectSession(
235
448
  chatId,
236
449
  sessionId,
237
450
  requestId,
238
451
  signal,
239
452
  );
240
453
  const { inspection, inputs } = await this.#inspect(target, signal);
241
- await this.#ackInputs(target, inputs);
454
+ await this.#ackInputs(target, inputs, signal);
242
455
  const selected = names ? new Set(names) : undefined;
243
456
  return {
244
457
  ...inspection,
458
+ ...(initialization ? { initialization } : {}),
245
459
  tools: selected
246
460
  ? inspection.tools.filter(({ name }) => selected.has(name))
247
461
  : inspection.tools,
@@ -256,7 +470,7 @@ export class Broker {
256
470
  requestId: unknown,
257
471
  signal: AbortSignal,
258
472
  ): Promise<CallResult> {
259
- const { sessionId: target } = await this.#selectSession(
473
+ const { sessionId: target, initialization } = await this.#selectSession(
260
474
  chatId,
261
475
  sessionId,
262
476
  requestId,
@@ -273,16 +487,17 @@ export class Broker {
273
487
  (id) => ({
274
488
  type: "call",
275
489
  id,
276
- chatId,
490
+ ...source(chatId, requestId),
277
491
  sessionId: target,
278
492
  calls: toolCalls,
279
493
  }),
280
494
  signal,
281
495
  );
282
496
  if ("toolResults" in result) {
283
- await this.#ackInputs(target, result.inputs);
497
+ await this.#ackInputs(target, result.inputs, signal);
284
498
  return {
285
499
  sessionId: target,
500
+ ...(initialization ? { initialization } : {}),
286
501
  cwd: result.cwd,
287
502
  toolResults: result.toolResults,
288
503
  inputs: result.inputs,
@@ -291,19 +506,42 @@ export class Broker {
291
506
  throw new Error("Pi session returned no tool results");
292
507
  }
293
508
 
294
- async inputs(
509
+ async history(
295
510
  chatId: string,
296
511
  sessionId: string | undefined,
512
+ range: HistoryRange,
297
513
  requestId: unknown,
298
514
  signal: AbortSignal,
515
+ ) {
516
+ const target = sessionId ?? this.#state.binding(chatId);
517
+ if (!target) throw new Error("Specify a Pi sessionId to read history");
518
+ await this.#waitForSession(target, signal);
519
+ const result = await this.#request(
520
+ target,
521
+ (id) => ({
522
+ type: "history",
523
+ id,
524
+ sessionId: target,
525
+ range,
526
+ ...source(chatId, requestId),
527
+ }),
528
+ signal,
529
+ );
530
+ if ("history" in result) return { sessionId: target, ...result };
531
+ throw new Error("Pi session returned no history");
532
+ }
533
+
534
+ async inputs(
535
+ chatId: string,
536
+ sessionId: string | undefined,
537
+ signal: AbortSignal,
299
538
  ): 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);
539
+ const target = sessionId ?? this.#state.binding(chatId);
540
+ if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
541
+ return [];
305
542
  const { inputs } = await this.#inspect(target, signal);
306
- await this.#ackInputs(target, inputs);
543
+ if (!this.deliveryAllowed(signal)) return [];
544
+ await this.#ackInputs(target, inputs, signal);
307
545
  return inputs;
308
546
  }
309
547
 
@@ -313,13 +551,14 @@ export class Broker {
313
551
  input: QuestionInput,
314
552
  requestId: unknown,
315
553
  signal: AbortSignal,
316
- ): Promise<Question> {
317
- const { sessionId: target } = await this.#selectSession(
554
+ ): Promise<Question & { initialization?: Initialization }> {
555
+ const { sessionId: target, initialization } = await this.#selectSession(
318
556
  chatId,
319
557
  sessionId,
320
558
  requestId,
321
559
  signal,
322
560
  );
561
+ this.#requireUnlocked(chatId, target, signal);
323
562
  const session = this.#sessions.get(target);
324
563
  if (!session) throw new Error(`Pi session ${target} is offline`);
325
564
  const question: QuestionRecord = {
@@ -331,11 +570,19 @@ export class Broker {
331
570
  delivered: false,
332
571
  };
333
572
  await this.#state.addQuestion(question);
573
+ const activity = source(chatId, requestId);
334
574
  void this.#notify(
335
575
  target,
336
- `ChatGPT ${chatId.slice(-4)} asked: ${question.question}`,
576
+ `${chatLabel(activity)} asked: ${question.question}`,
577
+ {
578
+ event: "asked",
579
+ ...activity,
580
+ },
337
581
  ).catch(() => {});
338
- return questionView(question);
582
+ return {
583
+ ...questionView(question),
584
+ ...(initialization ? { initialization } : {}),
585
+ };
339
586
  }
340
587
 
341
588
  async assertQuestion(
@@ -346,6 +593,7 @@ export class Broker {
346
593
  for (;;) {
347
594
  signal.throwIfAborted();
348
595
  const question = this.#state.question(chatId, id);
596
+ this.#requireUnlocked(chatId, question.sessionId, signal);
349
597
  if (question.loaded) return questionView(question);
350
598
  await this.#waitForChange(signal);
351
599
  }
@@ -380,14 +628,20 @@ export class Broker {
380
628
  : previous
381
629
  ? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
382
630
  : `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
383
- void this.#notify(question.sessionId, message).catch(() => {});
631
+ void this.#notify(question.sessionId, message, {
632
+ event: question.answer?.skipped ? "skipped" : "answered",
633
+ chatId,
634
+ }).catch(() => {});
384
635
  }
385
636
  }
386
637
  return questionView(question);
387
638
  }
388
639
 
389
640
  answers(chatId: string): QuestionRecord[] {
390
- return this.#state.answers(chatId);
641
+ if (this.synchronizing(chatId)) return [];
642
+ return this.#state
643
+ .answers(chatId)
644
+ .filter((question) => !this.#locks.has(question.sessionId));
391
645
  }
392
646
 
393
647
  async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
@@ -403,7 +657,10 @@ export class Broker {
403
657
  }
404
658
 
405
659
  deliveries(chatId: string): DeliveryRecord[] {
406
- return this.#state.deliveries(chatId);
660
+ if (this.synchronizing(chatId)) return [];
661
+ return this.#state
662
+ .deliveries(chatId)
663
+ .filter((delivery) => !this.#locks.has(delivery.sessionId));
407
664
  }
408
665
 
409
666
  acknowledge(
@@ -432,7 +689,9 @@ export class Broker {
432
689
  sessionId: message.session.id,
433
690
  });
434
691
  if (!registered)
435
- await this.#notify(message.session.id, "Chappie connected");
692
+ await this.#notify(message.session.id, "Chappie connected", {
693
+ event: "connected",
694
+ });
436
695
  this.#notifyChange();
437
696
  break;
438
697
  }
@@ -465,40 +724,48 @@ export class Broker {
465
724
  requestId: unknown,
466
725
  signal: AbortSignal,
467
726
  bindRequested = false,
727
+ communication = false,
468
728
  ): Promise<{
469
729
  sessionId: string;
470
730
  selection: InitializedSession["selection"];
731
+ initialization?: Initialization;
471
732
  }> {
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
733
  for (;;) {
483
734
  signal.throwIfAborted();
484
735
  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" };
736
+ let target = requestedId ?? boundId;
737
+ if (!target) {
738
+ const occupied = this.#state.bindingCounts();
739
+ target = [...this.#sessions.keys()].find(
740
+ (id) => !occupied.has(id) && !this.#locks.has(id),
741
+ );
492
742
  }
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" };
743
+ if (!communication) this.#requireUnlocked(chatId, target, signal);
744
+ if (!target) {
745
+ await this.#waitForChange(signal);
746
+ continue;
500
747
  }
501
- await this.#waitForChange(signal);
748
+ await this.#waitForSession(target, signal);
749
+ 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
+ const initialization =
757
+ initialize && !this.synchronizing(chatId, target)
758
+ ? await this.#join(chatId, target, requestId, signal, bindRequested)
759
+ : undefined;
760
+ return {
761
+ sessionId: target,
762
+ selection: requestedId
763
+ ? "explicit"
764
+ : boundId
765
+ ? "existing"
766
+ : "automatic",
767
+ ...(initialization ? { initialization } : {}),
768
+ };
502
769
  }
503
770
  }
504
771
 
@@ -506,28 +773,99 @@ export class Broker {
506
773
  chatId: string,
507
774
  sessionId: string,
508
775
  requestId: unknown,
776
+ signal: AbortSignal,
509
777
  explicit = false,
510
- ): Promise<void> {
778
+ ): Promise<Initialization> {
779
+ this.#requireUnlocked(chatId, sessionId, signal);
780
+ const operation = this.#operations.get(signal);
781
+ if (operation) this.#observe(operation, sessionId);
511
782
  const previous = this.#state.binding(chatId);
512
- const workflow = workflowId(requestId);
513
- const joined = await this.#state.join(chatId, sessionId, workflow);
783
+ const code = this.#sync ? randomBytes(6).toString("hex") : undefined;
784
+ 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
+ }
798
+ const activity = source(chatId, requestId);
514
799
  if (previous !== sessionId) {
515
800
  this.#notifyChange();
516
801
  if (previous)
517
- await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
802
+ await this.#notify(previous, `${chatLabel(activity)} left`, {
803
+ ...activity,
804
+ event: "left",
805
+ });
518
806
  }
519
- if (joined || explicit) {
520
- await this.#notify(
807
+ this.#requireUnlocked(chatId, sessionId, signal);
808
+ await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
809
+ ...activity,
810
+ event: "joined",
811
+ initialization: explicit ? "explicit" : "implicit",
812
+ });
813
+ const name = activity.requestId?.match(/\/([^/]+)$/)?.[1];
814
+ const instructions = name
815
+ ? `${historyInstructions} Use this name when coordinating through chat.`
816
+ : historyInstructions;
817
+ return {
818
+ sessionId,
819
+ ...(name ? { name } : {}),
820
+ ...(code ? { code } : {}),
821
+ instructions: code
822
+ ? `${instructions} Keep this code private for sync verification.`
823
+ : instructions,
824
+ };
825
+ }
826
+
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(
521
844
  sessionId,
522
- `ChatGPT ${chatId.slice(-4)}${workflow ? ` (${workflow.slice(-4)})` : ""} joined`,
845
+ this.#syncRevisions.get(sessionId) ?? 0,
523
846
  );
524
847
  }
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
+ );
525
859
  }
526
860
 
527
- async #notify(sessionId: string, message: string): Promise<void> {
861
+ async #notify(
862
+ sessionId: string,
863
+ message: string,
864
+ activity: Activity = {},
865
+ ): Promise<void> {
528
866
  await this.#sessions
529
867
  .get(sessionId)
530
- ?.peer.send({ type: "notice", sessionId, message });
868
+ ?.peer.send({ type: "notice", sessionId, message, activity });
531
869
  }
532
870
 
533
871
  async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
@@ -547,7 +885,12 @@ export class Broker {
547
885
  throw new Error("Pi session returned no inspection");
548
886
  }
549
887
 
550
- async #ackInputs(sessionId: string, inputs: SessionInput[]): Promise<void> {
888
+ async #ackInputs(
889
+ sessionId: string,
890
+ inputs: SessionInput[],
891
+ signal: AbortSignal,
892
+ ): Promise<void> {
893
+ signal.throwIfAborted();
551
894
  if (inputs.length === 0) return;
552
895
  const session = this.#sessions.get(sessionId);
553
896
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
@@ -563,6 +906,12 @@ export class Broker {
563
906
  message: (id: number) => BrokerMessage,
564
907
  signal: AbortSignal,
565
908
  ): 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
+ }
566
915
  const session = this.#sessions.get(sessionId);
567
916
  if (!session) throw new Error(`Pi session ${sessionId} is offline`);
568
917
  if (signal.aborted) throw abortError(signal);
@@ -591,12 +940,10 @@ export class Broker {
591
940
  };
592
941
  this.#pending.set(id, pending);
593
942
  signal.addEventListener("abort", onAbort, { once: true });
594
- try {
595
- await session.peer.send(message(id));
596
- } catch (error) {
943
+ void session.peer.send(message(id)).catch((error: unknown) => {
597
944
  this.#finishRequest(id, pending);
598
- throw error;
599
- }
945
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
946
+ });
600
947
  return completion.promise;
601
948
  }
602
949
 
@@ -648,12 +995,8 @@ export class Broker {
648
995
  }
649
996
  }
650
997
 
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;
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.`;
657
1000
  }
658
1001
 
659
1002
  function abortError(signal: AbortSignal): Error {