jeopi-cli 16.6.13 → 16.6.17

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