@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/README.md +11 -3
- package/docs/tools.md +51 -3
- package/package.json +1 -1
- package/src/activity.ts +21 -0
- package/src/broker.ts +459 -116
- package/src/config.ts +1 -1
- package/src/delivery.ts +3 -2
- package/src/history.ts +123 -0
- package/src/instructions.md +5 -1
- package/src/ipc.ts +14 -1
- package/src/provider.ts +2 -1
- package/src/server.ts +140 -34
- package/src/session.ts +49 -7
- package/src/state.ts +13 -32
- package/src/tools.ts +10 -1
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
|
|
40
|
-
|
|
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 #
|
|
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
|
-
#
|
|
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.#
|
|
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
|
|
108
|
-
|
|
134
|
+
for (const operation of this.#operations.values()) {
|
|
135
|
+
operation.controller.abort(error);
|
|
109
136
|
}
|
|
110
|
-
this.#
|
|
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
|
|
125
|
-
chatId: string
|
|
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
|
-
)
|
|
129
|
-
if (!this.#
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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 {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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) => ({
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
+
`${chatLabel(activity)} asked: ${question.question}`,
|
|
577
|
+
{
|
|
578
|
+
event: "asked",
|
|
579
|
+
...activity,
|
|
580
|
+
},
|
|
337
581
|
).catch(() => {});
|
|
338
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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.#
|
|
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<
|
|
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
|
|
513
|
-
|
|
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,
|
|
802
|
+
await this.#notify(previous, `${chatLabel(activity)} left`, {
|
|
803
|
+
...activity,
|
|
804
|
+
event: "left",
|
|
805
|
+
});
|
|
518
806
|
}
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
652
|
-
return
|
|
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 {
|