jeopi-cli 16.6.11 → 16.6.13

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.
@@ -1,17 +1,177 @@
1
1
  import { logger } from "jeopi-utils";
2
- import type { MessengerAdapter, MessengerConfig, MessengerMessage, MessengerReply } from "./types";
2
+ import { MessengerHttpError } from "./http";
3
+ import type {
4
+ MessengerActivityReporter,
5
+ MessengerAdapter,
6
+ MessengerConfig,
7
+ MessengerMessage,
8
+ MessengerReply,
9
+ } from "./types";
3
10
  import { MessengerFatalError } from "./types";
4
11
 
5
12
  const MAX_MESSAGE_LENGTH = 32_000;
6
13
  const MAX_QUEUED_MESSAGES = 100;
7
14
  const MAX_SEEN_MESSAGES = 10_000;
15
+ const STATUS_TIMEOUT_MS = 1000;
16
+ const MAX_STATUS_IN_FLIGHT = 8;
17
+ const MAX_ACTIVITY_LENGTH = 120;
18
+ // Status edits are cosmetic: keep them sparse so they never compete with reply delivery for rate limits.
19
+ const ACTIVITY_INTERVAL_MS = 2_000;
20
+ const ACTIVITY_TIMEOUT_MS = 3_000;
21
+
22
+ /** Collapse to one bounded line: drop invisible format characters, fold control and whitespace runs. */
23
+ function activityLine(text: string): string {
24
+ const line = text
25
+ .replace(/\p{Cf}/gu, "")
26
+ .replace(/[\p{Cc}\p{Z}\s]+/gu, " ")
27
+ .trim();
28
+ if (line.length <= MAX_ACTIVITY_LENGTH) return line;
29
+ let end = MAX_ACTIVITY_LENGTH - 1;
30
+ const last = line.charCodeAt(end - 1);
31
+ if (last >= 0xd800 && last <= 0xdbff) end--;
32
+ return `${line.slice(0, end).trimEnd()}…`;
33
+ }
34
+
35
+ interface ActivityReporter {
36
+ report: MessengerActivityReporter;
37
+ /** Stop updates and remove any shown line; idempotent and never rejects. */
38
+ close(): Promise<void>;
39
+ }
40
+
41
+ const NO_ACTIVITY: ActivityReporter = { report() {}, close: () => Promise.resolve() };
42
+
43
+ /**
44
+ * Bound a cosmetic adapter call: a stalled provider request must not hold up the request queue. A timeout
45
+ * rejects so callers can retry; cancellation through `signal` resolves because the request is ending.
46
+ */
47
+ async function boundedActivity(run: (signal: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<void> {
48
+ const timeout = new AbortController();
49
+ const callSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
50
+ const stopped = Promise.withResolvers<void>();
51
+ const onStop = (): void => {
52
+ if (timeout.signal.aborted) stopped.reject(new Error("Messenger activity update timed out"));
53
+ else stopped.resolve();
54
+ };
55
+ callSignal.addEventListener("abort", onStop, { once: true });
56
+ const timer = setTimeout(() => timeout.abort(), ACTIVITY_TIMEOUT_MS);
57
+ try {
58
+ await Promise.race([Promise.resolve().then(() => run(callSignal)), stopped.promise]);
59
+ } finally {
60
+ clearTimeout(timer);
61
+ callSignal.removeEventListener("abort", onStop);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Coalesce a request's activity into one status line: show the first report at once, then at most the
67
+ * latest line per interval. Removal skips the request signal so an aborted request still retires its line.
68
+ */
69
+ function createActivityReporter(
70
+ adapter: MessengerAdapter,
71
+ destination: MessengerMessage,
72
+ signal: AbortSignal,
73
+ intervalMs: number,
74
+ ): ActivityReporter {
75
+ const activity = adapter.activity;
76
+ if (!activity) return NO_ACTIVITY;
77
+ let latest: string | undefined;
78
+ let sent: string | undefined;
79
+ let shown = false;
80
+ let closed = false;
81
+ let pumping = false;
82
+ let pump = Promise.resolve();
83
+ let nextAt = 0;
84
+ let wake: (() => void) | undefined;
85
+ let closing: Promise<void> | undefined;
86
+ const update = (text: string | undefined, callSignal?: AbortSignal): Promise<void> =>
87
+ boundedActivity(bounded => activity.call(adapter, destination, text, bounded), callSignal);
88
+ const pause = async (ms: number): Promise<void> => {
89
+ const { promise, resolve } = Promise.withResolvers<void>();
90
+ const timer = setTimeout(resolve, ms);
91
+ wake = () => {
92
+ clearTimeout(timer);
93
+ resolve();
94
+ };
95
+ try {
96
+ await promise;
97
+ } finally {
98
+ wake = undefined;
99
+ }
100
+ };
101
+ const run = async (): Promise<void> => {
102
+ try {
103
+ while (!closed && !signal.aborted && latest !== sent) {
104
+ const delay = nextAt - Date.now();
105
+ if (delay > 0) {
106
+ await pause(delay);
107
+ continue;
108
+ }
109
+ const text = latest;
110
+ sent = text;
111
+ shown = true;
112
+ let spacing = intervalMs;
113
+ try {
114
+ await update(text, signal);
115
+ } catch (error) {
116
+ if (error instanceof MessengerHttpError && error.status === 429) {
117
+ // Keep the line pending: a rate-limited update is retried unless a newer line replaces it.
118
+ sent = undefined;
119
+ spacing = Math.max(spacing, error.retryAfterMs ?? 1_000);
120
+ } else if (!signal.aborted) {
121
+ logger.warn("Messenger activity update failed", { platform: destination.platform });
122
+ }
123
+ }
124
+ nextAt = Date.now() + spacing;
125
+ }
126
+ } finally {
127
+ pumping = false;
128
+ }
129
+ };
130
+ return {
131
+ report(text) {
132
+ if (closed || signal.aborted) return;
133
+ const line = activityLine(text);
134
+ if (!line) return;
135
+ latest = line;
136
+ if (pumping) return;
137
+ pumping = true;
138
+ pump = run();
139
+ },
140
+ close() {
141
+ closing ??= (async () => {
142
+ closed = true;
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 });
158
+ return;
159
+ }
160
+ await Bun.sleep(wait);
161
+ }
162
+ }
163
+ })();
164
+ return closing;
165
+ },
166
+ };
167
+ }
8
168
 
9
169
  /** Include the sender even in shared threads: remote histories must not cross users. */
10
170
  export function messengerConversationKey(message: MessengerMessage): string {
11
171
  return JSON.stringify([
12
172
  message.platform,
13
173
  message.accountId,
14
- message.channelId,
174
+ message.parentChannelId ?? message.channelId,
15
175
  message.threadId ?? "",
16
176
  message.senderId,
17
177
  ]);
@@ -25,11 +185,14 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
25
185
  message.channelId &&
26
186
  message.senderId &&
27
187
  message.messageId &&
28
- message.text.trim() &&
188
+ (message.text.trim() ||
189
+ (message.platform === "discord" && config.platforms.discord?.artifactDir && message.attachments?.length)) &&
29
190
  message.text.length <= MAX_MESSAGE_LENGTH &&
30
191
  platform.allowedUserIds.includes(message.senderId) &&
31
- (!platform.allowedChannelIds || platform.allowedChannelIds.includes(message.channelId)) &&
32
- (message.direct || message.mentioned),
192
+ (!platform.allowedChannelIds ||
193
+ platform.allowedChannelIds.includes(message.channelId) ||
194
+ (Boolean(message.parentChannelId) && platform.allowedChannelIds.includes(message.parentChannelId!))) &&
195
+ (message.direct || message.mentioned || (message.platform === "discord" && Boolean(message.parentChannelId))),
33
196
  );
34
197
  }
35
198
 
@@ -45,11 +208,18 @@ export interface MessengerGateway {
45
208
  handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
46
209
  }
47
210
 
211
+ export interface MessengerGatewayOptions {
212
+ /** Minimum spacing between in-place activity edits for one request. */
213
+ activityIntervalMs?: number;
214
+ }
215
+
48
216
  export function createMessengerGateway(
49
217
  config: MessengerConfig,
50
218
  adapters: MessengerAdapter[],
51
219
  reply: MessengerReply,
220
+ options: MessengerGatewayOptions = {},
52
221
  ): MessengerGateway {
222
+ const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
53
223
  const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
54
224
  if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
55
225
  for (const platform of Object.keys(config.platforms)) {
@@ -63,34 +233,81 @@ export function createMessengerGateway(
63
233
  let workerActive = false;
64
234
  let running = false;
65
235
  let fatalError: MessengerFatalError | undefined;
236
+ let statusInFlight = 0;
66
237
 
67
238
  async function drain(): Promise<void> {
68
239
  while (queue.length > 0) {
69
240
  const pending = queue.shift();
70
241
  if (!pending) break;
71
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
+ }
274
+ });
275
+ };
72
276
  try {
73
277
  if (signal.aborted) continue;
278
+ status("processing");
279
+ const destination = (await adapter.prepare?.(message, signal)) ?? message;
280
+ activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
74
281
  let text: string;
75
282
  try {
76
- text = await reply(message, signal);
283
+ text = await reply(destination, signal, activity.report);
77
284
  } catch (error) {
285
+ failed = true;
78
286
  if (error instanceof MessengerFatalError) {
79
287
  fatalError = error;
288
+ status("failure");
80
289
  lifetime.abort();
81
290
  continue;
82
291
  }
83
292
  if (signal.aborted) continue;
84
293
  logger.warn("Messenger agent turn failed", { platform: message.platform });
85
294
  text = "The request could not be completed. Please try again.";
295
+ } finally {
296
+ // Retire the status line alongside reply delivery rather than delaying the reply on it.
297
+ void activity.close();
86
298
  }
87
299
  if (!signal.aborted && text.trim()) {
88
300
  // A failed send may already have delivered a chunk: never retry the entire reply.
89
- await adapter.send(message, text, signal);
301
+ await adapter.send(destination, text, signal);
90
302
  }
303
+ if (!signal.aborted) status(failed ? "failure" : "success");
91
304
  } catch {
92
- if (!signal.aborted) logger.warn("Messenger reply delivery failed", { platform: message.platform });
305
+ if (!signal.aborted) {
306
+ logger.warn("Messenger reply delivery failed", { platform: message.platform });
307
+ status("failure");
308
+ }
93
309
  } finally {
310
+ await activity.close();
94
311
  resolve();
95
312
  }
96
313
  }
@@ -65,6 +65,7 @@ export async function messengerFetchJson(
65
65
  if (!response.bodyUsed) await response.body?.cancel();
66
66
  throw new MessengerHttpError(response.status, retryAfterMs);
67
67
  }
68
+ if (response.status === 204) return undefined;
68
69
  return await response.json();
69
70
  } catch (error) {
70
71
  if (error instanceof MessengerHttpError) throw error;
@@ -1,5 +1,7 @@
1
1
  import type { AssistantMessage, UserMessage } from "jeopi-ai";
2
2
  import type { InteractiveModeContext } from "../modes/types";
3
+ import { createMessengerActivityTracker } from "./activity";
4
+ import { messengerArtifactPrompt } from "./artifacts";
3
5
  import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
4
6
  import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
5
7
  import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
@@ -21,12 +23,18 @@ export interface LiveDiscordBridgeDependencies {
21
23
  export interface LiveDiscordBridge {
22
24
  readonly gateway: MessengerGateway;
23
25
  readonly active: boolean;
26
+ readonly relayDestination: string | undefined;
27
+ setRelayDestination(channelId: string | undefined): void;
28
+ withLocalTurn<T>(
29
+ text: string,
30
+ submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
31
+ ): Promise<T>;
24
32
  run(signal?: AbortSignal): Promise<void>;
25
33
  close(): Promise<void>;
26
34
  }
27
35
 
28
36
  interface TurnCapture {
29
- readonly promptText: string;
37
+ promptText: string;
30
38
  promptSeen: boolean;
31
39
  dispatched: boolean;
32
40
  hostSettled: boolean;
@@ -143,8 +151,13 @@ export function createLiveDiscordBridge(
143
151
  let currentTurn: TurnCapture | undefined;
144
152
  let pollTimer: NodeJS.Timeout;
145
153
  let bridgeRef: LiveDiscordBridge | undefined;
154
+ let relayDestination: string | undefined;
155
+ let relayLifetime = new AbortController();
156
+ let relaySend = Promise.resolve();
157
+ let localCapture: object | undefined;
146
158
  let close: () => Promise<void>;
147
- const reply: MessengerReply = async (message, signal) => {
159
+ let revoke: () => Promise<void>;
160
+ const reply: MessengerReply = async (message, signal, onActivity) => {
148
161
  if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
149
162
  if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
150
163
  if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
@@ -156,10 +169,11 @@ export function createLiveDiscordBridge(
156
169
  attachedSession.isBashRunning ||
157
170
  attachedSession.isEvalRunning ||
158
171
  attachedSession.hasPostPromptWork ||
172
+ localCapture ||
159
173
  currentTurn
160
174
  )
161
175
  return LIVE_BUSY_MESSAGE;
162
- const promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
176
+ let promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
163
177
  const capture: TurnCapture = {
164
178
  promptText,
165
179
  promptSeen: false,
@@ -173,8 +187,17 @@ export function createLiveDiscordBridge(
173
187
  ctx.showStatus(`Discord request from ${message.senderId}`);
174
188
  } catch {}
175
189
  const operationSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
190
+ const onAbort = (): void => {
191
+ void revoke();
192
+ };
193
+ operationSignal.addEventListener("abort", onAbort, { once: true });
194
+ const track = onActivity && createMessengerActivityTracker(onActivity);
176
195
  const unsubscribe = attachedSession.subscribe(event => {
177
- if (event.type !== "message_end") return;
196
+ if (event.type !== "message_end") {
197
+ // Report only this Discord turn: its prompt event precedes the turn's tool and retry events.
198
+ if (capture.promptSeen && !capture.conflicted) track?.(event);
199
+ return;
200
+ }
178
201
  if (event.message.role === "user") {
179
202
  const text = userMessageText(event.message);
180
203
  if (!capture.promptSeen && text === capture.promptText) {
@@ -187,6 +210,21 @@ export function createLiveDiscordBridge(
187
210
  if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
188
211
  });
189
212
  try {
213
+ const guidance = await messengerArtifactPrompt(
214
+ scopedConfig.platforms.discord?.artifactDir,
215
+ message,
216
+ operationSignal,
217
+ );
218
+ if (guidance) promptText += `\n\n${guidance}`;
219
+ capture.promptText = promptText;
220
+ if (
221
+ attachedSession.isStreaming ||
222
+ attachedSession.isCompacting ||
223
+ attachedSession.isBashRunning ||
224
+ attachedSession.isEvalRunning ||
225
+ localCapture
226
+ )
227
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
190
228
  if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
191
229
  throw new Error(LIVE_UNAVAILABLE_MESSAGE);
192
230
  const promptPromise = attachedSession.prompt(promptText, {
@@ -222,6 +260,7 @@ export function createLiveDiscordBridge(
222
260
  if (!response.trim()) throw new Error("Live Discord response unavailable");
223
261
  return response;
224
262
  } finally {
263
+ operationSignal.removeEventListener("abort", onAbort);
225
264
  unsubscribe();
226
265
  if (currentTurn === capture) currentTurn = undefined;
227
266
  }
@@ -232,6 +271,7 @@ export function createLiveDiscordBridge(
232
271
  const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
233
272
  const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
234
273
  const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
274
+ const activity = adapter.activity;
235
275
  const guardedAdapter: MessengerAdapter = {
236
276
  ...adapter,
237
277
  send: async (message, text, signal) => {
@@ -244,37 +284,187 @@ export function createLiveDiscordBridge(
244
284
  }
245
285
  await adapter.send(message, text, sendSignal);
246
286
  },
287
+ // New status text requires the attached session; removal must still retire a shown line after close.
288
+ activity:
289
+ activity &&
290
+ (async (message, text, signal) => {
291
+ if (text !== undefined && (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))) return;
292
+ await activity.call(adapter, message, text, signal);
293
+ }),
247
294
  };
248
- const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
249
- close = async (): Promise<void> => {
250
- if (closed) {
251
- await runPromise?.catch(() => {});
252
- return;
295
+ const setRelayDestination = (channelId: string | undefined): void => {
296
+ if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))
297
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
298
+ if (channelId !== undefined && !discordConfig.allowedChannelIds?.includes(channelId)) {
299
+ throw new Error("Discord relay destination must be an explicitly allowed channel ID");
300
+ }
301
+ relayLifetime.abort();
302
+ relayLifetime = new AbortController();
303
+ relayDestination = channelId;
304
+ };
305
+ const withLocalTurn = async <T>(
306
+ _text: string,
307
+ submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
308
+ ): Promise<T> => {
309
+ const destination = relayDestination;
310
+ if (!destination || closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return submit(() => {});
311
+ if (
312
+ currentTurn ||
313
+ localCapture ||
314
+ attachedSession.isStreaming ||
315
+ attachedSession.isCompacting ||
316
+ attachedSession.isRetrying ||
317
+ attachedSession.isGeneratingHandoff ||
318
+ attachedSession.isBashRunning ||
319
+ attachedSession.isEvalRunning ||
320
+ attachedSession.hasPostPromptWork
321
+ ) {
322
+ try {
323
+ ctx.showWarning("Discord relay skipped: another session operation is in progress.");
324
+ } catch {}
325
+ return submit(() => {});
253
326
  }
327
+ const signal = AbortSignal.any([lifetime.signal, relayLifetime.signal]);
328
+ const capture = {};
329
+ localCapture = capture;
330
+ let promptSeen = false;
331
+ let preparedMessage: UserMessage | undefined;
332
+ let conflicted = false;
333
+ let response: AssistantMessage | undefined;
334
+ const unsubscribe = attachedSession.subscribe(event => {
335
+ if (event.type !== "message_end") return;
336
+ if (event.message.role === "user") {
337
+ if (!promptSeen && preparedMessage === event.message) promptSeen = true;
338
+ else conflicted = true;
339
+ } else if (isCompetingMessage(event.message)) {
340
+ conflicted = true;
341
+ } else if (promptSeen && event.message.role === "assistant") {
342
+ response = event.message;
343
+ }
344
+ });
345
+ let cleaned = false;
346
+ let submitting = true;
347
+ const cleanup = (): void => {
348
+ if (cleaned) return;
349
+ cleaned = true;
350
+ signal.removeEventListener("abort", cleanup);
351
+ unsubscribe();
352
+ if (!submitting && localCapture === capture) localCapture = undefined;
353
+ };
354
+ signal.addEventListener("abort", cleanup, { once: true });
355
+ if (signal.aborted) cleanup();
356
+ try {
357
+ const result = await submit(message => {
358
+ preparedMessage = message;
359
+ });
360
+ submitting = false;
361
+ if (cleaned && localCapture === capture) localCapture = undefined;
362
+ void (async () => {
363
+ try {
364
+ await awaitWithAbort(attachedSession.waitForIdle(), signal);
365
+ if (result === false || signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
366
+ return;
367
+ if (!promptSeen || conflicted || currentTurn) {
368
+ try {
369
+ ctx.showWarning(
370
+ "Discord relay skipped: could not safely identify this local turn or another turn intervened.",
371
+ );
372
+ } catch {}
373
+ return;
374
+ }
375
+ if (response?.stopReason !== "stop") {
376
+ try {
377
+ ctx.showWarning("Discord relay skipped: the local turn did not finish successfully.");
378
+ } catch {}
379
+ return;
380
+ }
381
+ const output = assistantMessageText(response);
382
+ cleanup();
383
+ if (!output.trim()) {
384
+ try {
385
+ ctx.showWarning("Discord relay skipped: the local response contains no text.");
386
+ } catch {}
387
+ return;
388
+ }
389
+ const message = {
390
+ platform: "discord" as const,
391
+ accountId: "",
392
+ channelId: destination,
393
+ senderId: "",
394
+ messageId: "",
395
+ text: "",
396
+ direct: false,
397
+ mentioned: false,
398
+ };
399
+ const send = relaySend.then(async () => {
400
+ if (signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return;
401
+ await guardedAdapter.send(message, output, signal);
402
+ });
403
+ relaySend = send.catch(() => {});
404
+ await send;
405
+ } catch (error) {
406
+ if (!signal.aborted) {
407
+ try {
408
+ ctx.showWarning(
409
+ `Discord relay delivery failed: ${error instanceof Error ? error.message : String(error)}`,
410
+ );
411
+ } catch {}
412
+ }
413
+ } finally {
414
+ cleanup();
415
+ }
416
+ })();
417
+ return result;
418
+ } catch (error) {
419
+ submitting = false;
420
+ cleanup();
421
+ if (localCapture === capture) localCapture = undefined;
422
+ throw error;
423
+ }
424
+ };
425
+ const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
426
+ let revokePromise: Promise<void> | undefined;
427
+ revoke = (): Promise<void> => {
428
+ if (revokePromise) return revokePromise;
429
+ const completion = Promise.withResolvers<void>();
430
+ revokePromise = completion.promise;
431
+ const turn = currentTurn;
254
432
  closed = true;
255
433
  clearInterval(pollTimer);
434
+ relayLifetime.abort();
435
+ relayDestination = undefined;
256
436
  lifetime.abort();
257
- const turn = currentTurn;
258
- if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
259
- if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
260
- try {
261
- await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
262
- } catch {
437
+ void (async () => {
438
+ if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
439
+ if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
263
440
  try {
264
- ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
441
+ await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
442
+ } catch {
443
+ try {
444
+ ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
445
+ } catch {}
446
+ }
447
+ } else if (!turn.hostSettled) {
448
+ try {
449
+ ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
265
450
  } catch {}
266
451
  }
267
- } else if (!turn.hostSettled) {
268
- try {
269
- ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
270
- } catch {}
271
452
  }
272
- }
453
+ })().then(completion.resolve, completion.reject);
454
+ return revokePromise;
455
+ };
456
+ close = async (): Promise<void> => {
457
+ await revoke();
273
458
  await runPromise?.catch(() => {});
274
459
  };
275
460
 
276
461
  const run = (signal?: AbortSignal): Promise<void> => {
277
462
  if (runPromise) return runPromise;
463
+ const onAbort = (): void => {
464
+ void revoke();
465
+ };
466
+ signal?.addEventListener("abort", onAbort, { once: true });
467
+ if (signal?.aborted) onAbort();
278
468
  const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
279
469
  runPromise = gateway
280
470
  .run(effectiveSignal)
@@ -285,9 +475,9 @@ export function createLiveDiscordBridge(
285
475
  ctx.showStatus("Discord live bridge detached: session changed");
286
476
  }
287
477
  })
288
- .finally(() => {
289
- closed = true;
290
- clearInterval(pollTimer);
478
+ .finally(async () => {
479
+ signal?.removeEventListener("abort", onAbort);
480
+ await revoke();
291
481
  });
292
482
  return runPromise;
293
483
  };
@@ -303,6 +493,11 @@ export function createLiveDiscordBridge(
303
493
  get active() {
304
494
  return !closed;
305
495
  },
496
+ get relayDestination() {
497
+ return relayDestination;
498
+ },
499
+ setRelayDestination,
500
+ withLocalTurn,
306
501
  run,
307
502
  close,
308
503
  };
@@ -10,6 +10,8 @@ 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";
14
+ import { messengerArtifactPrompt } from "./artifacts";
13
15
  import { messengerConversationKey } from "./gateway";
14
16
  import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
15
17
 
@@ -122,12 +124,13 @@ export function createMessengerSessionBridge(
122
124
  activeRoute = route;
123
125
  }
124
126
 
125
- const reply: MessengerReply = async (message, signal) => {
127
+ const reply: MessengerReply = async (message, signal, onActivity) => {
126
128
  if (closed || signal.aborted) throw new Error("Messenger session is closed");
127
129
  if (activeTurn) throw new Error("Messenger turns must be serialized");
128
130
  const finished = Promise.withResolvers<void>();
129
131
  activeTurn = finished.promise;
130
132
  let aborting: Promise<void> | undefined;
133
+ let unsubscribe: (() => void) | undefined;
131
134
  const onAbort = (): void => {
132
135
  if (session && !aborting) {
133
136
  aborting = session.abort({ reason: USER_INTERRUPT_LABEL });
@@ -175,7 +178,15 @@ export function createMessengerSessionBridge(
175
178
  await constrainTools(current);
176
179
  if (closed || signal.aborted || runtimeFailed) throw new Error("Messenger session unavailable");
177
180
  // Slash-looking text is ordinary user data, never an extension command or developer message.
178
- const dispatched = await current.prompt(message.text, { expandPromptTemplates: false });
181
+ let prompt = message.text;
182
+ const artifactRoot = message.platform === "discord" ? config.platforms.discord?.artifactDir : undefined;
183
+ if (message.platform === "discord") {
184
+ const guidance = await messengerArtifactPrompt(artifactRoot, message, signal);
185
+ if (guidance) prompt += `\n\n${guidance}`;
186
+ }
187
+ // Turns are serialized, so every event until idle belongs to this request.
188
+ if (onActivity) unsubscribe = current.subscribe(createMessengerActivityTracker(onActivity));
189
+ const dispatched = await current.prompt(prompt, { expandPromptTemplates: false });
179
190
  await current.waitForIdle();
180
191
  await current.sessionManager.flush();
181
192
  if (!dispatched || closed || signal.aborted || runtimeFailed) throw new Error("Messenger request failed");
@@ -190,6 +201,7 @@ export function createMessengerSessionBridge(
190
201
  if (!text.trim()) throw new Error("Messenger response is empty");
191
202
  return text;
192
203
  } finally {
204
+ unsubscribe?.();
193
205
  signal.removeEventListener("abort", onAbort);
194
206
  await aborting?.catch(() => {});
195
207
  activeTurn = undefined;