jeopi-cli 16.6.12 → 16.6.14
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/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/dist/cli.js +2629 -2624
- package/dist/types/cli/messenger-cli.d.ts +4 -1
- package/dist/types/cli/update-cli.d.ts +9 -0
- package/dist/types/messenger/activity.d.ts +7 -0
- package/dist/types/messenger/gateway.d.ts +12 -1
- package/dist/types/messenger/session-pool.d.ts +38 -0
- package/dist/types/messenger/session-worker.d.ts +49 -0
- package/dist/types/messenger/types.d.ts +8 -1
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +22 -24
- package/src/cli/update-cli.ts +13 -3
- package/src/cli.ts +13 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +8 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/messenger/activity.ts +24 -0
- package/src/messenger/discord.ts +58 -0
- package/src/messenger/gateway.ts +297 -73
- package/src/messenger/live-session.ts +16 -2
- package/src/messenger/session-pool.ts +332 -0
- package/src/messenger/session-worker.ts +79 -0
- package/src/messenger/session.ts +8 -5
- package/src/messenger/types.ts +13 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createWorkerHandle,
|
|
4
|
+
createWorkerSubprocess,
|
|
5
|
+
resolveWorkerSpawnCmd,
|
|
6
|
+
SMOKE_TEST_TIMEOUT_MS,
|
|
7
|
+
smokeTestWorker,
|
|
8
|
+
type WorkerHandle,
|
|
9
|
+
workerEnvFromParent,
|
|
10
|
+
} from "../subprocess/worker-client";
|
|
11
|
+
import { messengerSessionRoute } from "./gateway";
|
|
12
|
+
import type { MessengerSessionInbound, MessengerSessionOutbound } from "./session-worker";
|
|
13
|
+
import type { MessengerActivityReporter, MessengerConfig, MessengerMessage, MessengerReply } from "./types";
|
|
14
|
+
|
|
15
|
+
/** Hidden CLI selector that re-enters jeopi as one conversation's session process (dispatched in `cli.ts`). */
|
|
16
|
+
export const MESSENGER_SESSION_WORKER_ARG = "__omp_worker_messenger_session";
|
|
17
|
+
|
|
18
|
+
const MAX_SESSION_PROCESSES = 8;
|
|
19
|
+
const SESSION_IDLE_MS = 10 * 60_000;
|
|
20
|
+
/** Bound on a session process flushing and disposing its session before it is killed. */
|
|
21
|
+
const SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** One conversation's jeopi session process, as the pool sees it. */
|
|
24
|
+
export interface MessengerSessionProcess {
|
|
25
|
+
send(message: MessengerSessionInbound): void;
|
|
26
|
+
onMessage(handler: (message: MessengerSessionOutbound) => void): () => void;
|
|
27
|
+
/** Settles when the process has exited for any reason. */
|
|
28
|
+
exited: Promise<unknown>;
|
|
29
|
+
kill(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Session processes start without the configured platform token variables. Not OS isolation: tools run as the same user. */
|
|
33
|
+
function sessionEnv(config: MessengerConfig): Record<string, string> {
|
|
34
|
+
const secrets = new Set<string>();
|
|
35
|
+
for (const platform of Object.values(config.platforms)) {
|
|
36
|
+
if (!platform) continue;
|
|
37
|
+
secrets.add(platform.tokenEnv);
|
|
38
|
+
if (platform.appTokenEnv) secrets.add(platform.appTokenEnv);
|
|
39
|
+
}
|
|
40
|
+
return Object.fromEntries(Object.entries(workerEnvFromParent()).filter(([key]) => !secrets.has(key)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function spawnSessionHandle(
|
|
44
|
+
env: Record<string, string>,
|
|
45
|
+
): WorkerHandle<MessengerSessionInbound, MessengerSessionOutbound> & { exited: Promise<unknown> } {
|
|
46
|
+
const spawned = createWorkerSubprocess<MessengerSessionOutbound>({
|
|
47
|
+
spawnCommand: resolveWorkerSpawnCmd(MESSENGER_SESSION_WORKER_ARG),
|
|
48
|
+
env,
|
|
49
|
+
exitLabel: "messenger session process",
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
...createWorkerHandle<MessengerSessionInbound, MessengerSessionOutbound>(spawned, message =>
|
|
53
|
+
spawned.proc.send(message),
|
|
54
|
+
),
|
|
55
|
+
exited: spawned.proc.exited,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Spawn a real session process (the pool's default). */
|
|
60
|
+
export function spawnMessengerSessionProcess(config: MessengerConfig): MessengerSessionProcess {
|
|
61
|
+
const handle = spawnSessionHandle(sessionEnv(config));
|
|
62
|
+
return {
|
|
63
|
+
send: handle.send,
|
|
64
|
+
onMessage: handler => handle.onMessage(handler),
|
|
65
|
+
exited: handle.exited,
|
|
66
|
+
kill: () => void handle.terminate(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `jeopi --smoke-test` probe: the session process loads its module graph and answers over IPC. */
|
|
71
|
+
export async function smokeTestMessengerSessionWorker({
|
|
72
|
+
timeoutMs = SMOKE_TEST_TIMEOUT_MS,
|
|
73
|
+
}: {
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
} = {}): Promise<void> {
|
|
76
|
+
await smokeTestWorker(spawnSessionHandle(workerEnvFromParent()), "messenger session process", timeoutMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MessengerSessionPoolOptions {
|
|
80
|
+
/** Resident session processes; at the limit a new conversation retires the least recently used idle one. */
|
|
81
|
+
maxProcesses?: number;
|
|
82
|
+
/** Idle time after which a conversation's process exits; its session file keeps the history. */
|
|
83
|
+
idleMs?: number;
|
|
84
|
+
/** Test seam: start one conversation's session process. */
|
|
85
|
+
spawn?: (config: MessengerConfig) => MessengerSessionProcess;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MessengerSessionPool {
|
|
89
|
+
reply: MessengerReply;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface LiveSession {
|
|
94
|
+
process: MessengerSessionProcess;
|
|
95
|
+
/** Rejects the in-flight turn when the process dies under it. */
|
|
96
|
+
failTurn?: (error: Error) => void;
|
|
97
|
+
lastUsed: number;
|
|
98
|
+
/** Its capacity slot was returned; set once. */
|
|
99
|
+
released: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Lane {
|
|
103
|
+
route: string;
|
|
104
|
+
/** Settles after this conversation's latest queued job: jobs run strictly one after another. */
|
|
105
|
+
tail: Promise<unknown>;
|
|
106
|
+
/** Turns queued or running; idle retirement waits for zero. */
|
|
107
|
+
turns: number;
|
|
108
|
+
live?: LiveSession;
|
|
109
|
+
idle?: NodeJS.Timeout;
|
|
110
|
+
/** Already chosen to free a slot, so concurrent starts pick different victims. */
|
|
111
|
+
evicting?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Give every conversation (a Discord thread or DM, a Telegram chat or topic, a Slack thread) its own jeopi
|
|
116
|
+
* session process: the first message starts it, later turns reuse it, and it exits after idling; the next
|
|
117
|
+
* message resumes the persisted session in a fresh process. Turns of one conversation run strictly in order
|
|
118
|
+
* (including retirement), while different conversations run concurrently. At most `maxProcesses` processes
|
|
119
|
+
* are resident: a new conversation waits for the least recently used idle one to retire, or for a slot.
|
|
120
|
+
*/
|
|
121
|
+
export function createMessengerSessionPool(
|
|
122
|
+
config: MessengerConfig,
|
|
123
|
+
options: MessengerSessionPoolOptions = {},
|
|
124
|
+
): MessengerSessionPool {
|
|
125
|
+
const maxProcesses = Math.max(1, options.maxProcesses ?? MAX_SESSION_PROCESSES);
|
|
126
|
+
const idleMs = Math.max(0, options.idleMs ?? SESSION_IDLE_MS);
|
|
127
|
+
const spawn = options.spawn ?? spawnMessengerSessionProcess;
|
|
128
|
+
const cwd = path.resolve(config.cwd);
|
|
129
|
+
const lanes = new Map<string, Lane>();
|
|
130
|
+
const waiters = new Set<() => void>();
|
|
131
|
+
let resident = 0;
|
|
132
|
+
let nextId = 0;
|
|
133
|
+
let closed = false;
|
|
134
|
+
let closing: Promise<void> | undefined;
|
|
135
|
+
|
|
136
|
+
const enqueue = <T>(lane: Lane, job: () => Promise<T>): Promise<T> => {
|
|
137
|
+
const run = lane.tail.then(job);
|
|
138
|
+
lane.tail = run.catch(() => {});
|
|
139
|
+
return run;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A slot freed or a conversation went idle: let waiting starts re-check capacity. */
|
|
143
|
+
const capacityChanged = (): void => {
|
|
144
|
+
for (const wake of [...waiters]) wake();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const release = (live: LiveSession): void => {
|
|
148
|
+
if (live.released) return;
|
|
149
|
+
live.released = true;
|
|
150
|
+
resident--;
|
|
151
|
+
capacityChanged();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Let the process flush and dispose its session, then kill it; the session file stays for resumption. */
|
|
155
|
+
const retire = async (lane: Lane): Promise<void> => {
|
|
156
|
+
const live = lane.live;
|
|
157
|
+
if (!live) return;
|
|
158
|
+
lane.live = undefined;
|
|
159
|
+
const id = String(++nextId);
|
|
160
|
+
const acknowledged = Promise.withResolvers<void>();
|
|
161
|
+
const unsubscribe = live.process.onMessage(event => {
|
|
162
|
+
if (event.type === "closed" && event.id === id) acknowledged.resolve();
|
|
163
|
+
});
|
|
164
|
+
const timer = setTimeout(acknowledged.resolve, SESSION_CLOSE_TIMEOUT_MS);
|
|
165
|
+
try {
|
|
166
|
+
live.process.send({ type: "close", id });
|
|
167
|
+
await Promise.race([acknowledged.promise, live.process.exited]);
|
|
168
|
+
} catch {
|
|
169
|
+
// The process is already gone; killing it below is still safe.
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
unsubscribe();
|
|
173
|
+
live.process.kill();
|
|
174
|
+
release(live);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const retireIfIdle = async (lane: Lane): Promise<void> => {
|
|
179
|
+
try {
|
|
180
|
+
if (lane.turns > 0) return;
|
|
181
|
+
await retire(lane);
|
|
182
|
+
if (lane.turns === 0 && !lane.live && lanes.get(lane.route) === lane) {
|
|
183
|
+
clearTimeout(lane.idle);
|
|
184
|
+
lanes.delete(lane.route);
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
lane.evicting = false;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const leastRecentlyUsedIdle = (): Lane | undefined => {
|
|
192
|
+
let victim: Lane | undefined;
|
|
193
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
194
|
+
for (const lane of lanes.values()) {
|
|
195
|
+
if (!lane.live || lane.turns > 0 || lane.evicting || lane.live.lastUsed >= oldest) continue;
|
|
196
|
+
oldest = lane.live.lastUsed;
|
|
197
|
+
victim = lane;
|
|
198
|
+
}
|
|
199
|
+
return victim;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const waitForCapacity = async (signal: AbortSignal): Promise<void> => {
|
|
203
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
204
|
+
const wake = (): void => resolve();
|
|
205
|
+
waiters.add(wake);
|
|
206
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
207
|
+
try {
|
|
208
|
+
await promise;
|
|
209
|
+
} finally {
|
|
210
|
+
waiters.delete(wake);
|
|
211
|
+
signal.removeEventListener("abort", wake);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Start a conversation's process without exceeding `maxProcesses`; the check and spawn are atomic. */
|
|
216
|
+
const start = async (lane: Lane, signal: AbortSignal): Promise<LiveSession> => {
|
|
217
|
+
for (;;) {
|
|
218
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
219
|
+
if (resident < maxProcesses) break;
|
|
220
|
+
const victim = leastRecentlyUsedIdle();
|
|
221
|
+
if (victim) {
|
|
222
|
+
victim.evicting = true;
|
|
223
|
+
await enqueue(victim, () => retireIfIdle(victim));
|
|
224
|
+
} else {
|
|
225
|
+
await waitForCapacity(signal);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const child = spawn(config);
|
|
229
|
+
resident++;
|
|
230
|
+
const live: LiveSession = { process: child, lastUsed: Date.now(), released: false };
|
|
231
|
+
lane.live = live;
|
|
232
|
+
const onExit = (): void => {
|
|
233
|
+
live.failTurn?.(new Error("Messenger session process exited"));
|
|
234
|
+
if (lane.live === live) lane.live = undefined;
|
|
235
|
+
release(live);
|
|
236
|
+
};
|
|
237
|
+
void child.exited.then(onExit, onExit);
|
|
238
|
+
child.send({ type: "init", id: String(++nextId), config });
|
|
239
|
+
return live;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const runTurn = async (
|
|
243
|
+
lane: Lane,
|
|
244
|
+
message: MessengerMessage,
|
|
245
|
+
signal: AbortSignal,
|
|
246
|
+
onActivity: MessengerActivityReporter | undefined,
|
|
247
|
+
): Promise<string> => {
|
|
248
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
249
|
+
const id = String(++nextId);
|
|
250
|
+
const result = Promise.withResolvers<string>();
|
|
251
|
+
// Abort, IPC events and process exit may each settle it, possibly after the turn already failed elsewhere.
|
|
252
|
+
result.promise.catch(() => {});
|
|
253
|
+
let live = lane.live;
|
|
254
|
+
let unsubscribe: (() => void) | undefined;
|
|
255
|
+
const onAbort = (): void => {
|
|
256
|
+
try {
|
|
257
|
+
live?.process.send({ type: "abort", id });
|
|
258
|
+
} catch {
|
|
259
|
+
// Already gone: retirement below reaps it.
|
|
260
|
+
}
|
|
261
|
+
result.reject(new Error("Messenger request aborted"));
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
live ??= await start(lane, signal);
|
|
265
|
+
const current = live;
|
|
266
|
+
current.failTurn = result.reject;
|
|
267
|
+
unsubscribe = current.process.onMessage(event => {
|
|
268
|
+
if (event.id !== id) return;
|
|
269
|
+
if (event.type === "activity") onActivity?.(event.text);
|
|
270
|
+
else if (event.type === "reply") result.resolve(event.text);
|
|
271
|
+
else if (event.type === "failed") result.reject(new Error("Messenger session turn failed"));
|
|
272
|
+
});
|
|
273
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
try {
|
|
276
|
+
current.process.send({ type: "turn", id, message });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
result.reject(error);
|
|
279
|
+
}
|
|
280
|
+
return await result.promise;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// A failed, aborted or crashed turn may leave its session unusable: the next turn resumes the
|
|
283
|
+
// persisted session in a fresh process, after this one has flushed.
|
|
284
|
+
await retire(lane);
|
|
285
|
+
throw error;
|
|
286
|
+
} finally {
|
|
287
|
+
signal.removeEventListener("abort", onAbort);
|
|
288
|
+
unsubscribe?.();
|
|
289
|
+
if (live) {
|
|
290
|
+
live.failTurn = undefined;
|
|
291
|
+
live.lastUsed = Date.now();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
297
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
298
|
+
const route = messengerSessionRoute(cwd, message);
|
|
299
|
+
let lane = lanes.get(route);
|
|
300
|
+
if (!lane) {
|
|
301
|
+
lane = { route, tail: Promise.resolve(), turns: 0 };
|
|
302
|
+
lanes.set(route, lane);
|
|
303
|
+
}
|
|
304
|
+
const current = lane;
|
|
305
|
+
clearTimeout(current.idle);
|
|
306
|
+
current.turns++;
|
|
307
|
+
try {
|
|
308
|
+
return await enqueue(current, () => runTurn(current, message, signal, onActivity));
|
|
309
|
+
} finally {
|
|
310
|
+
current.turns--;
|
|
311
|
+
if (current.turns === 0 && !closed) {
|
|
312
|
+
current.idle = setTimeout(() => void enqueue(current, () => retireIfIdle(current)), idleMs);
|
|
313
|
+
current.idle.unref?.();
|
|
314
|
+
capacityChanged();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const close = (): Promise<void> => {
|
|
320
|
+
closing ??= (async () => {
|
|
321
|
+
closed = true;
|
|
322
|
+
capacityChanged();
|
|
323
|
+
const all = [...lanes.values()];
|
|
324
|
+
lanes.clear();
|
|
325
|
+
for (const lane of all) clearTimeout(lane.idle);
|
|
326
|
+
await Promise.allSettled(all.map(retire));
|
|
327
|
+
})();
|
|
328
|
+
return closing;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { reply, close };
|
|
332
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { logger } from "jeopi-utils";
|
|
2
|
+
import { createMessengerSessionBridge, type MessengerSessionBridge } from "./session";
|
|
3
|
+
import type { MessengerConfig, MessengerMessage } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Gateway pool → session process. */
|
|
6
|
+
export type MessengerSessionInbound =
|
|
7
|
+
| { type: "ping"; id: string }
|
|
8
|
+
| { type: "init"; id: string; config: MessengerConfig }
|
|
9
|
+
| { type: "turn"; id: string; message: MessengerMessage }
|
|
10
|
+
| { type: "abort"; id: string }
|
|
11
|
+
| { type: "close"; id: string };
|
|
12
|
+
|
|
13
|
+
/** Session process → gateway pool. Failures carry no detail: tool output and provider errors stay here. */
|
|
14
|
+
export type MessengerSessionOutbound =
|
|
15
|
+
| { type: "pong"; id: string }
|
|
16
|
+
| { type: "activity"; id: string; text: string }
|
|
17
|
+
| { type: "reply"; id: string; text: string }
|
|
18
|
+
| { type: "failed"; id: string }
|
|
19
|
+
| { type: "closed"; id: string };
|
|
20
|
+
|
|
21
|
+
export interface MessengerSessionTransport {
|
|
22
|
+
send(message: MessengerSessionOutbound): void;
|
|
23
|
+
onMessage(handler: (message: MessengerSessionInbound) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Serve one conversation's isolated AgentSession inside its own session process. The pool serializes turns, so
|
|
28
|
+
* the bridge sees one turn at a time and keeps its tool isolation and persisted history unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function startMessengerSessionWorker(
|
|
31
|
+
transport: MessengerSessionTransport,
|
|
32
|
+
createBridge: typeof createMessengerSessionBridge = createMessengerSessionBridge,
|
|
33
|
+
): void {
|
|
34
|
+
let bridge: MessengerSessionBridge | undefined;
|
|
35
|
+
const turns = new Map<string, AbortController>();
|
|
36
|
+
transport.onMessage(message => {
|
|
37
|
+
switch (message.type) {
|
|
38
|
+
case "ping":
|
|
39
|
+
transport.send({ type: "pong", id: message.id });
|
|
40
|
+
return;
|
|
41
|
+
case "init":
|
|
42
|
+
if (bridge) return;
|
|
43
|
+
try {
|
|
44
|
+
bridge = createBridge(message.config);
|
|
45
|
+
} catch {
|
|
46
|
+
logger.warn("Messenger session process could not create its session");
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
case "turn": {
|
|
50
|
+
const current = bridge;
|
|
51
|
+
if (!current) {
|
|
52
|
+
transport.send({ type: "failed", id: message.id });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
turns.set(message.id, controller);
|
|
57
|
+
void current
|
|
58
|
+
.reply(message.message, controller.signal, text =>
|
|
59
|
+
transport.send({ type: "activity", id: message.id, text }),
|
|
60
|
+
)
|
|
61
|
+
.then(
|
|
62
|
+
text => transport.send({ type: "reply", id: message.id, text }),
|
|
63
|
+
() => transport.send({ type: "failed", id: message.id }),
|
|
64
|
+
)
|
|
65
|
+
.finally(() => turns.delete(message.id));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
case "abort":
|
|
69
|
+
turns.get(message.id)?.abort();
|
|
70
|
+
return;
|
|
71
|
+
case "close":
|
|
72
|
+
for (const controller of turns.values()) controller.abort();
|
|
73
|
+
void (bridge?.close() ?? Promise.resolve())
|
|
74
|
+
.catch(() => logger.warn("Messenger session process shutdown failed"))
|
|
75
|
+
.finally(() => transport.send({ type: "closed", id: message.id }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
package/src/messenger/session.ts
CHANGED
|
@@ -10,8 +10,9 @@ import type { AgentSession } from "../session/agent-session";
|
|
|
10
10
|
import type { AuthStorage } from "../session/auth-storage";
|
|
11
11
|
import { USER_INTERRUPT_LABEL } from "../session/messages";
|
|
12
12
|
import { SessionManager } from "../session/session-manager";
|
|
13
|
+
import { createMessengerActivityTracker } from "./activity";
|
|
13
14
|
import { messengerArtifactPrompt } from "./artifacts";
|
|
14
|
-
import {
|
|
15
|
+
import { messengerSessionRoute } from "./gateway";
|
|
15
16
|
import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
|
|
16
17
|
|
|
17
18
|
const SUPPORTED_TOOLS: Record<string, true> = {
|
|
@@ -123,12 +124,13 @@ export function createMessengerSessionBridge(
|
|
|
123
124
|
activeRoute = route;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
const reply: MessengerReply = async (message, signal) => {
|
|
127
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
127
128
|
if (closed || signal.aborted) throw new Error("Messenger session is closed");
|
|
128
129
|
if (activeTurn) throw new Error("Messenger turns must be serialized");
|
|
129
130
|
const finished = Promise.withResolvers<void>();
|
|
130
131
|
activeTurn = finished.promise;
|
|
131
132
|
let aborting: Promise<void> | undefined;
|
|
133
|
+
let unsubscribe: (() => void) | undefined;
|
|
132
134
|
const onAbort = (): void => {
|
|
133
135
|
if (session && !aborting) {
|
|
134
136
|
aborting = session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
@@ -144,9 +146,7 @@ export function createMessengerSessionBridge(
|
|
|
144
146
|
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
145
147
|
throw new Error("Invalid messenger session directory");
|
|
146
148
|
await fs.chmod(sessionDir, 0o700);
|
|
147
|
-
const route =
|
|
148
|
-
.update(JSON.stringify([cwd, messengerConversationKey(message)]))
|
|
149
|
-
.digest("hex");
|
|
149
|
+
const route = messengerSessionRoute(cwd, message);
|
|
150
150
|
// Deterministic filenames are the route map; missing files are created atomically by SessionManager.
|
|
151
151
|
const file = path.join(sessionDir, `${route}.jsonl`);
|
|
152
152
|
const stat = await fs.lstat(file).catch((error: unknown) => {
|
|
@@ -182,6 +182,8 @@ export function createMessengerSessionBridge(
|
|
|
182
182
|
const guidance = await messengerArtifactPrompt(artifactRoot, message, signal);
|
|
183
183
|
if (guidance) prompt += `\n\n${guidance}`;
|
|
184
184
|
}
|
|
185
|
+
// Turns are serialized, so every event until idle belongs to this request.
|
|
186
|
+
if (onActivity) unsubscribe = current.subscribe(createMessengerActivityTracker(onActivity));
|
|
185
187
|
const dispatched = await current.prompt(prompt, { expandPromptTemplates: false });
|
|
186
188
|
await current.waitForIdle();
|
|
187
189
|
await current.sessionManager.flush();
|
|
@@ -197,6 +199,7 @@ export function createMessengerSessionBridge(
|
|
|
197
199
|
if (!text.trim()) throw new Error("Messenger response is empty");
|
|
198
200
|
return text;
|
|
199
201
|
} finally {
|
|
202
|
+
unsubscribe?.();
|
|
200
203
|
signal.removeEventListener("abort", onAbort);
|
|
201
204
|
await aborting?.catch(() => {});
|
|
202
205
|
activeTurn = undefined;
|
package/src/messenger/types.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface MessengerMessage {
|
|
|
14
14
|
attachments?: { url: string; filename: string; size: number }[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Report a short, human-readable description of the work in progress. */
|
|
18
|
+
export type MessengerActivityReporter = (text: string) => void;
|
|
19
|
+
|
|
17
20
|
export interface MessengerAdapter {
|
|
18
21
|
platform: MessengerPlatform;
|
|
19
22
|
start(onMessage: (message: MessengerMessage) => Promise<void>, signal: AbortSignal): Promise<void>;
|
|
@@ -24,6 +27,11 @@ export interface MessengerAdapter {
|
|
|
24
27
|
status: "processing" | "success" | "failure",
|
|
25
28
|
signal?: AbortSignal,
|
|
26
29
|
): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Show `text` (one sanitized line) as the request's single in-progress status, editing it in place on
|
|
32
|
+
* later calls; `undefined` removes it. The gateway serializes and throttles calls per request.
|
|
33
|
+
*/
|
|
34
|
+
activity?(message: MessengerMessage, text: string | undefined, signal?: AbortSignal): Promise<void>;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export interface MessengerPlatformConfig {
|
|
@@ -47,7 +55,11 @@ export interface MessengerTransportDeps {
|
|
|
47
55
|
WebSocket?: typeof WebSocket;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
export type MessengerReply = (
|
|
58
|
+
export type MessengerReply = (
|
|
59
|
+
message: MessengerMessage,
|
|
60
|
+
signal: AbortSignal,
|
|
61
|
+
onActivity?: MessengerActivityReporter,
|
|
62
|
+
) => Promise<string>;
|
|
51
63
|
/** A partially initialized SDK must not be retried inside a live gateway. */
|
|
52
64
|
export class MessengerFatalError extends Error {
|
|
53
65
|
constructor() {
|