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.
@@ -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
+ }
@@ -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 { messengerConversationKey } from "./gateway";
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 = new Bun.CryptoHasher("sha256")
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;
@@ -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 = (message: MessengerMessage, signal: AbortSignal) => Promise<string>;
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() {