@zetaloop/chappie 0.4.1 → 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 +35 -37
- package/package.json +1 -1
- package/src/broker.ts +106 -334
- package/src/config.ts +0 -1
- package/src/history.ts +15 -2
- package/src/index.ts +4 -1
- package/src/instructions.md +5 -7
- 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,8 +49,6 @@ interface ChangeWaiter {
|
|
|
66
49
|
}
|
|
67
50
|
|
|
68
51
|
export interface Initialization {
|
|
69
|
-
name?: string;
|
|
70
|
-
code?: string;
|
|
71
52
|
sessionId: string;
|
|
72
53
|
instructions: string;
|
|
73
54
|
}
|
|
@@ -75,7 +56,7 @@ export interface Initialization {
|
|
|
75
56
|
export interface InitializedSession extends Omit<SessionInspection, "tools"> {
|
|
76
57
|
selection: "existing" | "explicit" | "automatic";
|
|
77
58
|
initialization?: Initialization;
|
|
78
|
-
globalAgents?: string;
|
|
59
|
+
globalAgents?: { path: string };
|
|
79
60
|
inputs: SessionInput[];
|
|
80
61
|
tools: { name: string; description: string }[];
|
|
81
62
|
}
|
|
@@ -103,12 +84,12 @@ export class Broker {
|
|
|
103
84
|
readonly #sessions = new Map<string, RegisteredSession>();
|
|
104
85
|
readonly #pending = new Map<number, PendingRequest>();
|
|
105
86
|
readonly #waiters = new Set<ChangeWaiter>();
|
|
106
|
-
readonly #
|
|
107
|
-
readonly #
|
|
108
|
-
|
|
109
|
-
|
|
87
|
+
readonly #cooldowns = new Map<string, number>();
|
|
88
|
+
readonly #relays = new Map<
|
|
89
|
+
JsonLinePeer<SessionMessage, BrokerMessage>,
|
|
90
|
+
Map<number, AbortController>
|
|
91
|
+
>();
|
|
110
92
|
#ask = true;
|
|
111
|
-
#sync = false;
|
|
112
93
|
#nextRequestId = 1;
|
|
113
94
|
|
|
114
95
|
constructor(agentDir: string) {
|
|
@@ -124,20 +105,17 @@ export class Broker {
|
|
|
124
105
|
async start(): Promise<void> {
|
|
125
106
|
const config = await readConfig(this.#agentDir);
|
|
126
107
|
this.#ask = config.ask ?? true;
|
|
127
|
-
this.#sync = config.sync ?? false;
|
|
128
108
|
await this.#state.load();
|
|
129
109
|
await this.#ipc.start(config.listen ?? false);
|
|
130
110
|
}
|
|
131
111
|
|
|
132
112
|
async close(): Promise<void> {
|
|
133
113
|
const error = new Error("Chappie broker ended");
|
|
134
|
-
|
|
135
|
-
|
|
114
|
+
this.#cooldowns.clear();
|
|
115
|
+
for (const relays of this.#relays.values()) {
|
|
116
|
+
for (const controller of relays.values()) controller.abort(error);
|
|
136
117
|
}
|
|
137
|
-
this.#
|
|
138
|
-
this.#locks.clear();
|
|
139
|
-
this.#startingLocks.clear();
|
|
140
|
-
this.#syncRevisions.clear();
|
|
118
|
+
this.#relays.clear();
|
|
141
119
|
for (const [id, pending] of this.#pending) {
|
|
142
120
|
this.#finishRequest(id, pending);
|
|
143
121
|
pending.reject(error);
|
|
@@ -151,205 +129,8 @@ export class Broker {
|
|
|
151
129
|
await this.#ipc.close();
|
|
152
130
|
}
|
|
153
131
|
|
|
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,
|
|
237
|
-
requestId: unknown,
|
|
238
|
-
signal: AbortSignal,
|
|
239
|
-
) {
|
|
240
|
-
if (!this.#sync) throw new Error("Session synchronization is disabled");
|
|
241
|
-
signal.throwIfAborted();
|
|
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
|
-
);
|
|
310
|
-
}
|
|
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
|
-
};
|
|
348
|
-
}
|
|
349
|
-
|
|
350
132
|
listSessions(sessionId?: string): (SessionDescription & {
|
|
351
133
|
bindingCount: number;
|
|
352
|
-
sync?: { locked: boolean; verified: boolean };
|
|
353
134
|
})[] {
|
|
354
135
|
const counts = this.#state.bindingCounts();
|
|
355
136
|
return [...this.#sessions.values()]
|
|
@@ -357,7 +138,6 @@ export class Broker {
|
|
|
357
138
|
.map(({ description }) => ({
|
|
358
139
|
...description,
|
|
359
140
|
bindingCount: counts.get(description.id) ?? 0,
|
|
360
|
-
...(this.#sync ? { sync: this.syncState(description.id) } : {}),
|
|
361
141
|
}));
|
|
362
142
|
}
|
|
363
143
|
|
|
@@ -410,8 +190,6 @@ export class Broker {
|
|
|
410
190
|
sessionId,
|
|
411
191
|
requestId,
|
|
412
192
|
signal,
|
|
413
|
-
false,
|
|
414
|
-
true,
|
|
415
193
|
);
|
|
416
194
|
const result = await this.#request(
|
|
417
195
|
target,
|
|
@@ -425,7 +203,7 @@ export class Broker {
|
|
|
425
203
|
signal,
|
|
426
204
|
);
|
|
427
205
|
if ("message" in result) {
|
|
428
|
-
const inputs =
|
|
206
|
+
const inputs = result.inputs;
|
|
429
207
|
await this.#ackInputs(target, inputs, signal);
|
|
430
208
|
return {
|
|
431
209
|
sessionId: target,
|
|
@@ -537,10 +315,8 @@ export class Broker {
|
|
|
537
315
|
signal: AbortSignal,
|
|
538
316
|
): Promise<SessionInput[]> {
|
|
539
317
|
const target = sessionId ?? this.#state.binding(chatId);
|
|
540
|
-
if (!target || !this.#sessions.has(target)
|
|
541
|
-
return [];
|
|
318
|
+
if (!target || !this.#sessions.has(target)) return [];
|
|
542
319
|
const { inputs } = await this.#inspect(target, signal);
|
|
543
|
-
if (!this.deliveryAllowed(signal)) return [];
|
|
544
320
|
await this.#ackInputs(target, inputs, signal);
|
|
545
321
|
return inputs;
|
|
546
322
|
}
|
|
@@ -558,7 +334,7 @@ export class Broker {
|
|
|
558
334
|
requestId,
|
|
559
335
|
signal,
|
|
560
336
|
);
|
|
561
|
-
|
|
337
|
+
signal.throwIfAborted();
|
|
562
338
|
const session = this.#sessions.get(target);
|
|
563
339
|
if (!session) throw new Error(`Pi session ${target} is offline`);
|
|
564
340
|
const question: QuestionRecord = {
|
|
@@ -590,12 +366,35 @@ export class Broker {
|
|
|
590
366
|
id: string,
|
|
591
367
|
signal: AbortSignal,
|
|
592
368
|
): Promise<Question> {
|
|
593
|
-
|
|
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) {
|
|
594
379
|
signal.throwIfAborted();
|
|
380
|
+
if (!timeout.aborted) throw error;
|
|
595
381
|
const question = this.#state.question(chatId, id);
|
|
596
|
-
this.#requireUnlocked(chatId, question.sessionId, signal);
|
|
597
382
|
if (question.loaded) return questionView(question);
|
|
598
|
-
|
|
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
|
+
);
|
|
599
398
|
}
|
|
600
399
|
}
|
|
601
400
|
|
|
@@ -607,6 +406,7 @@ export class Broker {
|
|
|
607
406
|
): Promise<Question> {
|
|
608
407
|
let question = this.#state.question(chatId, id);
|
|
609
408
|
if ((loaded || answer) && !question.loaded) {
|
|
409
|
+
this.#cooldown(chatId, question.sessionId);
|
|
610
410
|
question = { ...question, loaded: true };
|
|
611
411
|
await this.#state.addQuestion(question);
|
|
612
412
|
this.#notifyChange();
|
|
@@ -638,29 +438,30 @@ export class Broker {
|
|
|
638
438
|
}
|
|
639
439
|
|
|
640
440
|
answers(chatId: string): QuestionRecord[] {
|
|
641
|
-
|
|
642
|
-
return this.#state
|
|
643
|
-
.answers(chatId)
|
|
644
|
-
.filter((question) => !this.#locks.has(question.sessionId));
|
|
441
|
+
return this.#state.answers(chatId);
|
|
645
442
|
}
|
|
646
443
|
|
|
647
444
|
async readResource(uri: string, signal: AbortSignal): Promise<ResourceData> {
|
|
648
|
-
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);
|
|
649
450
|
await this.#waitForSession(sessionId, signal);
|
|
650
451
|
const result = await this.#request(
|
|
651
452
|
sessionId,
|
|
652
|
-
(id) => ({ type: "readResource", id, sessionId, uri }),
|
|
453
|
+
(id) => ({ type: "readResource", id, sessionId, uri: requested.href }),
|
|
653
454
|
signal,
|
|
654
455
|
);
|
|
655
|
-
if ("resource" in result)
|
|
456
|
+
if ("resource" in result) {
|
|
457
|
+
if (chatId) this.#cooldown(chatId, sessionId);
|
|
458
|
+
return { ...result.resource, uri };
|
|
459
|
+
}
|
|
656
460
|
throw new Error("Pi session returned no resource");
|
|
657
461
|
}
|
|
658
462
|
|
|
659
463
|
deliveries(chatId: string): DeliveryRecord[] {
|
|
660
|
-
|
|
661
|
-
return this.#state
|
|
662
|
-
.deliveries(chatId)
|
|
663
|
-
.filter((delivery) => !this.#locks.has(delivery.sessionId));
|
|
464
|
+
return this.#state.deliveries(chatId);
|
|
664
465
|
}
|
|
665
466
|
|
|
666
467
|
acknowledge(
|
|
@@ -676,6 +477,39 @@ export class Broker {
|
|
|
676
477
|
message: SessionMessage,
|
|
677
478
|
): Promise<void> {
|
|
678
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;
|
|
679
513
|
case "sync": {
|
|
680
514
|
const registered =
|
|
681
515
|
this.#sessions.get(message.session.id)?.peer === peer;
|
|
@@ -724,7 +558,6 @@ export class Broker {
|
|
|
724
558
|
requestId: unknown,
|
|
725
559
|
signal: AbortSignal,
|
|
726
560
|
bindRequested = false,
|
|
727
|
-
communication = false,
|
|
728
561
|
): Promise<{
|
|
729
562
|
sessionId: string;
|
|
730
563
|
selection: InitializedSession["selection"];
|
|
@@ -736,25 +569,16 @@ export class Broker {
|
|
|
736
569
|
let target = requestedId ?? boundId;
|
|
737
570
|
if (!target) {
|
|
738
571
|
const occupied = this.#state.bindingCounts();
|
|
739
|
-
target = [...this.#sessions.keys()].find(
|
|
740
|
-
(id) => !occupied.has(id) && !this.#locks.has(id),
|
|
741
|
-
);
|
|
572
|
+
target = [...this.#sessions.keys()].find((id) => !occupied.has(id));
|
|
742
573
|
}
|
|
743
|
-
if (!communication) this.#requireUnlocked(chatId, target, signal);
|
|
744
574
|
if (!target) {
|
|
745
575
|
await this.#waitForChange(signal);
|
|
746
576
|
continue;
|
|
747
577
|
}
|
|
748
578
|
await this.#waitForSession(target, signal);
|
|
749
579
|
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
580
|
const initialization =
|
|
757
|
-
|
|
581
|
+
bindRequested || !this.#state.binding(chatId)
|
|
758
582
|
? await this.#join(chatId, target, requestId, signal, bindRequested)
|
|
759
583
|
: undefined;
|
|
760
584
|
return {
|
|
@@ -776,25 +600,13 @@ export class Broker {
|
|
|
776
600
|
signal: AbortSignal,
|
|
777
601
|
explicit = false,
|
|
778
602
|
): Promise<Initialization> {
|
|
779
|
-
|
|
780
|
-
const operation = this.#operations.get(signal);
|
|
781
|
-
if (operation) this.#observe(operation, sessionId);
|
|
603
|
+
signal.throwIfAborted();
|
|
782
604
|
const previous = this.#state.binding(chatId);
|
|
783
|
-
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);
|
|
784
608
|
await this.#state.bind(chatId, sessionId);
|
|
785
|
-
|
|
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
|
-
}
|
|
609
|
+
signal.throwIfAborted();
|
|
798
610
|
const activity = source(chatId, requestId);
|
|
799
611
|
if (previous !== sessionId) {
|
|
800
612
|
this.#notifyChange();
|
|
@@ -804,58 +616,24 @@ export class Broker {
|
|
|
804
616
|
event: "left",
|
|
805
617
|
});
|
|
806
618
|
}
|
|
807
|
-
|
|
619
|
+
signal.throwIfAborted();
|
|
808
620
|
await this.#notify(sessionId, `${chatLabel(activity)} joined`, {
|
|
809
621
|
...activity,
|
|
810
622
|
event: "joined",
|
|
811
623
|
initialization: explicit ? "explicit" : "implicit",
|
|
812
624
|
});
|
|
813
|
-
const name = activity.requestId?.match(/\/([^/]+)$/)?.[1];
|
|
814
|
-
const instructions = name
|
|
815
|
-
? `${historyInstructions} Use this name when coordinating through chat.`
|
|
816
|
-
: historyInstructions;
|
|
817
625
|
return {
|
|
818
626
|
sessionId,
|
|
819
|
-
|
|
820
|
-
...(code ? { code } : {}),
|
|
821
|
-
instructions: code
|
|
822
|
-
? `${instructions} Keep this code private for sync verification.`
|
|
823
|
-
: instructions,
|
|
627
|
+
instructions: observer ? observerInstructions : historyInstructions,
|
|
824
628
|
};
|
|
825
629
|
}
|
|
826
630
|
|
|
827
|
-
#
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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(
|
|
844
|
-
sessionId,
|
|
845
|
-
this.#syncRevisions.get(sessionId) ?? 0,
|
|
846
|
-
);
|
|
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);
|
|
847
635
|
}
|
|
848
|
-
|
|
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
|
-
);
|
|
636
|
+
this.#cooldowns.set(JSON.stringify([chatId, sessionId]), now + 10_000);
|
|
859
637
|
}
|
|
860
638
|
|
|
861
639
|
async #notify(
|
|
@@ -906,12 +684,6 @@ export class Broker {
|
|
|
906
684
|
message: (id: number) => BrokerMessage,
|
|
907
685
|
signal: AbortSignal,
|
|
908
686
|
): 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
|
-
}
|
|
915
687
|
const session = this.#sessions.get(sessionId);
|
|
916
688
|
if (!session) throw new Error(`Pi session ${sessionId} is offline`);
|
|
917
689
|
if (signal.aborted) throw abortError(signal);
|
|
@@ -979,6 +751,10 @@ export class Broker {
|
|
|
979
751
|
}
|
|
980
752
|
|
|
981
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);
|
|
982
758
|
for (const [sessionId, session] of this.#sessions) {
|
|
983
759
|
if (session.peer === peer) this.#removeSession(sessionId);
|
|
984
760
|
}
|
|
@@ -995,10 +771,6 @@ export class Broker {
|
|
|
995
771
|
}
|
|
996
772
|
}
|
|
997
773
|
|
|
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.`;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
774
|
function abortError(signal: AbortSignal): Error {
|
|
1003
775
|
return signal.reason instanceof Error
|
|
1004
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
|
}
|