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.
@@ -141,24 +141,27 @@ function createActivityReporter(
141
141
  closing ??= (async () => {
142
142
  closed = true;
143
143
  wake?.();
144
- await pump;
145
- if (!shown) return;
146
- for (let attempt = 0; ; attempt++) {
147
- try {
148
- await update(undefined);
149
- return;
150
- } catch (error) {
151
- const wait =
152
- error instanceof MessengerHttpError && error.status === 429
153
- ? (error.retryAfterMs ?? 1_000)
154
- : 1_000;
155
- // One bounded retry: removal holds the request queue.
156
- if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
157
- logger.warn("Messenger activity removal failed", { platform: destination.platform });
144
+ try {
145
+ await pump;
146
+ if (!shown) return;
147
+ for (let attempt = 0; ; attempt++) {
148
+ try {
149
+ await update(undefined);
158
150
  return;
151
+ } catch (error) {
152
+ const wait =
153
+ error instanceof MessengerHttpError && error.status === 429
154
+ ? (error.retryAfterMs ?? 1_000)
155
+ : 1_000;
156
+ if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
157
+ logger.warn("Messenger activity removal failed", { platform: destination.platform });
158
+ return;
159
+ }
160
+ await Bun.sleep(wait);
159
161
  }
160
- await Bun.sleep(wait);
161
162
  }
163
+ } finally {
164
+ adapter.releaseActivity?.(destination);
162
165
  }
163
166
  })();
164
167
  return closing;
@@ -177,6 +180,28 @@ export function messengerConversationKey(message: MessengerMessage): string {
177
180
  ]);
178
181
  }
179
182
 
183
+ /** Stable per-conversation route shared by the persisted session file and its session process. */
184
+ export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
185
+ return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
186
+ }
187
+
188
+ /**
189
+ * Discord creates a thread with the original message's ID. Reserve that conversation key before
190
+ * preparing it so follow-ups cannot overtake the request while thread creation is in flight.
191
+ */
192
+ function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
193
+ if (
194
+ message.platform === "discord" &&
195
+ adapter.prepare &&
196
+ !message.direct &&
197
+ !message.threadId &&
198
+ !message.parentChannelId
199
+ ) {
200
+ return messengerConversationKey({ ...message, threadId: message.messageId });
201
+ }
202
+ return messengerConversationKey(message);
203
+ }
204
+
180
205
  export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
181
206
  const platform = config.platforms[message.platform];
182
207
  return Boolean(
@@ -201,6 +226,8 @@ interface PendingMessage {
201
226
  adapter: MessengerAdapter;
202
227
  signal: AbortSignal;
203
228
  resolve: () => void;
229
+ /** Requests sharing a key never run at the same time. */
230
+ key: string;
204
231
  }
205
232
 
206
233
  export interface MessengerGateway {
@@ -211,6 +238,11 @@ export interface MessengerGateway {
211
238
  export interface MessengerGatewayOptions {
212
239
  /** Minimum spacing between in-place activity edits for one request. */
213
240
  activityIntervalMs?: number;
241
+ /**
242
+ * Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
243
+ * order; above 1 the reply implementation must accept concurrent turns for different conversations.
244
+ */
245
+ concurrency?: number;
214
246
  }
215
247
 
216
248
  export function createMessengerGateway(
@@ -220,6 +252,7 @@ export function createMessengerGateway(
220
252
  options: MessengerGatewayOptions = {},
221
253
  ): MessengerGateway {
222
254
  const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
255
+ const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
223
256
  const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
224
257
  if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
225
258
  for (const platform of Object.keys(config.platforms)) {
@@ -228,90 +261,139 @@ export function createMessengerGateway(
228
261
  }
229
262
  const seen = new Set<string>();
230
263
  const queue: PendingMessage[] = [];
264
+ const activeKeys = new Set<string>();
265
+ const turns = new Set<Promise<void>>();
266
+ /** Settles when the latest request of a prepared conversation has delivered its reply. */
267
+ const conversationTails = new Map<string, Promise<void>>();
231
268
  const lifetime = new AbortController();
232
- let draining: Promise<void> | undefined;
233
- let workerActive = false;
234
269
  let running = false;
235
270
  let fatalError: MessengerFatalError | undefined;
236
271
  let statusInFlight = 0;
237
272
 
238
- async function drain(): Promise<void> {
239
- while (queue.length > 0) {
240
- const pending = queue.shift();
241
- if (!pending) break;
242
- const { message, adapter, signal, resolve } = pending;
243
- let failed = false;
244
- let activity = NO_ACTIVITY;
245
- let statusChain = Promise.resolve();
246
- const status = (state: "processing" | "success" | "failure"): void => {
247
- if (!adapter.status) return;
248
- statusChain = statusChain.then(async () => {
249
- if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
250
- const timeout = new AbortController();
251
- const statusSignal = AbortSignal.any([signal, timeout.signal]);
252
- const stopped = Promise.withResolvers<void>();
253
- const onAbort = (): void => stopped.resolve();
254
- statusSignal.addEventListener("abort", onAbort, { once: true });
255
- statusInFlight++;
256
- const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
257
- void operation.then(
258
- () => {
259
- statusInFlight--;
260
- },
261
- () => {
262
- statusInFlight--;
263
- },
264
- );
265
- const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
266
- try {
267
- await Promise.race([operation, stopped.promise]);
268
- } catch {
269
- if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
270
- } finally {
271
- clearTimeout(timer);
272
- statusSignal.removeEventListener("abort", onAbort);
273
- }
273
+ async function processRequest(pending: PendingMessage): Promise<void> {
274
+ const { message, adapter, signal, resolve } = pending;
275
+ let failed = false;
276
+ let activity = NO_ACTIVITY;
277
+ let release = (): void => {};
278
+ let tail: Promise<void> | undefined;
279
+ let conversation = "";
280
+ let statusChain = Promise.resolve();
281
+ let processingStarted = false;
282
+ const status = (state: "processing" | "success" | "failure"): void => {
283
+ if (!adapter.status) return;
284
+ statusChain = statusChain.then(async () => {
285
+ if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
286
+ const timeout = new AbortController();
287
+ const statusSignal = AbortSignal.any([signal, timeout.signal]);
288
+ const stopped = Promise.withResolvers<void>();
289
+ const onAbort = (): void => stopped.resolve();
290
+ statusSignal.addEventListener("abort", onAbort, { once: true });
291
+ statusInFlight++;
292
+ const operation = Promise.resolve().then(() => {
293
+ if (state === "processing") processingStarted = true;
294
+ return adapter.status!(message, state, statusSignal);
274
295
  });
275
- };
276
- try {
277
- if (signal.aborted) continue;
278
- status("processing");
279
- const destination = (await adapter.prepare?.(message, signal)) ?? message;
280
- activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
281
- let text: string;
296
+ void operation.then(
297
+ () => {
298
+ statusInFlight--;
299
+ },
300
+ () => {
301
+ statusInFlight--;
302
+ },
303
+ );
304
+ const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
282
305
  try {
283
- text = await reply(destination, signal, activity.report);
284
- } catch (error) {
285
- failed = true;
286
- if (error instanceof MessengerFatalError) {
287
- fatalError = error;
288
- status("failure");
289
- lifetime.abort();
290
- continue;
291
- }
292
- if (signal.aborted) continue;
293
- logger.warn("Messenger agent turn failed", { platform: message.platform });
294
- text = "The request could not be completed. Please try again.";
306
+ await Promise.race([operation, stopped.promise]);
307
+ } catch {
308
+ if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
295
309
  } finally {
296
- // Retire the status line alongside reply delivery rather than delaying the reply on it.
297
- void activity.close();
310
+ clearTimeout(timer);
311
+ statusSignal.removeEventListener("abort", onAbort);
298
312
  }
299
- if (!signal.aborted && text.trim()) {
300
- // A failed send may already have delivered a chunk: never retry the entire reply.
301
- await adapter.send(destination, text, signal);
302
- }
303
- if (!signal.aborted) status(failed ? "failure" : "success");
304
- } catch {
305
- if (!signal.aborted) {
306
- logger.warn("Messenger reply delivery failed", { platform: message.platform });
313
+ });
314
+ };
315
+ try {
316
+ if (signal.aborted) return;
317
+ status("processing");
318
+ const destination = (await adapter.prepare?.(message, signal)) ?? message;
319
+ // Answer and deliver in arrival order per prepared conversation, so a follow-up in a new thread can
320
+ // neither overtake nor interleave with the reply of the request that created it.
321
+ conversation = messengerConversationKey(destination);
322
+ const previous = conversationTails.get(conversation);
323
+ const done = Promise.withResolvers<void>();
324
+ release = done.resolve;
325
+ tail = done.promise;
326
+ conversationTails.set(conversation, tail);
327
+ await previous;
328
+ activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
329
+ let text: string;
330
+ try {
331
+ text = await reply(destination, signal, activity.report);
332
+ } catch (error) {
333
+ failed = true;
334
+ if (error instanceof MessengerFatalError) {
335
+ fatalError = error;
307
336
  status("failure");
337
+ lifetime.abort();
338
+ return;
308
339
  }
340
+ if (signal.aborted) return;
341
+ logger.warn("Messenger agent turn failed", { platform: message.platform });
342
+ text = "The request could not be completed. Please try again.";
309
343
  } finally {
310
- await activity.close();
311
- resolve();
344
+ // Retire the status line alongside reply delivery rather than delaying the reply on it.
345
+ void activity.close();
346
+ }
347
+ if (!signal.aborted && text.trim()) {
348
+ // A failed send may already have delivered a chunk: never retry the entire reply.
349
+ await adapter.send(destination, text, signal);
312
350
  }
351
+ if (!signal.aborted) status(failed ? "failure" : "success");
352
+ } catch {
353
+ if (!signal.aborted) {
354
+ logger.warn("Messenger reply delivery failed", { platform: message.platform });
355
+ status("failure");
356
+ }
357
+ } finally {
358
+ await activity.close();
359
+ if (signal.aborted && processingStarted && adapter.clearStatus && statusInFlight < MAX_STATUS_IN_FLIGHT) {
360
+ statusInFlight++;
361
+ const operation = Promise.resolve().then(() =>
362
+ adapter.clearStatus!(message, AbortSignal.timeout(STATUS_TIMEOUT_MS)),
363
+ );
364
+ void operation.then(
365
+ () => statusInFlight--,
366
+ () => statusInFlight--,
367
+ );
368
+ try {
369
+ await Promise.race([operation, Bun.sleep(STATUS_TIMEOUT_MS)]);
370
+ } catch {
371
+ logger.warn("Messenger cancelled status removal failed", { platform: message.platform });
372
+ }
373
+ }
374
+ release();
375
+ if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
376
+ resolve();
377
+ }
378
+ }
379
+
380
+ /** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
381
+ function schedule(): void {
382
+ for (let index = 0; index < queue.length && turns.size < concurrency; ) {
383
+ const pending = queue[index]!;
384
+ if (activeKeys.has(pending.key)) {
385
+ index++;
386
+ continue;
387
+ }
388
+ queue.splice(index, 1);
389
+ activeKeys.add(pending.key);
390
+ const turn = processRequest(pending).finally(() => {
391
+ activeKeys.delete(pending.key);
392
+ turns.delete(turn);
393
+ schedule();
394
+ });
395
+ turns.add(turn);
313
396
  }
314
- workerActive = false;
315
397
  }
316
398
 
317
399
  function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
@@ -326,11 +408,8 @@ export function createMessengerGateway(
326
408
  if (oldest !== undefined) seen.delete(oldest);
327
409
  }
328
410
  const { promise, resolve } = Promise.withResolvers<void>();
329
- queue.push({ message, adapter, signal: effectiveSignal, resolve });
330
- if (!workerActive) {
331
- workerActive = true;
332
- draining = drain();
333
- }
411
+ queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
412
+ schedule();
334
413
  return promise;
335
414
  }
336
415
 
@@ -362,7 +441,7 @@ export function createMessengerGateway(
362
441
  } finally {
363
442
  lifetime.abort();
364
443
  await Promise.allSettled(starts);
365
- await draining;
444
+ while (turns.size > 0) await Promise.allSettled(turns);
366
445
  signal.removeEventListener("abort", abort);
367
446
  }
368
447
  },
@@ -0,0 +1,122 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { $ } from "bun";
5
+
6
+ /** A per-user background service that keeps one messenger profile's gateway running. */
7
+ export interface MessengerServicePlan {
8
+ /** Stable service name derived from the profile directory. */
9
+ label: string;
10
+ /** Profile directory: working directory and home of the run script and log. */
11
+ directory: string;
12
+ runScript: string;
13
+ logFile: string;
14
+ }
15
+
16
+ export function messengerServicePlan(directory: string, runScript: string): MessengerServicePlan {
17
+ const slug =
18
+ path
19
+ .basename(directory)
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, "-")
22
+ .replace(/^-+|-+$/g, "") || "default";
23
+ return {
24
+ label: `dev.jeopi.messenger.${slug}`,
25
+ directory,
26
+ runScript,
27
+ logFile: path.join(directory, "gateway.log"),
28
+ };
29
+ }
30
+
31
+ function xml(value: string): string {
32
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
33
+ }
34
+
35
+ /** launchd job: starts at login, restarts after a crash (throttled), logs to the profile directory. */
36
+ export function launchdPlist(plan: MessengerServicePlan): string {
37
+ return `<?xml version="1.0" encoding="UTF-8"?>
38
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39
+ <plist version="1.0">
40
+ <dict>
41
+ <key>Label</key><string>${xml(plan.label)}</string>
42
+ <key>ProgramArguments</key>
43
+ <array><string>/bin/sh</string><string>${xml(plan.runScript)}</string></array>
44
+ <key>WorkingDirectory</key><string>${xml(plan.directory)}</string>
45
+ <key>RunAtLoad</key><true/>
46
+ <key>KeepAlive</key><true/>
47
+ <key>ThrottleInterval</key><integer>30</integer>
48
+ <key>StandardOutPath</key><string>${xml(plan.logFile)}</string>
49
+ <key>StandardErrorPath</key><string>${xml(plan.logFile)}</string>
50
+ </dict>
51
+ </plist>
52
+ `;
53
+ }
54
+
55
+ /** systemd --user unit with the same lifecycle as the launchd job. */
56
+ export function systemdUnit(plan: MessengerServicePlan): string {
57
+ const quoted = (value: string): string => `"${value.replace(/(["\\])/g, "\\$1")}"`;
58
+ return `[Unit]
59
+ Description=jeopi messenger gateway (${plan.label})
60
+ After=network-online.target
61
+
62
+ [Service]
63
+ WorkingDirectory=${quoted(plan.directory)}
64
+ ExecStart=/bin/sh ${quoted(plan.runScript)}
65
+ Restart=always
66
+ RestartSec=30
67
+ StandardOutput=append:${plan.logFile}
68
+ StandardError=append:${plan.logFile}
69
+
70
+ [Install]
71
+ WantedBy=default.target
72
+ `;
73
+ }
74
+
75
+ /**
76
+ * Install (or replace) and start the service, then confirm the platform reports it running.
77
+ * Returns a one-line description of where it was installed.
78
+ */
79
+ export async function installMessengerService(
80
+ plan: MessengerServicePlan,
81
+ platform: NodeJS.Platform = process.platform,
82
+ home: string = os.homedir(),
83
+ ): Promise<string> {
84
+ if (platform === "darwin") {
85
+ const file = path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
86
+ await Bun.write(file, launchdPlist(plan));
87
+ const domain = `gui/${process.getuid?.() ?? 0}`;
88
+ await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
89
+ // bootstrap can fail briefly while a replaced job is still exiting.
90
+ let loaded = false;
91
+ for (let attempt = 0; attempt < 5 && !loaded; attempt++) {
92
+ if (attempt) await Bun.sleep(1_000);
93
+ loaded = (await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode === 0;
94
+ }
95
+ if (!loaded) throw new Error(`launchctl could not load ${file}`);
96
+ for (let attempt = 0; attempt < 20; attempt++) {
97
+ const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
98
+ if (status.exitCode === 0 && /\bstate = running\b/.test(status.text()))
99
+ return `launchd job ${plan.label} (${file})`;
100
+ await Bun.sleep(500);
101
+ }
102
+ throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
103
+ }
104
+ if (platform === "linux") {
105
+ const unit = `${plan.label}.service`;
106
+ const file = path.join(home, ".config", "systemd", "user", unit);
107
+ await fs.mkdir(path.dirname(file), { recursive: true });
108
+ await Bun.write(file, systemdUnit(plan));
109
+ const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
110
+ const enable = await $`systemctl --user enable ${unit}`.quiet().nothrow();
111
+ const restart = await $`systemctl --user restart ${unit}`.quiet().nothrow();
112
+ if (reload.exitCode !== 0 || enable.exitCode !== 0 || restart.exitCode !== 0) {
113
+ throw new Error(`systemctl --user could not start ${unit}`);
114
+ }
115
+ const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
116
+ if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
117
+ return `systemd user unit ${unit} (${file})`;
118
+ }
119
+ throw new Error(
120
+ `Background services are supported on macOS (launchd) and Linux (systemd --user); run ${plan.runScript} under your own process manager`,
121
+ );
122
+ }