jeopi-cli 16.6.9 → 16.6.12

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.
@@ -5,13 +5,15 @@ import { MessengerFatalError } from "./types";
5
5
  const MAX_MESSAGE_LENGTH = 32_000;
6
6
  const MAX_QUEUED_MESSAGES = 100;
7
7
  const MAX_SEEN_MESSAGES = 10_000;
8
+ const STATUS_TIMEOUT_MS = 1000;
9
+ const MAX_STATUS_IN_FLIGHT = 8;
8
10
 
9
11
  /** Include the sender even in shared threads: remote histories must not cross users. */
10
12
  export function messengerConversationKey(message: MessengerMessage): string {
11
13
  return JSON.stringify([
12
14
  message.platform,
13
15
  message.accountId,
14
- message.channelId,
16
+ message.parentChannelId ?? message.channelId,
15
17
  message.threadId ?? "",
16
18
  message.senderId,
17
19
  ]);
@@ -25,11 +27,14 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
25
27
  message.channelId &&
26
28
  message.senderId &&
27
29
  message.messageId &&
28
- message.text.trim() &&
30
+ (message.text.trim() ||
31
+ (message.platform === "discord" && config.platforms.discord?.artifactDir && message.attachments?.length)) &&
29
32
  message.text.length <= MAX_MESSAGE_LENGTH &&
30
33
  platform.allowedUserIds.includes(message.senderId) &&
31
- (!platform.allowedChannelIds || platform.allowedChannelIds.includes(message.channelId)) &&
32
- (message.direct || message.mentioned),
34
+ (!platform.allowedChannelIds ||
35
+ platform.allowedChannelIds.includes(message.channelId) ||
36
+ (Boolean(message.parentChannelId) && platform.allowedChannelIds.includes(message.parentChannelId!))) &&
37
+ (message.direct || message.mentioned || (message.platform === "discord" && Boolean(message.parentChannelId))),
33
38
  );
34
39
  }
35
40
 
@@ -63,20 +68,57 @@ export function createMessengerGateway(
63
68
  let workerActive = false;
64
69
  let running = false;
65
70
  let fatalError: MessengerFatalError | undefined;
71
+ let statusInFlight = 0;
66
72
 
67
73
  async function drain(): Promise<void> {
68
74
  while (queue.length > 0) {
69
75
  const pending = queue.shift();
70
76
  if (!pending) break;
71
77
  const { message, adapter, signal, resolve } = pending;
78
+ let failed = false;
79
+ let statusChain = Promise.resolve();
80
+ const status = (state: "processing" | "success" | "failure"): void => {
81
+ if (!adapter.status) return;
82
+ statusChain = statusChain.then(async () => {
83
+ if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
84
+ const timeout = new AbortController();
85
+ const statusSignal = AbortSignal.any([signal, timeout.signal]);
86
+ const stopped = Promise.withResolvers<void>();
87
+ const onAbort = (): void => stopped.resolve();
88
+ statusSignal.addEventListener("abort", onAbort, { once: true });
89
+ statusInFlight++;
90
+ const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
91
+ void operation.then(
92
+ () => {
93
+ statusInFlight--;
94
+ },
95
+ () => {
96
+ statusInFlight--;
97
+ },
98
+ );
99
+ const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
100
+ try {
101
+ await Promise.race([operation, stopped.promise]);
102
+ } catch {
103
+ if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
104
+ } finally {
105
+ clearTimeout(timer);
106
+ statusSignal.removeEventListener("abort", onAbort);
107
+ }
108
+ });
109
+ };
72
110
  try {
73
111
  if (signal.aborted) continue;
112
+ status("processing");
113
+ const destination = (await adapter.prepare?.(message, signal)) ?? message;
74
114
  let text: string;
75
115
  try {
76
- text = await reply(message, signal);
116
+ text = await reply(destination, signal);
77
117
  } catch (error) {
118
+ failed = true;
78
119
  if (error instanceof MessengerFatalError) {
79
120
  fatalError = error;
121
+ status("failure");
80
122
  lifetime.abort();
81
123
  continue;
82
124
  }
@@ -86,10 +128,14 @@ export function createMessengerGateway(
86
128
  }
87
129
  if (!signal.aborted && text.trim()) {
88
130
  // A failed send may already have delivered a chunk: never retry the entire reply.
89
- await adapter.send(message, text, signal);
131
+ await adapter.send(destination, text, signal);
90
132
  }
133
+ if (!signal.aborted) status(failed ? "failure" : "success");
91
134
  } catch {
92
- if (!signal.aborted) logger.warn("Messenger reply delivery failed", { platform: message.platform });
135
+ if (!signal.aborted) {
136
+ logger.warn("Messenger reply delivery failed", { platform: message.platform });
137
+ status("failure");
138
+ }
93
139
  } finally {
94
140
  resolve();
95
141
  }
@@ -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;
@@ -0,0 +1,502 @@
1
+ import type { AssistantMessage, UserMessage } from "jeopi-ai";
2
+ import type { InteractiveModeContext } from "../modes/types";
3
+ import { messengerArtifactPrompt } from "./artifacts";
4
+ import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
5
+ import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
6
+ import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
7
+ import type { MessengerAdapter, MessengerConfig, MessengerReply } from "./types";
8
+
9
+ const DEFAULT_POLL_INTERVAL_MS = 250;
10
+ const LIVE_HOST_ABORT_TIMEOUT_MS = 1000;
11
+ const LIVE_BUSY_MESSAGE = "The live session is busy. Please try again when it is idle.";
12
+ const LIVE_UNAVAILABLE_MESSAGE = "The live Discord bridge is no longer attached to this session.";
13
+
14
+ export interface LiveDiscordBridgeDependencies {
15
+ createAdapter?: typeof createDiscordAdapter;
16
+ createGateway?: typeof createMessengerGateway;
17
+ env?: Record<string, string | undefined>;
18
+ transport?: DiscordTransportDeps;
19
+ pollIntervalMs?: number;
20
+ }
21
+
22
+ export interface LiveDiscordBridge {
23
+ readonly gateway: MessengerGateway;
24
+ readonly active: boolean;
25
+ readonly relayDestination: string | undefined;
26
+ setRelayDestination(channelId: string | undefined): void;
27
+ withLocalTurn<T>(
28
+ text: string,
29
+ submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
30
+ ): Promise<T>;
31
+ run(signal?: AbortSignal): Promise<void>;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ interface TurnCapture {
36
+ promptText: string;
37
+ promptSeen: boolean;
38
+ dispatched: boolean;
39
+ hostSettled: boolean;
40
+ conflicted: boolean;
41
+ }
42
+
43
+ export interface LiveDiscordBridgeOptions {
44
+ /** Explicit acknowledgement that Discord requests may use this host session's tools. */
45
+ acknowledgeFullControl?: boolean;
46
+ }
47
+
48
+ function userMessageText(message: UserMessage): string {
49
+ if (typeof message.content === "string") return message.content;
50
+ return message.content
51
+ .filter(block => block.type === "text")
52
+ .map(block => block.text)
53
+ .join("");
54
+ }
55
+ function assistantMessageText(message: AssistantMessage): string {
56
+ return message.content
57
+ .filter(block => block.type === "text")
58
+ .map(block => block.text)
59
+ .join("\n");
60
+ }
61
+
62
+ function sameSession(
63
+ ctx: InteractiveModeContext,
64
+ attachedSession: InteractiveModeContext["session"],
65
+ sessionId: string,
66
+ sessionFile: string | undefined,
67
+ ): boolean {
68
+ return (
69
+ ctx.session === attachedSession &&
70
+ !attachedSession.isDisposed &&
71
+ ctx.sessionManager.getSessionId() === sessionId &&
72
+ ctx.sessionManager.getSessionFile() === sessionFile &&
73
+ !ctx.shutdownRequested &&
74
+ !ctx.isShuttingDown
75
+ );
76
+ }
77
+
78
+ function isCompetingMessage(message: { role: string; attribution?: string }): boolean {
79
+ if (message.role === "user") return true;
80
+ const attribution = message.attribution ?? "agent";
81
+ return (
82
+ (message.role === "custom" || message.role === "developer" || message.role === "hookMessage") &&
83
+ attribution !== "agent"
84
+ );
85
+ }
86
+
87
+ async function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
88
+ if (!signal) return operation;
89
+ if (signal.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
90
+ const cancellation = Promise.withResolvers<never>();
91
+ const onAbort = () => cancellation.reject(new Error(LIVE_UNAVAILABLE_MESSAGE));
92
+ signal.addEventListener("abort", onAbort, { once: true });
93
+ if (signal.aborted) onAbort();
94
+ try {
95
+ return await Promise.race([operation, cancellation.promise]);
96
+ } finally {
97
+ signal.removeEventListener("abort", onAbort);
98
+ }
99
+ }
100
+
101
+ async function awaitHostAbort(operation: Promise<void>): Promise<void> {
102
+ const timeout = Promise.withResolvers<never>();
103
+ const timer = setTimeout(
104
+ () => timeout.reject(new Error("Live Discord host abort timed out")),
105
+ LIVE_HOST_ABORT_TIMEOUT_MS,
106
+ );
107
+ try {
108
+ await Promise.race([operation, timeout.promise]);
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ function liveConfig(config: MessengerConfig): MessengerConfig {
115
+ const discord = config.platforms.discord;
116
+ if (!discord) throw new Error("Live Discord requires a discord platform configuration");
117
+ if (discord.allowedChannelIds === undefined || discord.allowedChannelIds.length === 0) {
118
+ throw new Error("Live Discord requires an explicit nonempty allowedChannelIds list");
119
+ }
120
+ return { ...config, platforms: { discord } };
121
+ }
122
+
123
+ /**
124
+ * Attach an authorized Discord transport to the already-running interactive session.
125
+ * This never creates, switches, or disposes an AgentSession. The caller owns activation
126
+ * and must retain the returned bridge to close it when the TUI shuts down.
127
+ */
128
+ export function createLiveDiscordBridge(
129
+ ctx: InteractiveModeContext,
130
+ config: MessengerConfig,
131
+ dependencies: LiveDiscordBridgeDependencies = {},
132
+ options: LiveDiscordBridgeOptions = {},
133
+ ): LiveDiscordBridge {
134
+ if (!options.acknowledgeFullControl) {
135
+ throw new Error(
136
+ "Live Discord requires explicit full-control acknowledgement (--allow-full-control); " +
137
+ "Discord requests run in the active session with its tools and approval policy",
138
+ );
139
+ }
140
+ const scopedConfig = liveConfig(config);
141
+ validateMessengerEnvironment(scopedConfig, dependencies.env);
142
+ const attachedSession = ctx.session;
143
+ const sessionId = ctx.sessionManager.getSessionId();
144
+ const sessionFile = ctx.sessionManager.getSessionFile();
145
+ if (!sessionId) throw new Error("Live Discord requires a persisted session identity");
146
+
147
+ const lifetime = new AbortController();
148
+ let closed = false;
149
+ let runPromise: Promise<void> | undefined;
150
+ let currentTurn: TurnCapture | undefined;
151
+ let pollTimer: NodeJS.Timeout;
152
+ let bridgeRef: LiveDiscordBridge | undefined;
153
+ let relayDestination: string | undefined;
154
+ let relayLifetime = new AbortController();
155
+ let relaySend = Promise.resolve();
156
+ let localCapture: object | undefined;
157
+ let close: () => Promise<void>;
158
+ let revoke: () => Promise<void>;
159
+ const reply: MessengerReply = async (message, signal) => {
160
+ if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
161
+ if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
162
+ if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
163
+ if (
164
+ attachedSession.isStreaming ||
165
+ attachedSession.isCompacting ||
166
+ attachedSession.isGeneratingHandoff ||
167
+ attachedSession.isRetrying ||
168
+ attachedSession.isBashRunning ||
169
+ attachedSession.isEvalRunning ||
170
+ attachedSession.hasPostPromptWork ||
171
+ localCapture ||
172
+ currentTurn
173
+ )
174
+ return LIVE_BUSY_MESSAGE;
175
+ let promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
176
+ const capture: TurnCapture = {
177
+ promptText,
178
+ promptSeen: false,
179
+ dispatched: false,
180
+ hostSettled: false,
181
+ conflicted: false,
182
+ };
183
+ currentTurn = capture;
184
+
185
+ try {
186
+ ctx.showStatus(`Discord request from ${message.senderId}`);
187
+ } catch {}
188
+ const operationSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
189
+ const onAbort = (): void => {
190
+ void revoke();
191
+ };
192
+ operationSignal.addEventListener("abort", onAbort, { once: true });
193
+ const unsubscribe = attachedSession.subscribe(event => {
194
+ if (event.type !== "message_end") return;
195
+ if (event.message.role === "user") {
196
+ const text = userMessageText(event.message);
197
+ if (!capture.promptSeen && text === capture.promptText) {
198
+ capture.promptSeen = true;
199
+ } else if (capture.promptSeen) {
200
+ capture.conflicted = true;
201
+ }
202
+ return;
203
+ }
204
+ if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
205
+ });
206
+ try {
207
+ const guidance = await messengerArtifactPrompt(
208
+ scopedConfig.platforms.discord?.artifactDir,
209
+ message,
210
+ operationSignal,
211
+ );
212
+ if (guidance) promptText += `\n\n${guidance}`;
213
+ capture.promptText = promptText;
214
+ if (
215
+ attachedSession.isStreaming ||
216
+ attachedSession.isCompacting ||
217
+ attachedSession.isBashRunning ||
218
+ attachedSession.isEvalRunning ||
219
+ localCapture
220
+ )
221
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
222
+ if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
223
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
224
+ const promptPromise = attachedSession.prompt(promptText, {
225
+ expandPromptTemplates: false,
226
+ attribution: "user",
227
+ });
228
+ capture.dispatched = true;
229
+ const dispatched = await awaitWithAbort(promptPromise, operationSignal);
230
+ capture.dispatched = dispatched;
231
+ await awaitWithAbort(attachedSession.waitForIdle(), operationSignal);
232
+ capture.hostSettled = true;
233
+ const turnMessages = attachedSession.messages;
234
+ const promptIndices = turnMessages.flatMap((turnMessage, index) =>
235
+ turnMessage.role === "user" && userMessageText(turnMessage) === promptText ? [index] : [],
236
+ );
237
+ capture.promptSeen = capture.promptSeen || promptIndices.length === 1;
238
+ if (
239
+ closed ||
240
+ !dispatched ||
241
+ !sameSession(ctx, attachedSession, sessionId, sessionFile) ||
242
+ capture.conflicted ||
243
+ promptIndices.length !== 1
244
+ )
245
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
246
+ const promptIndex = promptIndices[0]!;
247
+ const messagesAfterPrompt = turnMessages.slice(promptIndex + 1);
248
+ if (messagesAfterPrompt.some(isCompetingMessage)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
249
+ const finalAssistant = messagesAfterPrompt.findLast(
250
+ (turnMessage): turnMessage is AssistantMessage => turnMessage.role === "assistant",
251
+ );
252
+ if (finalAssistant?.stopReason !== "stop") throw new Error("Live Discord response unavailable");
253
+ const response = assistantMessageText(finalAssistant);
254
+ if (!response.trim()) throw new Error("Live Discord response unavailable");
255
+ return response;
256
+ } finally {
257
+ operationSignal.removeEventListener("abort", onAbort);
258
+ unsubscribe();
259
+ if (currentTurn === capture) currentTurn = undefined;
260
+ }
261
+ };
262
+
263
+ const discordConfig = scopedConfig.platforms.discord;
264
+ if (!discordConfig) throw new Error("Live Discord configuration unavailable");
265
+ const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
266
+ const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
267
+ const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
268
+ const guardedAdapter: MessengerAdapter = {
269
+ ...adapter,
270
+ send: async (message, text, signal) => {
271
+ if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) {
272
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
273
+ }
274
+ const sendSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
275
+ if (sendSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) {
276
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
277
+ }
278
+ await adapter.send(message, text, sendSignal);
279
+ },
280
+ };
281
+ const setRelayDestination = (channelId: string | undefined): void => {
282
+ if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))
283
+ throw new Error(LIVE_UNAVAILABLE_MESSAGE);
284
+ if (channelId !== undefined && !discordConfig.allowedChannelIds?.includes(channelId)) {
285
+ throw new Error("Discord relay destination must be an explicitly allowed channel ID");
286
+ }
287
+ relayLifetime.abort();
288
+ relayLifetime = new AbortController();
289
+ relayDestination = channelId;
290
+ };
291
+ const withLocalTurn = async <T>(
292
+ _text: string,
293
+ submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
294
+ ): Promise<T> => {
295
+ const destination = relayDestination;
296
+ if (!destination || closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return submit(() => {});
297
+ if (
298
+ currentTurn ||
299
+ localCapture ||
300
+ attachedSession.isStreaming ||
301
+ attachedSession.isCompacting ||
302
+ attachedSession.isRetrying ||
303
+ attachedSession.isGeneratingHandoff ||
304
+ attachedSession.isBashRunning ||
305
+ attachedSession.isEvalRunning ||
306
+ attachedSession.hasPostPromptWork
307
+ ) {
308
+ try {
309
+ ctx.showWarning("Discord relay skipped: another session operation is in progress.");
310
+ } catch {}
311
+ return submit(() => {});
312
+ }
313
+ const signal = AbortSignal.any([lifetime.signal, relayLifetime.signal]);
314
+ const capture = {};
315
+ localCapture = capture;
316
+ let promptSeen = false;
317
+ let preparedMessage: UserMessage | undefined;
318
+ let conflicted = false;
319
+ let response: AssistantMessage | undefined;
320
+ const unsubscribe = attachedSession.subscribe(event => {
321
+ if (event.type !== "message_end") return;
322
+ if (event.message.role === "user") {
323
+ if (!promptSeen && preparedMessage === event.message) promptSeen = true;
324
+ else conflicted = true;
325
+ } else if (isCompetingMessage(event.message)) {
326
+ conflicted = true;
327
+ } else if (promptSeen && event.message.role === "assistant") {
328
+ response = event.message;
329
+ }
330
+ });
331
+ let cleaned = false;
332
+ let submitting = true;
333
+ const cleanup = (): void => {
334
+ if (cleaned) return;
335
+ cleaned = true;
336
+ signal.removeEventListener("abort", cleanup);
337
+ unsubscribe();
338
+ if (!submitting && localCapture === capture) localCapture = undefined;
339
+ };
340
+ signal.addEventListener("abort", cleanup, { once: true });
341
+ if (signal.aborted) cleanup();
342
+ try {
343
+ const result = await submit(message => {
344
+ preparedMessage = message;
345
+ });
346
+ submitting = false;
347
+ if (cleaned && localCapture === capture) localCapture = undefined;
348
+ void (async () => {
349
+ try {
350
+ await awaitWithAbort(attachedSession.waitForIdle(), signal);
351
+ if (result === false || signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
352
+ return;
353
+ if (!promptSeen || conflicted || currentTurn) {
354
+ try {
355
+ ctx.showWarning(
356
+ "Discord relay skipped: could not safely identify this local turn or another turn intervened.",
357
+ );
358
+ } catch {}
359
+ return;
360
+ }
361
+ if (response?.stopReason !== "stop") {
362
+ try {
363
+ ctx.showWarning("Discord relay skipped: the local turn did not finish successfully.");
364
+ } catch {}
365
+ return;
366
+ }
367
+ const output = assistantMessageText(response);
368
+ cleanup();
369
+ if (!output.trim()) {
370
+ try {
371
+ ctx.showWarning("Discord relay skipped: the local response contains no text.");
372
+ } catch {}
373
+ return;
374
+ }
375
+ const message = {
376
+ platform: "discord" as const,
377
+ accountId: "",
378
+ channelId: destination,
379
+ senderId: "",
380
+ messageId: "",
381
+ text: "",
382
+ direct: false,
383
+ mentioned: false,
384
+ };
385
+ const send = relaySend.then(async () => {
386
+ if (signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return;
387
+ await guardedAdapter.send(message, output, signal);
388
+ });
389
+ relaySend = send.catch(() => {});
390
+ await send;
391
+ } catch (error) {
392
+ if (!signal.aborted) {
393
+ try {
394
+ ctx.showWarning(
395
+ `Discord relay delivery failed: ${error instanceof Error ? error.message : String(error)}`,
396
+ );
397
+ } catch {}
398
+ }
399
+ } finally {
400
+ cleanup();
401
+ }
402
+ })();
403
+ return result;
404
+ } catch (error) {
405
+ submitting = false;
406
+ cleanup();
407
+ if (localCapture === capture) localCapture = undefined;
408
+ throw error;
409
+ }
410
+ };
411
+ const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
412
+ let revokePromise: Promise<void> | undefined;
413
+ revoke = (): Promise<void> => {
414
+ if (revokePromise) return revokePromise;
415
+ const completion = Promise.withResolvers<void>();
416
+ revokePromise = completion.promise;
417
+ const turn = currentTurn;
418
+ closed = true;
419
+ clearInterval(pollTimer);
420
+ relayLifetime.abort();
421
+ relayDestination = undefined;
422
+ lifetime.abort();
423
+ void (async () => {
424
+ if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
425
+ if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
426
+ try {
427
+ await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
428
+ } catch {
429
+ try {
430
+ ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
431
+ } catch {}
432
+ }
433
+ } else if (!turn.hostSettled) {
434
+ try {
435
+ ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
436
+ } catch {}
437
+ }
438
+ }
439
+ })().then(completion.resolve, completion.reject);
440
+ return revokePromise;
441
+ };
442
+ close = async (): Promise<void> => {
443
+ await revoke();
444
+ await runPromise?.catch(() => {});
445
+ };
446
+
447
+ const run = (signal?: AbortSignal): Promise<void> => {
448
+ if (runPromise) return runPromise;
449
+ const onAbort = (): void => {
450
+ void revoke();
451
+ };
452
+ signal?.addEventListener("abort", onAbort, { once: true });
453
+ if (signal?.aborted) onAbort();
454
+ const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
455
+ runPromise = gateway
456
+ .run(effectiveSignal)
457
+ .then(() => {
458
+ if (ctx.liveDiscordBridge !== bridgeRef) return;
459
+ ctx.liveDiscordBridge = undefined;
460
+ if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) {
461
+ ctx.showStatus("Discord live bridge detached: session changed");
462
+ }
463
+ })
464
+ .finally(async () => {
465
+ signal?.removeEventListener("abort", onAbort);
466
+ await revoke();
467
+ });
468
+ return runPromise;
469
+ };
470
+
471
+ const pollInterval = Math.max(50, Math.trunc(dependencies.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
472
+ pollTimer = setInterval(() => {
473
+ if (!closed && !sameSession(ctx, attachedSession, sessionId, sessionFile)) void close().catch(() => {});
474
+ }, pollInterval);
475
+ pollTimer.unref?.();
476
+
477
+ const bridge: LiveDiscordBridge = {
478
+ gateway,
479
+ get active() {
480
+ return !closed;
481
+ },
482
+ get relayDestination() {
483
+ return relayDestination;
484
+ },
485
+ setRelayDestination,
486
+ withLocalTurn,
487
+ run,
488
+ close,
489
+ };
490
+ bridgeRef = bridge;
491
+ return bridge;
492
+ }
493
+
494
+ export async function loadAndCreateLiveDiscordBridge(
495
+ ctx: InteractiveModeContext,
496
+ configPath: string,
497
+ dependencies: LiveDiscordBridgeDependencies = {},
498
+ options: LiveDiscordBridgeOptions = {},
499
+ ): Promise<LiveDiscordBridge> {
500
+ const config = await loadMessengerConfig(configPath);
501
+ return createLiveDiscordBridge(ctx, config, dependencies, options);
502
+ }
@@ -10,6 +10,7 @@ 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 { messengerArtifactPrompt } from "./artifacts";
13
14
  import { messengerConversationKey } from "./gateway";
14
15
  import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
15
16
 
@@ -175,7 +176,13 @@ export function createMessengerSessionBridge(
175
176
  await constrainTools(current);
176
177
  if (closed || signal.aborted || runtimeFailed) throw new Error("Messenger session unavailable");
177
178
  // Slash-looking text is ordinary user data, never an extension command or developer message.
178
- const dispatched = await current.prompt(message.text, { expandPromptTemplates: false });
179
+ let prompt = message.text;
180
+ const artifactRoot = message.platform === "discord" ? config.platforms.discord?.artifactDir : undefined;
181
+ if (message.platform === "discord") {
182
+ const guidance = await messengerArtifactPrompt(artifactRoot, message, signal);
183
+ if (guidance) prompt += `\n\n${guidance}`;
184
+ }
185
+ const dispatched = await current.prompt(prompt, { expandPromptTemplates: false });
179
186
  await current.waitForIdle();
180
187
  await current.sessionManager.flush();
181
188
  if (!dispatched || closed || signal.aborted || runtimeFailed) throw new Error("Messenger request failed");
@@ -8,14 +8,22 @@ export interface MessengerMessage {
8
8
  messageId: string;
9
9
  text: string;
10
10
  threadId?: string;
11
+ parentChannelId?: string;
11
12
  direct: boolean;
12
13
  mentioned: boolean;
14
+ attachments?: { url: string; filename: string; size: number }[];
13
15
  }
14
16
 
15
17
  export interface MessengerAdapter {
16
18
  platform: MessengerPlatform;
17
19
  start(onMessage: (message: MessengerMessage) => Promise<void>, signal: AbortSignal): Promise<void>;
18
20
  send(message: MessengerMessage, text: string, signal?: AbortSignal): Promise<void>;
21
+ prepare?(message: MessengerMessage, signal?: AbortSignal): Promise<MessengerMessage>;
22
+ status?(
23
+ message: MessengerMessage,
24
+ status: "processing" | "success" | "failure",
25
+ signal?: AbortSignal,
26
+ ): Promise<void>;
19
27
  }
20
28
 
21
29
  export interface MessengerPlatformConfig {
@@ -23,6 +31,8 @@ export interface MessengerPlatformConfig {
23
31
  appTokenEnv?: string;
24
32
  allowedUserIds: string[];
25
33
  allowedChannelIds?: string[];
34
+ /** Dedicated operator-approved directory for Discord output files. */
35
+ artifactDir?: string;
26
36
  }
27
37
 
28
38
  export interface MessengerConfig {