@zetaloop/chappie 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/docs/tools.md +50 -4
- package/package.json +1 -1
- package/src/activity.ts +21 -0
- package/src/broker.ts +476 -113
- package/src/config.ts +1 -1
- package/src/delivery.ts +3 -2
- package/src/history.ts +123 -0
- package/src/instructions.md +3 -1
- package/src/ipc.ts +14 -1
- package/src/provider.ts +2 -1
- package/src/server.ts +154 -31
- package/src/session.ts +49 -8
- package/src/state.ts +12 -17
- package/src/tools.ts +12 -2
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
|
|
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,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 #
|
|
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
|
-
#
|
|
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.#
|
|
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
|
|
108
|
-
|
|
133
|
+
for (const operation of this.#operations.values()) {
|
|
134
|
+
operation.controller.abort(error);
|
|
109
135
|
}
|
|
110
|
-
this.#
|
|
136
|
+
this.#operations.clear();
|
|
137
|
+
this.#locks.clear();
|
|
138
|
+
this.#startingLocks.clear();
|
|
139
|
+
this.#syncRevisions.clear();
|
|
111
140
|
for (const [id, pending] of this.#pending) {
|
|
112
141
|
this.#finishRequest(id, pending);
|
|
113
142
|
pending.reject(error);
|
|
@@ -121,48 +150,213 @@ export class Broker {
|
|
|
121
150
|
await this.#ipc.close();
|
|
122
151
|
}
|
|
123
152
|
|
|
124
|
-
async
|
|
125
|
-
chatId: string
|
|
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
|
-
)
|
|
129
|
-
if (!this.#
|
|
130
|
-
const id =
|
|
131
|
-
typeof requestId === "string"
|
|
132
|
-
? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
|
|
133
|
-
.exec(requestId)?.[0]
|
|
134
|
-
.toLowerCase()
|
|
135
|
-
: undefined;
|
|
136
|
-
if (!id) return signal;
|
|
238
|
+
) {
|
|
239
|
+
if (!this.#sync) throw new Error("Session synchronization is disabled");
|
|
137
240
|
signal.throwIfAborted();
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
241
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
242
|
+
if (!target) throw new Error("Specify a Pi sessionId to synchronize");
|
|
243
|
+
const locked = this.synchronizing(chatId);
|
|
244
|
+
if (locked && locked !== target) throw new Error(syncMessage(locked));
|
|
245
|
+
const activity = source(chatId, requestId);
|
|
246
|
+
const result = { sessionId: target };
|
|
247
|
+
if (action === "start") {
|
|
248
|
+
if (!this.#locks.has(target)) {
|
|
249
|
+
this.#locks.set(target, false);
|
|
250
|
+
this.#startingLocks.add(target);
|
|
251
|
+
this.#syncRevisions.set(
|
|
252
|
+
target,
|
|
253
|
+
(this.#syncRevisions.get(target) ?? 0) + 1,
|
|
254
|
+
);
|
|
255
|
+
const error = new Error(syncMessage(target));
|
|
256
|
+
const rollbacks: Promise<void>[] = [];
|
|
257
|
+
for (const operation of this.#operations.values()) {
|
|
258
|
+
if (
|
|
259
|
+
operation.sessionId !== target &&
|
|
260
|
+
this.#state.binding(operation.chatId) !== target
|
|
261
|
+
)
|
|
262
|
+
continue;
|
|
263
|
+
if (operation.initialization)
|
|
264
|
+
rollbacks.push(this.#rollbackInitialization(operation));
|
|
265
|
+
if (!operation.communication || operation.initialization)
|
|
266
|
+
operation.controller.abort(error);
|
|
267
|
+
}
|
|
268
|
+
this.#notifyChange();
|
|
269
|
+
try {
|
|
270
|
+
await Promise.all(rollbacks);
|
|
271
|
+
if (!this.#state.code(target)) {
|
|
272
|
+
this.#locks.delete(target);
|
|
273
|
+
this.#notifyChange();
|
|
274
|
+
throw new Error(
|
|
275
|
+
"Initialize this Pi session before starting synchronization",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await this.#notify(
|
|
279
|
+
target,
|
|
280
|
+
`${chatLabel(activity)} started synchronization`,
|
|
281
|
+
{ ...activity, event: "sync_started" },
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
this.#startingLocks.delete(target);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
...result,
|
|
289
|
+
...this.syncState(target),
|
|
290
|
+
instructions: syncMessage(target),
|
|
291
|
+
};
|
|
151
292
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
293
|
+
if (this.#startingLocks.has(target))
|
|
294
|
+
throw new Error("Synchronization is still starting; retry this action");
|
|
295
|
+
if (!this.#locks.has(target))
|
|
296
|
+
throw new Error("This Pi session is not synchronizing");
|
|
297
|
+
const current = this.#state.code(target);
|
|
298
|
+
if (!current)
|
|
299
|
+
throw new Error("This Pi session has no synchronization code");
|
|
300
|
+
if (code !== current) {
|
|
301
|
+
await this.#notify(
|
|
302
|
+
target,
|
|
303
|
+
`${chatLabel(activity)} could not verify synchronization`,
|
|
304
|
+
{ ...activity, event: "sync_rejected" },
|
|
305
|
+
);
|
|
306
|
+
throw new Error(
|
|
307
|
+
`The sync code is missing or outdated. This execution is a stale, accidental duplicate. ${exitInstructions}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (action === "verify") {
|
|
311
|
+
let renewed: string | undefined;
|
|
312
|
+
if (!this.#locks.get(target)) {
|
|
313
|
+
this.#locks.set(target, true);
|
|
314
|
+
renewed = randomBytes(6).toString("hex");
|
|
315
|
+
await this.#state.setCode(target, renewed);
|
|
316
|
+
await this.#notify(
|
|
317
|
+
target,
|
|
318
|
+
`${chatLabel(activity)} verified synchronization`,
|
|
319
|
+
{ ...activity, event: "sync_verified" },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...result,
|
|
324
|
+
...this.syncState(target),
|
|
325
|
+
...(renewed ? { code: renewed } : {}),
|
|
326
|
+
instructions:
|
|
327
|
+
"This is the surviving execution. Keep the verified code private in this ChatGPT context. Use chat and history to identify every observed conflicting execution and require its final exit. Confirm they have ended their responses, rather than only paused ordinary tools. Verification, idle Pi status, or a quiet history page alone does not establish their exit. Release with this code only after explicit final exit statements and resolution of conflicting activity; ordinary tools remain locked until then.",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!this.#locks.get(target))
|
|
331
|
+
throw new Error(
|
|
332
|
+
"Verify the initialization code before releasing synchronization",
|
|
333
|
+
);
|
|
334
|
+
this.#locks.delete(target);
|
|
335
|
+
this.#notifyChange();
|
|
336
|
+
await this.#notify(
|
|
337
|
+
target,
|
|
338
|
+
`${chatLabel(activity)} released synchronization`,
|
|
339
|
+
{ ...activity, event: "sync_released" },
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
...result,
|
|
343
|
+
...this.syncState(target),
|
|
344
|
+
instructions:
|
|
345
|
+
"Synchronization released. The surviving execution may continue the current task. Executions with rejected or unavailable codes remain finished.",
|
|
346
|
+
};
|
|
155
347
|
}
|
|
156
348
|
|
|
157
|
-
listSessions(
|
|
158
|
-
|
|
159
|
-
|
|
349
|
+
listSessions(sessionId?: string): (SessionDescription & {
|
|
350
|
+
bindingCount: number;
|
|
351
|
+
sync?: { locked: boolean; verified: boolean };
|
|
352
|
+
})[] {
|
|
160
353
|
const counts = this.#state.bindingCounts();
|
|
161
354
|
return [...this.#sessions.values()]
|
|
162
355
|
.filter(({ description }) => !sessionId || description.id === sessionId)
|
|
163
356
|
.map(({ description }) => ({
|
|
164
357
|
...description,
|
|
165
358
|
bindingCount: counts.get(description.id) ?? 0,
|
|
359
|
+
...(this.#sync ? { sync: this.syncState(description.id) } : {}),
|
|
166
360
|
}));
|
|
167
361
|
}
|
|
168
362
|
|
|
@@ -177,24 +371,22 @@ export class Broker {
|
|
|
177
371
|
async initialize(
|
|
178
372
|
chatId: string,
|
|
179
373
|
sessionId: string | undefined,
|
|
374
|
+
requestId: unknown,
|
|
180
375
|
signal: AbortSignal,
|
|
181
376
|
): Promise<InitializedSession> {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
true,
|
|
188
|
-
);
|
|
189
|
-
if (previous === target)
|
|
190
|
-
await this.#notify(target, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
377
|
+
const {
|
|
378
|
+
sessionId: target,
|
|
379
|
+
selection,
|
|
380
|
+
initialization,
|
|
381
|
+
} = await this.#selectSession(chatId, sessionId, requestId, signal, true);
|
|
191
382
|
const { inspection, inputs, globalAgents } = await this.#inspect(
|
|
192
383
|
target,
|
|
193
384
|
signal,
|
|
194
385
|
);
|
|
195
|
-
await this.#ackInputs(target, inputs);
|
|
386
|
+
await this.#ackInputs(target, inputs, signal);
|
|
196
387
|
return {
|
|
197
388
|
selection,
|
|
389
|
+
...(initialization ? { initialization } : {}),
|
|
198
390
|
...inspection,
|
|
199
391
|
tools: inspection.tools.map(({ name, description }) => ({
|
|
200
392
|
name,
|
|
@@ -209,22 +401,37 @@ export class Broker {
|
|
|
209
401
|
chatId: string,
|
|
210
402
|
sessionId: string | undefined,
|
|
211
403
|
text: string,
|
|
404
|
+
requestId: unknown,
|
|
212
405
|
signal: AbortSignal,
|
|
213
406
|
): Promise<ChatResult> {
|
|
214
|
-
const { sessionId: target } = await this.#selectSession(
|
|
407
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
215
408
|
chatId,
|
|
216
409
|
sessionId,
|
|
410
|
+
requestId,
|
|
217
411
|
signal,
|
|
218
412
|
false,
|
|
413
|
+
true,
|
|
219
414
|
);
|
|
220
415
|
const result = await this.#request(
|
|
221
416
|
target,
|
|
222
|
-
(id) => ({
|
|
417
|
+
(id) => ({
|
|
418
|
+
type: "chat",
|
|
419
|
+
id,
|
|
420
|
+
...source(chatId, requestId),
|
|
421
|
+
sessionId: target,
|
|
422
|
+
text,
|
|
423
|
+
}),
|
|
223
424
|
signal,
|
|
224
425
|
);
|
|
225
426
|
if ("message" in result) {
|
|
226
|
-
|
|
227
|
-
|
|
427
|
+
const inputs = this.deliveryAllowed(signal) ? result.inputs : [];
|
|
428
|
+
await this.#ackInputs(target, inputs, signal);
|
|
429
|
+
return {
|
|
430
|
+
sessionId: target,
|
|
431
|
+
cwd: result.cwd,
|
|
432
|
+
inputs,
|
|
433
|
+
...(initialization ? { initialization } : {}),
|
|
434
|
+
};
|
|
228
435
|
}
|
|
229
436
|
throw new Error("Pi session returned no assistant message");
|
|
230
437
|
}
|
|
@@ -233,19 +440,21 @@ export class Broker {
|
|
|
233
440
|
chatId: string,
|
|
234
441
|
sessionId: string | undefined,
|
|
235
442
|
names: string[] | undefined,
|
|
443
|
+
requestId: unknown,
|
|
236
444
|
signal: AbortSignal,
|
|
237
445
|
): Promise<InspectedSession> {
|
|
238
|
-
const { sessionId: target } = await this.#selectSession(
|
|
446
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
239
447
|
chatId,
|
|
240
448
|
sessionId,
|
|
449
|
+
requestId,
|
|
241
450
|
signal,
|
|
242
|
-
false,
|
|
243
451
|
);
|
|
244
452
|
const { inspection, inputs } = await this.#inspect(target, signal);
|
|
245
|
-
await this.#ackInputs(target, inputs);
|
|
453
|
+
await this.#ackInputs(target, inputs, signal);
|
|
246
454
|
const selected = names ? new Set(names) : undefined;
|
|
247
455
|
return {
|
|
248
456
|
...inspection,
|
|
457
|
+
...(initialization ? { initialization } : {}),
|
|
249
458
|
tools: selected
|
|
250
459
|
? inspection.tools.filter(({ name }) => selected.has(name))
|
|
251
460
|
: inspection.tools,
|
|
@@ -257,13 +466,14 @@ export class Broker {
|
|
|
257
466
|
chatId: string,
|
|
258
467
|
sessionId: string | undefined,
|
|
259
468
|
calls: ToolInput[],
|
|
469
|
+
requestId: unknown,
|
|
260
470
|
signal: AbortSignal,
|
|
261
471
|
): Promise<CallResult> {
|
|
262
|
-
const { sessionId: target } = await this.#selectSession(
|
|
472
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
263
473
|
chatId,
|
|
264
474
|
sessionId,
|
|
475
|
+
requestId,
|
|
265
476
|
signal,
|
|
266
|
-
false,
|
|
267
477
|
);
|
|
268
478
|
const toolCalls: ToolCall[] = calls.map((call) => ({
|
|
269
479
|
type: "toolCall",
|
|
@@ -276,16 +486,17 @@ export class Broker {
|
|
|
276
486
|
(id) => ({
|
|
277
487
|
type: "call",
|
|
278
488
|
id,
|
|
279
|
-
chatId,
|
|
489
|
+
...source(chatId, requestId),
|
|
280
490
|
sessionId: target,
|
|
281
491
|
calls: toolCalls,
|
|
282
492
|
}),
|
|
283
493
|
signal,
|
|
284
494
|
);
|
|
285
495
|
if ("toolResults" in result) {
|
|
286
|
-
await this.#ackInputs(target, result.inputs);
|
|
496
|
+
await this.#ackInputs(target, result.inputs, signal);
|
|
287
497
|
return {
|
|
288
498
|
sessionId: target,
|
|
499
|
+
...(initialization ? { initialization } : {}),
|
|
289
500
|
cwd: result.cwd,
|
|
290
501
|
toolResults: result.toolResults,
|
|
291
502
|
inputs: result.inputs,
|
|
@@ -294,15 +505,42 @@ export class Broker {
|
|
|
294
505
|
throw new Error("Pi session returned no tool results");
|
|
295
506
|
}
|
|
296
507
|
|
|
508
|
+
async history(
|
|
509
|
+
chatId: string,
|
|
510
|
+
sessionId: string | undefined,
|
|
511
|
+
range: HistoryRange,
|
|
512
|
+
requestId: unknown,
|
|
513
|
+
signal: AbortSignal,
|
|
514
|
+
) {
|
|
515
|
+
const target = sessionId ?? this.#state.binding(chatId);
|
|
516
|
+
if (!target) throw new Error("Specify a Pi sessionId to read history");
|
|
517
|
+
await this.#waitForSession(target, signal);
|
|
518
|
+
const result = await this.#request(
|
|
519
|
+
target,
|
|
520
|
+
(id) => ({
|
|
521
|
+
type: "history",
|
|
522
|
+
id,
|
|
523
|
+
sessionId: target,
|
|
524
|
+
range,
|
|
525
|
+
...source(chatId, requestId),
|
|
526
|
+
}),
|
|
527
|
+
signal,
|
|
528
|
+
);
|
|
529
|
+
if ("history" in result) return { sessionId: target, ...result };
|
|
530
|
+
throw new Error("Pi session returned no history");
|
|
531
|
+
}
|
|
532
|
+
|
|
297
533
|
async inputs(
|
|
298
534
|
chatId: string,
|
|
299
535
|
sessionId: string | undefined,
|
|
300
536
|
signal: AbortSignal,
|
|
301
537
|
): Promise<SessionInput[]> {
|
|
302
538
|
const target = sessionId ?? this.#state.binding(chatId);
|
|
303
|
-
if (!target || !this.#sessions.has(target)
|
|
539
|
+
if (!target || !this.#sessions.has(target) || !this.deliveryAllowed(signal))
|
|
540
|
+
return [];
|
|
304
541
|
const { inputs } = await this.#inspect(target, signal);
|
|
305
|
-
|
|
542
|
+
if (!this.deliveryAllowed(signal)) return [];
|
|
543
|
+
await this.#ackInputs(target, inputs, signal);
|
|
306
544
|
return inputs;
|
|
307
545
|
}
|
|
308
546
|
|
|
@@ -310,14 +548,16 @@ export class Broker {
|
|
|
310
548
|
chatId: string,
|
|
311
549
|
sessionId: string | undefined,
|
|
312
550
|
input: QuestionInput,
|
|
551
|
+
requestId: unknown,
|
|
313
552
|
signal: AbortSignal,
|
|
314
|
-
): Promise<Question> {
|
|
315
|
-
const { sessionId: target } = await this.#selectSession(
|
|
553
|
+
): Promise<Question & { initialization?: Initialization }> {
|
|
554
|
+
const { sessionId: target, initialization } = await this.#selectSession(
|
|
316
555
|
chatId,
|
|
317
556
|
sessionId,
|
|
557
|
+
requestId,
|
|
318
558
|
signal,
|
|
319
|
-
false,
|
|
320
559
|
);
|
|
560
|
+
this.#requireUnlocked(chatId, target, signal);
|
|
321
561
|
const session = this.#sessions.get(target);
|
|
322
562
|
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
323
563
|
const question: QuestionRecord = {
|
|
@@ -329,11 +569,19 @@ export class Broker {
|
|
|
329
569
|
delivered: false,
|
|
330
570
|
};
|
|
331
571
|
await this.#state.addQuestion(question);
|
|
572
|
+
const activity = source(chatId, requestId);
|
|
332
573
|
void this.#notify(
|
|
333
574
|
target,
|
|
334
|
-
|
|
575
|
+
`${chatLabel(activity)} asked: ${question.question}`,
|
|
576
|
+
{
|
|
577
|
+
event: "asked",
|
|
578
|
+
...activity,
|
|
579
|
+
},
|
|
335
580
|
).catch(() => {});
|
|
336
|
-
return
|
|
581
|
+
return {
|
|
582
|
+
...questionView(question),
|
|
583
|
+
...(initialization ? { initialization } : {}),
|
|
584
|
+
};
|
|
337
585
|
}
|
|
338
586
|
|
|
339
587
|
async assertQuestion(
|
|
@@ -344,6 +592,7 @@ export class Broker {
|
|
|
344
592
|
for (;;) {
|
|
345
593
|
signal.throwIfAborted();
|
|
346
594
|
const question = this.#state.question(chatId, id);
|
|
595
|
+
this.#requireUnlocked(chatId, question.sessionId, signal);
|
|
347
596
|
if (question.loaded) return questionView(question);
|
|
348
597
|
await this.#waitForChange(signal);
|
|
349
598
|
}
|
|
@@ -378,14 +627,20 @@ export class Broker {
|
|
|
378
627
|
: previous
|
|
379
628
|
? `Answer updated in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`
|
|
380
629
|
: `Answered in ChatGPT ${chatId.slice(-4)}: ${question.question} — ${response}`;
|
|
381
|
-
void this.#notify(question.sessionId, message
|
|
630
|
+
void this.#notify(question.sessionId, message, {
|
|
631
|
+
event: question.answer?.skipped ? "skipped" : "answered",
|
|
632
|
+
chatId,
|
|
633
|
+
}).catch(() => {});
|
|
382
634
|
}
|
|
383
635
|
}
|
|
384
636
|
return questionView(question);
|
|
385
637
|
}
|
|
386
638
|
|
|
387
639
|
answers(chatId: string): QuestionRecord[] {
|
|
388
|
-
|
|
640
|
+
if (this.synchronizing(chatId)) return [];
|
|
641
|
+
return this.#state
|
|
642
|
+
.answers(chatId)
|
|
643
|
+
.filter((question) => !this.#locks.has(question.sessionId));
|
|
389
644
|
}
|
|
390
645
|
|
|
391
646
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
@@ -401,7 +656,10 @@ export class Broker {
|
|
|
401
656
|
}
|
|
402
657
|
|
|
403
658
|
deliveries(chatId: string): DeliveryRecord[] {
|
|
404
|
-
|
|
659
|
+
if (this.synchronizing(chatId)) return [];
|
|
660
|
+
return this.#state
|
|
661
|
+
.deliveries(chatId)
|
|
662
|
+
.filter((delivery) => !this.#locks.has(delivery.sessionId));
|
|
405
663
|
}
|
|
406
664
|
|
|
407
665
|
acknowledge(
|
|
@@ -430,7 +688,9 @@ export class Broker {
|
|
|
430
688
|
sessionId: message.session.id,
|
|
431
689
|
});
|
|
432
690
|
if (!registered)
|
|
433
|
-
await this.#notify(message.session.id, "Chappie connected"
|
|
691
|
+
await this.#notify(message.session.id, "Chappie connected", {
|
|
692
|
+
event: "connected",
|
|
693
|
+
});
|
|
434
694
|
this.#notifyChange();
|
|
435
695
|
break;
|
|
436
696
|
}
|
|
@@ -460,56 +720,146 @@ export class Broker {
|
|
|
460
720
|
async #selectSession(
|
|
461
721
|
chatId: string,
|
|
462
722
|
requestedId: string | undefined,
|
|
723
|
+
requestId: unknown,
|
|
463
724
|
signal: AbortSignal,
|
|
464
|
-
bindRequested
|
|
725
|
+
bindRequested = false,
|
|
726
|
+
communication = false,
|
|
465
727
|
): Promise<{
|
|
466
728
|
sessionId: string;
|
|
467
729
|
selection: InitializedSession["selection"];
|
|
730
|
+
initialization?: Initialization;
|
|
468
731
|
}> {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
732
|
+
for (;;) {
|
|
733
|
+
signal.throwIfAborted();
|
|
734
|
+
const boundId = this.#state.binding(chatId);
|
|
735
|
+
let target = requestedId ?? boundId;
|
|
736
|
+
if (!target) {
|
|
737
|
+
const occupied = this.#state.bindingCounts();
|
|
738
|
+
target = [...this.#sessions.keys()].find(
|
|
739
|
+
(id) => !occupied.has(id) && !this.#locks.has(id),
|
|
740
|
+
);
|
|
473
741
|
}
|
|
474
|
-
|
|
742
|
+
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
743
|
+
if (!target) {
|
|
744
|
+
await this.#waitForChange(signal);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
await this.#waitForSession(target, signal);
|
|
748
|
+
signal.throwIfAborted();
|
|
749
|
+
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
750
|
+
const current = this.#state.binding(chatId);
|
|
751
|
+
const initialize =
|
|
752
|
+
bindRequested ||
|
|
753
|
+
!current ||
|
|
754
|
+
(current === target && this.#sync && !this.#state.code(target));
|
|
755
|
+
const initialization =
|
|
756
|
+
initialize && !this.synchronizing(chatId, target)
|
|
757
|
+
? await this.#join(chatId, target, requestId, signal, bindRequested)
|
|
758
|
+
: undefined;
|
|
759
|
+
return {
|
|
760
|
+
sessionId: target,
|
|
761
|
+
selection: requestedId
|
|
762
|
+
? "explicit"
|
|
763
|
+
: boundId
|
|
764
|
+
? "existing"
|
|
765
|
+
: "automatic",
|
|
766
|
+
...(initialization ? { initialization } : {}),
|
|
767
|
+
};
|
|
475
768
|
}
|
|
769
|
+
}
|
|
476
770
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
771
|
+
async #join(
|
|
772
|
+
chatId: string,
|
|
773
|
+
sessionId: string,
|
|
774
|
+
requestId: unknown,
|
|
775
|
+
signal: AbortSignal,
|
|
776
|
+
explicit = false,
|
|
777
|
+
): Promise<Initialization> {
|
|
778
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
779
|
+
const operation = this.#operations.get(signal);
|
|
780
|
+
if (operation) this.#observe(operation, sessionId);
|
|
781
|
+
const previous = this.#state.binding(chatId);
|
|
782
|
+
const code = this.#sync ? randomBytes(6).toString("hex") : undefined;
|
|
783
|
+
await this.#state.bind(chatId, sessionId);
|
|
784
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
785
|
+
if (code) {
|
|
786
|
+
if (!operation)
|
|
787
|
+
throw new Error(
|
|
788
|
+
"Initialization is outside a tracked Chappie operation",
|
|
789
|
+
);
|
|
790
|
+
operation.initialization = {
|
|
791
|
+
sessionId,
|
|
792
|
+
code,
|
|
793
|
+
previousCode: undefined,
|
|
794
|
+
committed: false,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const activity = source(chatId, requestId);
|
|
798
|
+
if (previous !== sessionId) {
|
|
799
|
+
this.#notifyChange();
|
|
800
|
+
if (previous)
|
|
801
|
+
await this.#notify(previous, `${chatLabel(activity)} left`, {
|
|
802
|
+
...activity,
|
|
803
|
+
event: "left",
|
|
804
|
+
});
|
|
481
805
|
}
|
|
806
|
+
this.#requireUnlocked(chatId, sessionId, signal);
|
|
807
|
+
await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
|
|
808
|
+
...activity,
|
|
809
|
+
event: "joined",
|
|
810
|
+
initialization: explicit ? "explicit" : "implicit",
|
|
811
|
+
});
|
|
812
|
+
return {
|
|
813
|
+
sessionId,
|
|
814
|
+
...(code ? { code } : {}),
|
|
815
|
+
instructions: code
|
|
816
|
+
? `${historyInstructions} Retain this initialization code in this ChatGPT context for sync verification; Pi history and chat messages contain only coordination details.`
|
|
817
|
+
: historyInstructions,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
482
820
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
821
|
+
#requireUnlocked(
|
|
822
|
+
chatId: string,
|
|
823
|
+
sessionId: string | undefined,
|
|
824
|
+
signal: AbortSignal,
|
|
825
|
+
): void {
|
|
826
|
+
signal.throwIfAborted();
|
|
827
|
+
const operation = this.#operations.get(signal);
|
|
828
|
+
if (operation) this.#observe(operation, sessionId);
|
|
829
|
+
const locked = this.synchronizing(chatId, sessionId);
|
|
830
|
+
if (locked) throw new Error(syncMessage(locked));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#observe(operation: Operation, sessionId: string | undefined): void {
|
|
834
|
+
if (!sessionId) return;
|
|
835
|
+
operation.sessionId ??= sessionId;
|
|
836
|
+
if (!operation.revisions.has(sessionId)) {
|
|
837
|
+
operation.revisions.set(
|
|
838
|
+
sessionId,
|
|
839
|
+
this.#syncRevisions.get(sessionId) ?? 0,
|
|
487
840
|
);
|
|
488
|
-
if (candidate) {
|
|
489
|
-
await this.#bind(chatId, candidate);
|
|
490
|
-
return { sessionId: candidate, selection: "automatic" };
|
|
491
|
-
}
|
|
492
|
-
await this.#waitForChange(signal);
|
|
493
841
|
}
|
|
842
|
+
if (this.#locks.has(sessionId)) operation.coordinating = true;
|
|
494
843
|
}
|
|
495
844
|
|
|
496
|
-
async #
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
await this.#notify(sessionId, `ChatGPT ${chatId.slice(-4)} joined`);
|
|
845
|
+
async #rollbackInitialization(operation: Operation): Promise<void> {
|
|
846
|
+
const initialization = operation.initialization;
|
|
847
|
+
if (!initialization?.committed) return;
|
|
848
|
+
initialization.committed = false;
|
|
849
|
+
await this.#state.setCode(
|
|
850
|
+
initialization.sessionId,
|
|
851
|
+
initialization.previousCode,
|
|
852
|
+
);
|
|
507
853
|
}
|
|
508
854
|
|
|
509
|
-
async #notify(
|
|
855
|
+
async #notify(
|
|
856
|
+
sessionId: string,
|
|
857
|
+
message: string,
|
|
858
|
+
activity: Activity = {},
|
|
859
|
+
): Promise<void> {
|
|
510
860
|
await this.#sessions
|
|
511
861
|
.get(sessionId)
|
|
512
|
-
?.peer.send({ type: "notice", sessionId, message });
|
|
862
|
+
?.peer.send({ type: "notice", sessionId, message, activity });
|
|
513
863
|
}
|
|
514
864
|
|
|
515
865
|
async #waitForSession(sessionId: string, signal: AbortSignal): Promise<void> {
|
|
@@ -529,7 +879,12 @@ export class Broker {
|
|
|
529
879
|
throw new Error("Pi session returned no inspection");
|
|
530
880
|
}
|
|
531
881
|
|
|
532
|
-
async #ackInputs(
|
|
882
|
+
async #ackInputs(
|
|
883
|
+
sessionId: string,
|
|
884
|
+
inputs: SessionInput[],
|
|
885
|
+
signal: AbortSignal,
|
|
886
|
+
): Promise<void> {
|
|
887
|
+
signal.throwIfAborted();
|
|
533
888
|
if (inputs.length === 0) return;
|
|
534
889
|
const session = this.#sessions.get(sessionId);
|
|
535
890
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
@@ -545,6 +900,12 @@ export class Broker {
|
|
|
545
900
|
message: (id: number) => BrokerMessage,
|
|
546
901
|
signal: AbortSignal,
|
|
547
902
|
): Promise<SessionResult> {
|
|
903
|
+
const operation = this.#operations.get(signal);
|
|
904
|
+
if (operation) {
|
|
905
|
+
this.#observe(operation, sessionId);
|
|
906
|
+
if (!operation.communication)
|
|
907
|
+
this.#requireUnlocked(operation.chatId, sessionId, signal);
|
|
908
|
+
}
|
|
548
909
|
const session = this.#sessions.get(sessionId);
|
|
549
910
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
550
911
|
if (signal.aborted) throw abortError(signal);
|
|
@@ -573,12 +934,10 @@ export class Broker {
|
|
|
573
934
|
};
|
|
574
935
|
this.#pending.set(id, pending);
|
|
575
936
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
576
|
-
|
|
577
|
-
await session.peer.send(message(id));
|
|
578
|
-
} catch (error) {
|
|
937
|
+
void session.peer.send(message(id)).catch((error: unknown) => {
|
|
579
938
|
this.#finishRequest(id, pending);
|
|
580
|
-
|
|
581
|
-
}
|
|
939
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
940
|
+
});
|
|
582
941
|
return completion.promise;
|
|
583
942
|
}
|
|
584
943
|
|
|
@@ -630,6 +989,10 @@ export class Broker {
|
|
|
630
989
|
}
|
|
631
990
|
}
|
|
632
991
|
|
|
992
|
+
function syncMessage(sessionId: string): string {
|
|
993
|
+
return `Pi session ${sessionId} is synchronizing to end duplicate executions. Ordinary tools and initialization are locked for this session and its bound ChatGPT conversations. Verify with the code returned by your own most recent initialization. With a missing or rejected code, this execution is an accidental duplicate: ${exitInstructions} The verified execution must confirm the conflicting executions' final exits before releasing the lock.`;
|
|
994
|
+
}
|
|
995
|
+
|
|
633
996
|
function abortError(signal: AbortSignal): Error {
|
|
634
997
|
return signal.reason instanceof Error
|
|
635
998
|
? signal.reason
|