@zetaloop/chappie 0.4.0 → 0.5.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 +3 -9
- package/docs/tools.md +36 -36
- package/package.json +1 -1
- package/src/broker.ts +106 -328
- package/src/config.ts +0 -1
- package/src/history.ts +15 -2
- package/src/index.ts +4 -1
- package/src/instructions.md +5 -5
- package/src/ipc.ts +19 -4
- package/src/questions.ts +6 -14
- package/src/resources.ts +26 -6
- package/src/server.ts +40 -89
- package/src/session.ts +272 -27
- package/src/state.ts +1 -17
- package/src/transfer.ts +200 -49
package/src/broker.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
3
3
|
import { type Activity, chatLabel, source } from "./activity.ts";
|
|
4
4
|
import { readConfig } from "./config.ts";
|
|
@@ -25,8 +25,8 @@ import { type ResourceData, resourceSessionId } from "./resources.ts";
|
|
|
25
25
|
import { State } from "./state.ts";
|
|
26
26
|
import type { ToolInput } from "./tools.ts";
|
|
27
27
|
|
|
28
|
-
const
|
|
29
|
-
"
|
|
28
|
+
const observerInstructions =
|
|
29
|
+
"This ChatGPT conversation recently initialized or resumed work in this Pi session. A parallel execution is already continuing the task. Participate as an observer for this task: read history with observer: true, follow new entries with after and wait: true, and think independently. Leave execution and Pi communication to the ongoing work. Once its completion is recorded, explain the actual results in ChatGPT and finish your response. Continue observing this task rather than reinitializing to take over.";
|
|
30
30
|
|
|
31
31
|
interface RegisteredSession {
|
|
32
32
|
description: SessionDescription;
|
|
@@ -41,23 +41,6 @@ interface PendingRequest {
|
|
|
41
41
|
onAbort(): void;
|
|
42
42
|
}
|
|
43
43
|
|
|
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;
|
|
54
|
-
controller: AbortController;
|
|
55
|
-
communication: boolean;
|
|
56
|
-
coordinating: boolean;
|
|
57
|
-
revisions: Map<string, number>;
|
|
58
|
-
initialization?: InitializationGrant;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
44
|
interface ChangeWaiter {
|
|
62
45
|
resolve(): void;
|
|
63
46
|
reject(error: Error): void;
|
|
@@ -66,7 +49,6 @@ interface ChangeWaiter {
|
|
|
66
49
|
}
|
|
67
50
|
|
|
68
51
|
export interface Initialization {
|
|
69
|
-
code?: string;
|
|
70
52
|
sessionId: string;
|
|
71
53
|
instructions: string;
|
|
72
54
|
}
|
|
@@ -74,7 +56,7 @@ export interface Initialization {
|
|
|
74
56
|
export interface InitializedSession extends Omit<SessionInspection, "tools"> {
|
|
75
57
|
selection: "existing" | "explicit" | "automatic";
|
|
76
58
|
initialization?: Initialization;
|
|
77
|
-
globalAgents?: string;
|
|
59
|
+
globalAgents?: { path: string };
|
|
78
60
|
inputs: SessionInput[];
|
|
79
61
|
tools: { name: string; description: string }[];
|
|
80
62
|
}
|
|
@@ -102,12 +84,12 @@ export class Broker {
|
|
|
102
84
|
readonly #sessions = new Map<string, RegisteredSession>();
|
|
103
85
|
readonly #pending = new Map<number, PendingRequest>();
|
|
104
86
|
readonly #waiters = new Set<ChangeWaiter>();
|
|
105
|
-
readonly #
|
|
106
|
-
readonly #
|
|
107
|
-
|
|
108
|
-
|
|
87
|
+
readonly #cooldowns = new Map<string, number>();
|
|
88
|
+
readonly #relays = new Map<
|
|
89
|
+
JsonLinePeer<SessionMessage, BrokerMessage>,
|
|
90
|
+
Map<number, AbortController>
|
|
91
|
+
>();
|
|
109
92
|
#ask = true;
|
|
110
|
-
#sync = false;
|
|
111
93
|
#nextRequestId = 1;
|
|
112
94
|
|
|
113
95
|
constructor(agentDir: string) {
|
|
@@ -123,20 +105,17 @@ export class Broker {
|
|
|
123
105
|
async start(): Promise<void> {
|
|
124
106
|
const config = await readConfig(this.#agentDir);
|
|
125
107
|
this.#ask = config.ask ?? true;
|
|
126
|
-
this.#sync = config.sync ?? false;
|
|
127
108
|
await this.#state.load();
|
|
128
109
|
await this.#ipc.start(config.listen ?? false);
|
|
129
110
|
}
|
|
130
111
|
|
|
131
112
|
async close(): Promise<void> {
|
|
132
113
|
const error = new Error("Chappie broker ended");
|
|
133
|
-
|
|
134
|
-
|
|
114
|
+
this.#cooldowns.clear();
|
|
115
|
+
for (const relays of this.#relays.values()) {
|
|
116
|
+
for (const controller of relays.values()) controller.abort(error);
|
|
135
117
|
}
|
|
136
|
-
this.#
|
|
137
|
-
this.#locks.clear();
|
|
138
|
-
this.#startingLocks.clear();
|
|
139
|
-
this.#syncRevisions.clear();
|
|
118
|
+
this.#relays.clear();
|
|
140
119
|
for (const [id, pending] of this.#pending) {
|
|
141
120
|
this.#finishRequest(id, pending);
|
|
142
121
|
pending.reject(error);
|
|
@@ -150,205 +129,8 @@ export class Broker {
|
|
|
150
129
|
await this.#ipc.close();
|
|
151
130
|
}
|
|
152
131
|
|
|
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,
|
|
236
|
-
requestId: unknown,
|
|
237
|
-
signal: AbortSignal,
|
|
238
|
-
) {
|
|
239
|
-
if (!this.#sync) throw new Error("Session synchronization is disabled");
|
|
240
|
-
signal.throwIfAborted();
|
|
241
|
-
const target = sessionId ?? this.#state.binding(chatId);
|
|
242
|
-
if (!target) throw new Error("Specify a Pi sessionId to synchronize");
|
|
243
|
-
const locked = this.synchronizing(chatId);
|
|
244
|
-
if (locked && locked !== target) throw new Error(syncMessage(locked));
|
|
245
|
-
const activity = source(chatId, requestId);
|
|
246
|
-
const result = { sessionId: target };
|
|
247
|
-
if (action === "start") {
|
|
248
|
-
if (!this.#locks.has(target)) {
|
|
249
|
-
this.#locks.set(target, false);
|
|
250
|
-
this.#startingLocks.add(target);
|
|
251
|
-
this.#syncRevisions.set(
|
|
252
|
-
target,
|
|
253
|
-
(this.#syncRevisions.get(target) ?? 0) + 1,
|
|
254
|
-
);
|
|
255
|
-
const error = new Error(syncMessage(target));
|
|
256
|
-
const rollbacks: Promise<void>[] = [];
|
|
257
|
-
for (const operation of this.#operations.values()) {
|
|
258
|
-
if (
|
|
259
|
-
operation.sessionId !== target &&
|
|
260
|
-
this.#state.binding(operation.chatId) !== target
|
|
261
|
-
)
|
|
262
|
-
continue;
|
|
263
|
-
if (operation.initialization)
|
|
264
|
-
rollbacks.push(this.#rollbackInitialization(operation));
|
|
265
|
-
if (!operation.communication || operation.initialization)
|
|
266
|
-
operation.controller.abort(error);
|
|
267
|
-
}
|
|
268
|
-
this.#notifyChange();
|
|
269
|
-
try {
|
|
270
|
-
await Promise.all(rollbacks);
|
|
271
|
-
if (!this.#state.code(target)) {
|
|
272
|
-
this.#locks.delete(target);
|
|
273
|
-
this.#notifyChange();
|
|
274
|
-
throw new Error(
|
|
275
|
-
"Initialize this Pi session before starting synchronization",
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
await this.#notify(
|
|
279
|
-
target,
|
|
280
|
-
`${chatLabel(activity)} started synchronization`,
|
|
281
|
-
{ ...activity, event: "sync_started" },
|
|
282
|
-
);
|
|
283
|
-
} finally {
|
|
284
|
-
this.#startingLocks.delete(target);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
...result,
|
|
289
|
-
...this.syncState(target),
|
|
290
|
-
instructions: syncMessage(target),
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
if (this.#startingLocks.has(target))
|
|
294
|
-
throw new Error("Synchronization is still starting; retry this action");
|
|
295
|
-
if (!this.#locks.has(target))
|
|
296
|
-
throw new Error("This Pi session is not synchronizing");
|
|
297
|
-
const current = this.#state.code(target);
|
|
298
|
-
if (!current)
|
|
299
|
-
throw new Error("This Pi session has no synchronization code");
|
|
300
|
-
if (code !== current) {
|
|
301
|
-
await this.#notify(
|
|
302
|
-
target,
|
|
303
|
-
`${chatLabel(activity)} could not verify synchronization`,
|
|
304
|
-
{ ...activity, event: "sync_rejected" },
|
|
305
|
-
);
|
|
306
|
-
throw new Error(
|
|
307
|
-
`The sync code is missing or outdated. This execution is a stale, accidental duplicate. ${exitInstructions}`,
|
|
308
|
-
);
|
|
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
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
132
|
listSessions(sessionId?: string): (SessionDescription & {
|
|
350
133
|
bindingCount: number;
|
|
351
|
-
sync?: { locked: boolean; verified: boolean };
|
|
352
134
|
})[] {
|
|
353
135
|
const counts = this.#state.bindingCounts();
|
|
354
136
|
return [...this.#sessions.values()]
|
|
@@ -356,7 +138,6 @@ export class Broker {
|
|
|
356
138
|
.map(({ description }) => ({
|
|
357
139
|
...description,
|
|
358
140
|
bindingCount: counts.get(description.id) ?? 0,
|
|
359
|
-
...(this.#sync ? { sync: this.syncState(description.id) } : {}),
|
|
360
141
|
}));
|
|
361
142
|
}
|
|
362
143
|
|
|
@@ -409,8 +190,6 @@ export class Broker {
|
|
|
409
190
|
sessionId,
|
|
410
191
|
requestId,
|
|
411
192
|
signal,
|
|
412
|
-
false,
|
|
413
|
-
true,
|
|
414
193
|
);
|
|
415
194
|
const result = await this.#request(
|
|
416
195
|
target,
|
|
@@ -424,7 +203,7 @@ export class Broker {
|
|
|
424
203
|
signal,
|
|
425
204
|
);
|
|
426
205
|
if ("message" in result) {
|
|
427
|
-
const inputs =
|
|
206
|
+
const inputs = result.inputs;
|
|
428
207
|
await this.#ackInputs(target, inputs, signal);
|
|
429
208
|
return {
|
|
430
209
|
sessionId: target,
|
|
@@ -536,10 +315,8 @@ export class Broker {
|
|
|
536
315
|
signal: AbortSignal,
|
|
537
316
|
): Promise<SessionInput[]> {
|
|
538
317
|
const target = sessionId ?? this.#state.binding(chatId);
|
|
539
|
-
if (!target || !this.#sessions.has(target)
|
|
540
|
-
return [];
|
|
318
|
+
if (!target || !this.#sessions.has(target)) return [];
|
|
541
319
|
const { inputs } = await this.#inspect(target, signal);
|
|
542
|
-
if (!this.deliveryAllowed(signal)) return [];
|
|
543
320
|
await this.#ackInputs(target, inputs, signal);
|
|
544
321
|
return inputs;
|
|
545
322
|
}
|
|
@@ -557,7 +334,7 @@ export class Broker {
|
|
|
557
334
|
requestId,
|
|
558
335
|
signal,
|
|
559
336
|
);
|
|
560
|
-
|
|
337
|
+
signal.throwIfAborted();
|
|
561
338
|
const session = this.#sessions.get(target);
|
|
562
339
|
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
563
340
|
const question: QuestionRecord = {
|
|
@@ -589,12 +366,35 @@ export class Broker {
|
|
|
589
366
|
id: string,
|
|
590
367
|
signal: AbortSignal,
|
|
591
368
|
): Promise<Question> {
|
|
592
|
-
|
|
369
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
370
|
+
const combined = AbortSignal.any([signal, timeout]);
|
|
371
|
+
try {
|
|
372
|
+
for (;;) {
|
|
373
|
+
signal.throwIfAborted();
|
|
374
|
+
const question = this.#state.question(chatId, id);
|
|
375
|
+
if (question.loaded) return questionView(question);
|
|
376
|
+
await this.#waitForChange(combined);
|
|
377
|
+
}
|
|
378
|
+
} catch (error) {
|
|
593
379
|
signal.throwIfAborted();
|
|
380
|
+
if (!timeout.aborted) throw error;
|
|
594
381
|
const question = this.#state.question(chatId, id);
|
|
595
|
-
this.#requireUnlocked(chatId, question.sessionId, signal);
|
|
596
382
|
if (question.loaded) return questionView(question);
|
|
597
|
-
|
|
383
|
+
if (!question.answer) {
|
|
384
|
+
await this.#state.answer(chatId, id, {
|
|
385
|
+
selections: [],
|
|
386
|
+
text: "",
|
|
387
|
+
skipped: true,
|
|
388
|
+
});
|
|
389
|
+
void this.#notify(
|
|
390
|
+
question.sessionId,
|
|
391
|
+
`Question skipped after display timeout: ${question.question}`,
|
|
392
|
+
{ event: "skipped", chatId },
|
|
393
|
+
).catch(() => {});
|
|
394
|
+
}
|
|
395
|
+
throw new Error(
|
|
396
|
+
"Question widget did not load within 10 seconds. The question was automatically skipped. Use an installed Pi interactive tool through call if an answer is needed.",
|
|
397
|
+
);
|
|
598
398
|
}
|
|
599
399
|
}
|
|
600
400
|
|
|
@@ -606,6 +406,7 @@ export class Broker {
|
|
|
606
406
|
): Promise<Question> {
|
|
607
407
|
let question = this.#state.question(chatId, id);
|
|
608
408
|
if ((loaded || answer) && !question.loaded) {
|
|
409
|
+
this.#cooldown(chatId, question.sessionId);
|
|
609
410
|
question = { ...question, loaded: true };
|
|
610
411
|
await this.#state.addQuestion(question);
|
|
611
412
|
this.#notifyChange();
|
|
@@ -637,29 +438,30 @@ export class Broker {
|
|
|
637
438
|
}
|
|
638
439
|
|
|
639
440
|
answers(chatId: string): QuestionRecord[] {
|
|
640
|
-
|
|
641
|
-
return this.#state
|
|
642
|
-
.answers(chatId)
|
|
643
|
-
.filter((question) => !this.#locks.has(question.sessionId));
|
|
441
|
+
return this.#state.answers(chatId);
|
|
644
442
|
}
|
|
645
443
|
|
|
646
444
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
647
|
-
const
|
|
445
|
+
const requested = new URL(uri);
|
|
446
|
+
const chatId = requested.searchParams.get("chatId");
|
|
447
|
+
requested.search = "";
|
|
448
|
+
const sessionId = resourceSessionId(requested.href);
|
|
449
|
+
if (chatId) this.#cooldown(chatId, sessionId);
|
|
648
450
|
await this.#waitForSession(sessionId, signal);
|
|
649
451
|
const result = await this.#request(
|
|
650
452
|
sessionId,
|
|
651
|
-
(id) => ({ type: "readResource", id, sessionId, uri }),
|
|
453
|
+
(id) => ({ type: "readResource", id, sessionId, uri: requested.href }),
|
|
652
454
|
signal,
|
|
653
455
|
);
|
|
654
|
-
if ("resource" in result)
|
|
456
|
+
if ("resource" in result) {
|
|
457
|
+
if (chatId) this.#cooldown(chatId, sessionId);
|
|
458
|
+
return { ...result.resource, uri };
|
|
459
|
+
}
|
|
655
460
|
throw new Error("Pi session returned no resource");
|
|
656
461
|
}
|
|
657
462
|
|
|
658
463
|
deliveries(chatId: string): DeliveryRecord[] {
|
|
659
|
-
|
|
660
|
-
return this.#state
|
|
661
|
-
.deliveries(chatId)
|
|
662
|
-
.filter((delivery) => !this.#locks.has(delivery.sessionId));
|
|
464
|
+
return this.#state.deliveries(chatId);
|
|
663
465
|
}
|
|
664
466
|
|
|
665
467
|
acknowledge(
|
|
@@ -675,6 +477,39 @@ export class Broker {
|
|
|
675
477
|
message: SessionMessage,
|
|
676
478
|
): Promise<void> {
|
|
677
479
|
switch (message.type) {
|
|
480
|
+
case "request": {
|
|
481
|
+
let relays = this.#relays.get(peer);
|
|
482
|
+
if (!relays) {
|
|
483
|
+
relays = new Map();
|
|
484
|
+
this.#relays.set(peer, relays);
|
|
485
|
+
}
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
relays.set(message.id, controller);
|
|
488
|
+
void this.#request(
|
|
489
|
+
message.request.sessionId,
|
|
490
|
+
(id) => ({ ...message.request, id }),
|
|
491
|
+
controller.signal,
|
|
492
|
+
)
|
|
493
|
+
.then(
|
|
494
|
+
(result) =>
|
|
495
|
+
peer.send({ ...result, type: "response", id: message.id }),
|
|
496
|
+
(error: unknown) =>
|
|
497
|
+
peer.send({
|
|
498
|
+
type: "response",
|
|
499
|
+
id: message.id,
|
|
500
|
+
error: error instanceof Error ? error.message : String(error),
|
|
501
|
+
}),
|
|
502
|
+
)
|
|
503
|
+
.finally(() => relays.delete(message.id))
|
|
504
|
+
.catch(() => {});
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case "cancelRequest":
|
|
508
|
+
this.#relays
|
|
509
|
+
.get(peer)
|
|
510
|
+
?.get(message.id)
|
|
511
|
+
?.abort(new Error("Transfer cancelled"));
|
|
512
|
+
break;
|
|
678
513
|
case "sync": {
|
|
679
514
|
const registered =
|
|
680
515
|
this.#sessions.get(message.session.id)?.peer === peer;
|
|
@@ -723,7 +558,6 @@ export class Broker {
|
|
|
723
558
|
requestId: unknown,
|
|
724
559
|
signal: AbortSignal,
|
|
725
560
|
bindRequested = false,
|
|
726
|
-
communication = false,
|
|
727
561
|
): Promise<{
|
|
728
562
|
sessionId: string;
|
|
729
563
|
selection: InitializedSession["selection"];
|
|
@@ -735,25 +569,16 @@ export class Broker {
|
|
|
735
569
|
let target = requestedId ?? boundId;
|
|
736
570
|
if (!target) {
|
|
737
571
|
const occupied = this.#state.bindingCounts();
|
|
738
|
-
target = [...this.#sessions.keys()].find(
|
|
739
|
-
(id) => !occupied.has(id) && !this.#locks.has(id),
|
|
740
|
-
);
|
|
572
|
+
target = [...this.#sessions.keys()].find((id) => !occupied.has(id));
|
|
741
573
|
}
|
|
742
|
-
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
743
574
|
if (!target) {
|
|
744
575
|
await this.#waitForChange(signal);
|
|
745
576
|
continue;
|
|
746
577
|
}
|
|
747
578
|
await this.#waitForSession(target, signal);
|
|
748
579
|
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
580
|
const initialization =
|
|
756
|
-
|
|
581
|
+
bindRequested || !this.#state.binding(chatId)
|
|
757
582
|
? await this.#join(chatId, target, requestId, signal, bindRequested)
|
|
758
583
|
: undefined;
|
|
759
584
|
return {
|
|
@@ -775,25 +600,13 @@ export class Broker {
|
|
|
775
600
|
signal: AbortSignal,
|
|
776
601
|
explicit = false,
|
|
777
602
|
): Promise<Initialization> {
|
|
778
|
-
|
|
779
|
-
const operation = this.#operations.get(signal);
|
|
780
|
-
if (operation) this.#observe(operation, sessionId);
|
|
603
|
+
signal.throwIfAborted();
|
|
781
604
|
const previous = this.#state.binding(chatId);
|
|
782
|
-
const
|
|
605
|
+
const key = JSON.stringify([chatId, sessionId]);
|
|
606
|
+
const observer = (this.#cooldowns.get(key) ?? 0) > Date.now();
|
|
607
|
+
if (!observer) this.#cooldown(chatId, sessionId);
|
|
783
608
|
await this.#state.bind(chatId, sessionId);
|
|
784
|
-
|
|
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
|
-
}
|
|
609
|
+
signal.throwIfAborted();
|
|
797
610
|
const activity = source(chatId, requestId);
|
|
798
611
|
if (previous !== sessionId) {
|
|
799
612
|
this.#notifyChange();
|
|
@@ -803,7 +616,7 @@ export class Broker {
|
|
|
803
616
|
event: "left",
|
|
804
617
|
});
|
|
805
618
|
}
|
|
806
|
-
|
|
619
|
+
signal.throwIfAborted();
|
|
807
620
|
await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
|
|
808
621
|
...activity,
|
|
809
622
|
event: "joined",
|
|
@@ -811,45 +624,16 @@ export class Broker {
|
|
|
811
624
|
});
|
|
812
625
|
return {
|
|
813
626
|
sessionId,
|
|
814
|
-
|
|
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,
|
|
627
|
+
instructions: observer ? observerInstructions : historyInstructions,
|
|
818
628
|
};
|
|
819
629
|
}
|
|
820
630
|
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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,
|
|
840
|
-
);
|
|
631
|
+
#cooldown(chatId: string, sessionId: string): void {
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
for (const [key, expires] of this.#cooldowns) {
|
|
634
|
+
if (expires <= now) this.#cooldowns.delete(key);
|
|
841
635
|
}
|
|
842
|
-
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
async #rollbackInitialization(operation: Operation): Promise<void> {
|
|
846
|
-
const initialization = operation.initialization;
|
|
847
|
-
if (!initialization?.committed) return;
|
|
848
|
-
initialization.committed = false;
|
|
849
|
-
await this.#state.setCode(
|
|
850
|
-
initialization.sessionId,
|
|
851
|
-
initialization.previousCode,
|
|
852
|
-
);
|
|
636
|
+
this.#cooldowns.set(JSON.stringify([chatId, sessionId]), now + 10_000);
|
|
853
637
|
}
|
|
854
638
|
|
|
855
639
|
async #notify(
|
|
@@ -900,12 +684,6 @@ export class Broker {
|
|
|
900
684
|
message: (id: number) => BrokerMessage,
|
|
901
685
|
signal: AbortSignal,
|
|
902
686
|
): 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
|
-
}
|
|
909
687
|
const session = this.#sessions.get(sessionId);
|
|
910
688
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
911
689
|
if (signal.aborted) throw abortError(signal);
|
|
@@ -973,6 +751,10 @@ export class Broker {
|
|
|
973
751
|
}
|
|
974
752
|
|
|
975
753
|
#removePeer(peer: JsonLinePeer<SessionMessage, BrokerMessage>): void {
|
|
754
|
+
for (const controller of this.#relays.get(peer)?.values() ?? []) {
|
|
755
|
+
controller.abort(new Error("Pi session disconnected"));
|
|
756
|
+
}
|
|
757
|
+
this.#relays.delete(peer);
|
|
976
758
|
for (const [sessionId, session] of this.#sessions) {
|
|
977
759
|
if (session.peer === peer) this.#removeSession(sessionId);
|
|
978
760
|
}
|
|
@@ -989,10 +771,6 @@ export class Broker {
|
|
|
989
771
|
}
|
|
990
772
|
}
|
|
991
773
|
|
|
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
|
-
|
|
996
774
|
function abortError(signal: AbortSignal): Error {
|
|
997
775
|
return signal.reason instanceof Error
|
|
998
776
|
? signal.reason
|
package/src/config.ts
CHANGED
|
@@ -6,7 +6,6 @@ const configSchema = z.object({
|
|
|
6
6
|
ask: z.boolean().optional(),
|
|
7
7
|
connect: z.string().min(1).optional(),
|
|
8
8
|
listen: z.union([z.boolean(), z.number().int().min(1).max(65535)]).optional(),
|
|
9
|
-
sync: z.boolean().optional(),
|
|
10
9
|
});
|
|
11
10
|
|
|
12
11
|
export async function readConfig(agentDir: string) {
|
package/src/history.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import type { Source } from "./activity.ts";
|
|
3
|
+
import type { Activity, Source } from "./activity.ts";
|
|
4
4
|
import { toolResultsContent } from "./delivery.ts";
|
|
5
5
|
import type { ProviderOutput } from "./provider.ts";
|
|
6
6
|
import { contentWithImageReferences, rememberImages } from "./resources.ts";
|
|
@@ -22,6 +22,16 @@ export const historyInput = z.object({
|
|
|
22
22
|
.min(1)
|
|
23
23
|
.optional()
|
|
24
24
|
.describe("Read entries after this entry ID, starting with the earliest"),
|
|
25
|
+
wait: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe(
|
|
29
|
+
"Wait up to 30 seconds for new entries when caught up; before reads return immediately",
|
|
30
|
+
),
|
|
31
|
+
observer: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Read work progress as an observer"),
|
|
25
35
|
});
|
|
26
36
|
|
|
27
37
|
export type HistoryRange = z.infer<typeof historyInput>;
|
|
@@ -48,7 +58,10 @@ export function historyResult(
|
|
|
48
58
|
case "custom_message":
|
|
49
59
|
return entry.customType !== "chappie.request";
|
|
50
60
|
case "custom":
|
|
51
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
entry.customType === "chappie.notice" &&
|
|
63
|
+
(entry.data as Activity | undefined)?.event !== "history"
|
|
64
|
+
);
|
|
52
65
|
case "compaction":
|
|
53
66
|
case "branch_summary":
|
|
54
67
|
return true;
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ export default async function chappie(pi: ExtensionAPI): Promise<void> {
|
|
|
34
34
|
const config = await readConfig(agentDir);
|
|
35
35
|
const session = new LocalSession(pi, agentDir, config.connect);
|
|
36
36
|
session.install();
|
|
37
|
-
pi.registerTool(
|
|
37
|
+
pi.registerTool({
|
|
38
|
+
...transfer,
|
|
39
|
+
execute: (...args) => session.transfer(...args),
|
|
40
|
+
});
|
|
38
41
|
pi.registerProvider(createChappieProvider((output) => session.start(output)));
|
|
39
42
|
}
|