jeopi-cli 16.6.11 → 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;
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage, UserMessage } from "jeopi-ai";
2
2
  import type { InteractiveModeContext } from "../modes/types";
3
+ import { messengerArtifactPrompt } from "./artifacts";
3
4
  import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
4
5
  import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
5
6
  import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
@@ -21,12 +22,18 @@ export interface LiveDiscordBridgeDependencies {
21
22
  export interface LiveDiscordBridge {
22
23
  readonly gateway: MessengerGateway;
23
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>;
24
31
  run(signal?: AbortSignal): Promise<void>;
25
32
  close(): Promise<void>;
26
33
  }
27
34
 
28
35
  interface TurnCapture {
29
- readonly promptText: string;
36
+ promptText: string;
30
37
  promptSeen: boolean;
31
38
  dispatched: boolean;
32
39
  hostSettled: boolean;
@@ -143,7 +150,12 @@ export function createLiveDiscordBridge(
143
150
  let currentTurn: TurnCapture | undefined;
144
151
  let pollTimer: NodeJS.Timeout;
145
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;
146
157
  let close: () => Promise<void>;
158
+ let revoke: () => Promise<void>;
147
159
  const reply: MessengerReply = async (message, signal) => {
148
160
  if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
149
161
  if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
@@ -156,10 +168,11 @@ export function createLiveDiscordBridge(
156
168
  attachedSession.isBashRunning ||
157
169
  attachedSession.isEvalRunning ||
158
170
  attachedSession.hasPostPromptWork ||
171
+ localCapture ||
159
172
  currentTurn
160
173
  )
161
174
  return LIVE_BUSY_MESSAGE;
162
- const promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
175
+ let promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
163
176
  const capture: TurnCapture = {
164
177
  promptText,
165
178
  promptSeen: false,
@@ -173,6 +186,10 @@ export function createLiveDiscordBridge(
173
186
  ctx.showStatus(`Discord request from ${message.senderId}`);
174
187
  } catch {}
175
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 });
176
193
  const unsubscribe = attachedSession.subscribe(event => {
177
194
  if (event.type !== "message_end") return;
178
195
  if (event.message.role === "user") {
@@ -187,6 +204,21 @@ export function createLiveDiscordBridge(
187
204
  if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
188
205
  });
189
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);
190
222
  if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
191
223
  throw new Error(LIVE_UNAVAILABLE_MESSAGE);
192
224
  const promptPromise = attachedSession.prompt(promptText, {
@@ -222,6 +254,7 @@ export function createLiveDiscordBridge(
222
254
  if (!response.trim()) throw new Error("Live Discord response unavailable");
223
255
  return response;
224
256
  } finally {
257
+ operationSignal.removeEventListener("abort", onAbort);
225
258
  unsubscribe();
226
259
  if (currentTurn === capture) currentTurn = undefined;
227
260
  }
@@ -245,36 +278,179 @@ export function createLiveDiscordBridge(
245
278
  await adapter.send(message, text, sendSignal);
246
279
  },
247
280
  };
248
- const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
249
- close = async (): Promise<void> => {
250
- if (closed) {
251
- await runPromise?.catch(() => {});
252
- return;
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;
253
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;
254
418
  closed = true;
255
419
  clearInterval(pollTimer);
420
+ relayLifetime.abort();
421
+ relayDestination = undefined;
256
422
  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 {
423
+ void (async () => {
424
+ if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
425
+ if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
263
426
  try {
264
- ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
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.");
265
436
  } catch {}
266
437
  }
267
- } else if (!turn.hostSettled) {
268
- try {
269
- ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
270
- } catch {}
271
438
  }
272
- }
439
+ })().then(completion.resolve, completion.reject);
440
+ return revokePromise;
441
+ };
442
+ close = async (): Promise<void> => {
443
+ await revoke();
273
444
  await runPromise?.catch(() => {});
274
445
  };
275
446
 
276
447
  const run = (signal?: AbortSignal): Promise<void> => {
277
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();
278
454
  const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
279
455
  runPromise = gateway
280
456
  .run(effectiveSignal)
@@ -285,9 +461,9 @@ export function createLiveDiscordBridge(
285
461
  ctx.showStatus("Discord live bridge detached: session changed");
286
462
  }
287
463
  })
288
- .finally(() => {
289
- closed = true;
290
- clearInterval(pollTimer);
464
+ .finally(async () => {
465
+ signal?.removeEventListener("abort", onAbort);
466
+ await revoke();
291
467
  });
292
468
  return runPromise;
293
469
  };
@@ -303,6 +479,11 @@ export function createLiveDiscordBridge(
303
479
  get active() {
304
480
  return !closed;
305
481
  },
482
+ get relayDestination() {
483
+ return relayDestination;
484
+ },
485
+ setRelayDestination,
486
+ withLocalTurn,
306
487
  run,
307
488
  close,
308
489
  };
@@ -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 {
@@ -903,7 +903,19 @@ export class InputController {
903
903
  try {
904
904
  await this.ctx.withLocalSubmission(
905
905
  text,
906
- () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
906
+ () => {
907
+ const submit = () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
908
+ const bridge = this.ctx.liveDiscordBridge;
909
+ return bridge?.active
910
+ ? bridge.withLocalTurn(text, onPreparedUserMessage =>
911
+ this.ctx.session.prompt(text, {
912
+ streamingBehavior: "steer",
913
+ images,
914
+ onPreparedUserMessage,
915
+ }),
916
+ )
917
+ : submit();
918
+ },
907
919
  {
908
920
  imageCount: images?.length ?? 0,
909
921
  },
@@ -1229,9 +1241,19 @@ export class InputController {
1229
1241
  // Not streaming — just submit normally
1230
1242
  this.ctx.editor.clearDraft(text);
1231
1243
  try {
1232
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1233
- imageCount: images?.length ?? 0,
1234
- });
1244
+ await this.ctx.withLocalSubmission(
1245
+ text,
1246
+ () => {
1247
+ const submit = () => this.ctx.session.prompt(text, { images });
1248
+ const bridge = this.ctx.liveDiscordBridge;
1249
+ return bridge?.active
1250
+ ? bridge.withLocalTurn(text, onPreparedUserMessage =>
1251
+ this.ctx.session.prompt(text, { images, onPreparedUserMessage }),
1252
+ )
1253
+ : submit();
1254
+ },
1255
+ { imageCount: images?.length ?? 0 },
1256
+ );
1235
1257
  } catch (error) {
1236
1258
  restoreOnError(error);
1237
1259
  }
@@ -0,0 +1,4 @@
1
+ To deliver a requested file, write it inside {{directory}} and put [artifact:relative/path] on its own line in your final response. Only files in that directory can be uploaded; each must be at most 8 MiB. Never include credentials or secrets in output files.
2
+ {{#if inputs}}
3
+ Incoming files: {{inputs}}
4
+ {{/if}}
@@ -97,6 +97,7 @@ import type {
97
97
  ToolChoice,
98
98
  Usage,
99
99
  UsageReport,
100
+ UserMessage,
100
101
  } from "jeopi-ai";
101
102
  import {
102
103
  calculateRateLimitBackoffMs,
@@ -944,6 +945,8 @@ export interface PromptOptions {
944
945
  userInitiated?: boolean;
945
946
  /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
946
947
  attribution?: MessageAttribution;
948
+ /** Observe the exact user message prepared for this submission, including queued messages. */
949
+ onPreparedUserMessage?: (message: UserMessage) => void;
947
950
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
948
951
  skipCompactionCheck?: boolean;
949
952
  }
@@ -7539,9 +7542,9 @@ export class AgentSession {
7539
7542
  await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
7540
7543
  }
7541
7544
  if (options.streamingBehavior === "followUp") {
7542
- await this.#queueUserMessage(expandedText, options?.images, "followUp");
7545
+ await this.#queueUserMessage(expandedText, options?.images, "followUp", options?.onPreparedUserMessage);
7543
7546
  } else {
7544
- await this.#queueUserMessage(expandedText, options?.images, "steer");
7547
+ await this.#queueUserMessage(expandedText, options?.images, "steer", options?.onPreparedUserMessage);
7545
7548
  }
7546
7549
  return true;
7547
7550
  }
@@ -7568,6 +7571,7 @@ export class AgentSession {
7568
7571
  const message = options?.synthetic
7569
7572
  ? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
7570
7573
  : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
7574
+ if (message.role === "user") options?.onPreparedUserMessage?.(message);
7571
7575
 
7572
7576
  const preludeMessages: AgentMessage[] = [];
7573
7577
  if (eagerTodoPrelude) {
@@ -8021,6 +8025,7 @@ export class AgentSession {
8021
8025
  text: string,
8022
8026
  images: ImageContent[] | undefined,
8023
8027
  mode: "steer" | "followUp",
8028
+ onPreparedUserMessage?: (message: UserMessage) => void,
8024
8029
  ): Promise<void> {
8025
8030
  // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
8026
8031
  // while streaming) is a deliberate resume; re-enable advisor auto-resume that
@@ -8038,21 +8043,20 @@ export class AgentSession {
8038
8043
  : undefined;
8039
8044
  if (mode === "followUp") {
8040
8045
  if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice);
8041
- this.agent.followUp({
8042
- role: "user",
8043
- content,
8044
- attribution: "user",
8045
- timestamp: Date.now(),
8046
- });
8046
+ const message: UserMessage = { role: "user", content, attribution: "user", timestamp: Date.now() };
8047
+ onPreparedUserMessage?.(message);
8048
+ this.agent.followUp(message);
8047
8049
  } else {
8048
8050
  if (imageDescriptionNotice) this.agent.steer(imageDescriptionNotice);
8049
- this.agent.steer({
8051
+ const message: UserMessage = {
8050
8052
  role: "user",
8051
8053
  content,
8052
8054
  steering: true,
8053
8055
  attribution: "user",
8054
8056
  timestamp: Date.now(),
8055
- });
8057
+ };
8058
+ onPreparedUserMessage?.(message);
8059
+ this.agent.steer(message);
8056
8060
  }
8057
8061
  this.#scheduleIdleQueueDrain();
8058
8062
  }
@@ -661,9 +661,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
661
661
  {
662
662
  name: "discord",
663
663
  description: "Opt in to this session from an authorized Discord channel",
664
- inlineHint: "[live|stop|status] [configPath] [--allow-full-control]",
664
+ inlineHint: "[live|relay|stop|status] [configPath|channelId|off] [--allow-full-control]",
665
665
  subcommands: [
666
666
  { name: "live", description: "Start the live Discord bridge with a private config file" },
667
+ { name: "relay", description: "Relay local responses to an allowed channel, or turn relay off" },
667
668
  { name: "status", description: "Show live Discord bridge status" },
668
669
  { name: "stop", description: "Stop the live Discord bridge" },
669
670
  ],
@@ -682,10 +683,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
682
683
  const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
683
684
  const activeTools = ctx.session.getActiveToolNames();
684
685
  ctx.showStatus(
685
- `Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}.`,
686
+ `Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}. Local response relay: ${ctx.liveDiscordBridge.relayDestination ?? "off"}.`,
686
687
  );
687
688
  return;
688
689
  }
690
+ if (verb === "relay") {
691
+ const bridge = ctx.liveDiscordBridge;
692
+ if (!bridge?.active) {
693
+ ctx.showError("Start the Discord live bridge before enabling relay");
694
+ return;
695
+ }
696
+ if (!rest || /\s/.test(rest)) {
697
+ ctx.showError("Usage: /discord relay <allowed-channel-id|off>");
698
+ return;
699
+ }
700
+ try {
701
+ bridge.setRelayDestination(rest === "off" ? undefined : rest);
702
+ ctx.showStatus(
703
+ rest === "off"
704
+ ? "Discord local response relay is off"
705
+ : `Discord local responses will be sent to channel ${rest}; /discord relay off revokes relay.`,
706
+ );
707
+ } catch (error) {
708
+ ctx.showError(`Failed to configure Discord relay: ${errorMessage(error)}`);
709
+ }
710
+ return;
711
+ }
689
712
  if (verb === "stop") {
690
713
  const bridge = ctx.liveDiscordBridge;
691
714
  const start = liveDiscordStarts.get(ctx);