jeopi-cli 16.6.12 → 16.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import type { AgentSessionEvent } from "../session/agent-session";
2
+ import type { MessengerActivityReporter } from "./types";
3
+
4
+ /**
5
+ * Derive one-line progress from session events: the model's declared tool intent (falling back to the
6
+ * tool name) and long-running lifecycle notices. Raw tool arguments, results and errors never surface.
7
+ */
8
+ export function createMessengerActivityTracker(report: MessengerActivityReporter): (event: AgentSessionEvent) => void {
9
+ let step = 0;
10
+ return event => {
11
+ switch (event.type) {
12
+ case "tool_execution_start":
13
+ step++;
14
+ report(`Step ${step} · ${event.intent ?? `Using ${event.toolName}`}`);
15
+ return;
16
+ case "auto_compaction_start":
17
+ report("Compacting context");
18
+ return;
19
+ case "auto_retry_start":
20
+ report(`Retrying after a provider error (${event.attempt}/${event.maxAttempts})`);
21
+ return;
22
+ }
23
+ };
24
+ }
@@ -14,6 +14,8 @@ const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
14
14
  // they are part of the delivered text, and consume one UTF-16 unit each.
15
15
  const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
16
16
  const DISCORD_BOUNDARY_GUARD = "\u200b";
17
+ // SUPPRESS_EMBEDS | SUPPRESS_NOTIFICATIONS: a status line never unfurls links or pings on creation.
18
+ const DISCORD_ACTIVITY_FLAGS = (1 << 2) | (1 << 12);
17
19
 
18
20
  function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
19
21
  const chunks: string[] = [];
@@ -333,6 +335,7 @@ export function createDiscordAdapter(
333
335
  if (oldest !== undefined) createdThreads.delete(oldest);
334
336
  }
335
337
  };
338
+ const activityMessages = new Map<string, string>();
336
339
  const headers = (): Record<string, string> => {
337
340
  if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
338
341
  return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
@@ -504,6 +507,61 @@ export function createDiscordAdapter(
504
507
  deps.fetch,
505
508
  );
506
509
  },
510
+ async activity(message, text, signal) {
511
+ const channelId = message.threadId ?? message.channelId;
512
+ const key = JSON.stringify([channelId, message.messageId]);
513
+ const messages = `${API}/channels/${encodeURIComponent(channelId)}/messages`;
514
+ const statusId = activityMessages.get(key);
515
+ if (text === undefined) {
516
+ if (!statusId) return;
517
+ try {
518
+ await messengerFetchJson(
519
+ `${messages}/${encodeURIComponent(statusId)}`,
520
+ { method: "DELETE", headers: headers() },
521
+ signal,
522
+ deps.fetch,
523
+ );
524
+ } catch (error) {
525
+ // Keep the ID so the caller can retry; a missing message is already removed.
526
+ if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
527
+ }
528
+ activityMessages.delete(key);
529
+ return;
530
+ }
531
+ const content = `⏳ ${text}`;
532
+ if (statusId) {
533
+ try {
534
+ // Edits re-parse mentions with default allowances unless allowed_mentions is repeated.
535
+ await messengerFetchJson(
536
+ `${messages}/${encodeURIComponent(statusId)}`,
537
+ {
538
+ method: "PATCH",
539
+ headers: headers(),
540
+ body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
541
+ },
542
+ signal,
543
+ deps.fetch,
544
+ );
545
+ return;
546
+ } catch (error) {
547
+ if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
548
+ activityMessages.delete(key);
549
+ }
550
+ }
551
+ const created = await messengerFetchJson(
552
+ messages,
553
+ {
554
+ method: "POST",
555
+ headers: headers(),
556
+ body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }),
557
+ },
558
+ signal,
559
+ deps.fetch,
560
+ );
561
+ if (!isRecord(created) || typeof created.id !== "string" || !created.id)
562
+ throw new Error("Discord activity message returned no ID");
563
+ activityMessages.set(key, created.id);
564
+ },
507
565
  async send(message, text, signal) {
508
566
  let files: { name: string; data: Uint8Array }[] = [];
509
567
  if (config.artifactDir) {
@@ -1,5 +1,12 @@
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;
@@ -7,6 +14,157 @@ const MAX_QUEUED_MESSAGES = 100;
7
14
  const MAX_SEEN_MESSAGES = 10_000;
8
15
  const STATUS_TIMEOUT_MS = 1000;
9
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
+ }
10
168
 
11
169
  /** Include the sender even in shared threads: remote histories must not cross users. */
12
170
  export function messengerConversationKey(message: MessengerMessage): string {
@@ -19,6 +177,24 @@ export function messengerConversationKey(message: MessengerMessage): string {
19
177
  ]);
20
178
  }
21
179
 
180
+ /** Stable per-conversation route shared by the persisted session file and its session process. */
181
+ export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
182
+ return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
183
+ }
184
+
185
+ /**
186
+ * Scheduling heuristic, refined after `prepare`: a top-level server message on a prepare-capable adapter
187
+ * (Discord) starts its own reply thread, so it is never held behind queued work; every other message waits for
188
+ * earlier requests in its conversation. A follow-up arriving in that new thread is ordered behind the request
189
+ * that created it by the prepared-conversation lock in `processRequest`.
190
+ */
191
+ function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
192
+ if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
193
+ return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
194
+ }
195
+ return messengerConversationKey(message);
196
+ }
197
+
22
198
  export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
23
199
  const platform = config.platforms[message.platform];
24
200
  return Boolean(
@@ -43,6 +219,8 @@ interface PendingMessage {
43
219
  adapter: MessengerAdapter;
44
220
  signal: AbortSignal;
45
221
  resolve: () => void;
222
+ /** Requests sharing a key never run at the same time. */
223
+ key: string;
46
224
  }
47
225
 
48
226
  export interface MessengerGateway {
@@ -50,11 +228,24 @@ export interface MessengerGateway {
50
228
  handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
51
229
  }
52
230
 
231
+ export interface MessengerGatewayOptions {
232
+ /** Minimum spacing between in-place activity edits for one request. */
233
+ activityIntervalMs?: number;
234
+ /**
235
+ * Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
236
+ * order; above 1 the reply implementation must accept concurrent turns for different conversations.
237
+ */
238
+ concurrency?: number;
239
+ }
240
+
53
241
  export function createMessengerGateway(
54
242
  config: MessengerConfig,
55
243
  adapters: MessengerAdapter[],
56
244
  reply: MessengerReply,
245
+ options: MessengerGatewayOptions = {},
57
246
  ): MessengerGateway {
247
+ const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
248
+ const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
58
249
  const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
59
250
  if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
60
251
  for (const platform of Object.keys(config.platforms)) {
@@ -63,84 +254,120 @@ export function createMessengerGateway(
63
254
  }
64
255
  const seen = new Set<string>();
65
256
  const queue: PendingMessage[] = [];
257
+ const activeKeys = new Set<string>();
258
+ const turns = new Set<Promise<void>>();
259
+ /** Settles when the latest request of a prepared conversation has delivered its reply. */
260
+ const conversationTails = new Map<string, Promise<void>>();
66
261
  const lifetime = new AbortController();
67
- let draining: Promise<void> | undefined;
68
- let workerActive = false;
69
262
  let running = false;
70
263
  let fatalError: MessengerFatalError | undefined;
71
264
  let statusInFlight = 0;
72
265
 
73
- async function drain(): Promise<void> {
74
- while (queue.length > 0) {
75
- const pending = queue.shift();
76
- if (!pending) break;
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
- };
110
- try {
111
- if (signal.aborted) continue;
112
- status("processing");
113
- const destination = (await adapter.prepare?.(message, signal)) ?? message;
114
- let text: string;
266
+ async function processRequest(pending: PendingMessage): Promise<void> {
267
+ const { message, adapter, signal, resolve } = pending;
268
+ let failed = false;
269
+ let activity = NO_ACTIVITY;
270
+ let release = (): void => {};
271
+ let tail: Promise<void> | undefined;
272
+ let conversation = "";
273
+ let statusChain = Promise.resolve();
274
+ const status = (state: "processing" | "success" | "failure"): void => {
275
+ if (!adapter.status) return;
276
+ statusChain = statusChain.then(async () => {
277
+ if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
278
+ const timeout = new AbortController();
279
+ const statusSignal = AbortSignal.any([signal, timeout.signal]);
280
+ const stopped = Promise.withResolvers<void>();
281
+ const onAbort = (): void => stopped.resolve();
282
+ statusSignal.addEventListener("abort", onAbort, { once: true });
283
+ statusInFlight++;
284
+ const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
285
+ void operation.then(
286
+ () => {
287
+ statusInFlight--;
288
+ },
289
+ () => {
290
+ statusInFlight--;
291
+ },
292
+ );
293
+ const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
115
294
  try {
116
- text = await reply(destination, signal);
117
- } catch (error) {
118
- failed = true;
119
- if (error instanceof MessengerFatalError) {
120
- fatalError = error;
121
- status("failure");
122
- lifetime.abort();
123
- continue;
124
- }
125
- if (signal.aborted) continue;
126
- logger.warn("Messenger agent turn failed", { platform: message.platform });
127
- text = "The request could not be completed. Please try again.";
128
- }
129
- if (!signal.aborted && text.trim()) {
130
- // A failed send may already have delivered a chunk: never retry the entire reply.
131
- await adapter.send(destination, text, signal);
295
+ await Promise.race([operation, stopped.promise]);
296
+ } catch {
297
+ if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
298
+ } finally {
299
+ clearTimeout(timer);
300
+ statusSignal.removeEventListener("abort", onAbort);
132
301
  }
133
- if (!signal.aborted) status(failed ? "failure" : "success");
134
- } catch {
135
- if (!signal.aborted) {
136
- logger.warn("Messenger reply delivery failed", { platform: message.platform });
302
+ });
303
+ };
304
+ try {
305
+ if (signal.aborted) return;
306
+ status("processing");
307
+ const destination = (await adapter.prepare?.(message, signal)) ?? message;
308
+ // Answer and deliver in arrival order per prepared conversation, so a follow-up in a new thread can
309
+ // neither overtake nor interleave with the reply of the request that created it.
310
+ conversation = messengerConversationKey(destination);
311
+ const previous = conversationTails.get(conversation);
312
+ const done = Promise.withResolvers<void>();
313
+ release = done.resolve;
314
+ tail = done.promise;
315
+ conversationTails.set(conversation, tail);
316
+ await previous;
317
+ activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
318
+ let text: string;
319
+ try {
320
+ text = await reply(destination, signal, activity.report);
321
+ } catch (error) {
322
+ failed = true;
323
+ if (error instanceof MessengerFatalError) {
324
+ fatalError = error;
137
325
  status("failure");
326
+ lifetime.abort();
327
+ return;
138
328
  }
329
+ if (signal.aborted) return;
330
+ logger.warn("Messenger agent turn failed", { platform: message.platform });
331
+ text = "The request could not be completed. Please try again.";
139
332
  } finally {
140
- resolve();
333
+ // Retire the status line alongside reply delivery rather than delaying the reply on it.
334
+ void activity.close();
335
+ }
336
+ if (!signal.aborted && text.trim()) {
337
+ // A failed send may already have delivered a chunk: never retry the entire reply.
338
+ await adapter.send(destination, text, signal);
141
339
  }
340
+ if (!signal.aborted) status(failed ? "failure" : "success");
341
+ } catch {
342
+ if (!signal.aborted) {
343
+ logger.warn("Messenger reply delivery failed", { platform: message.platform });
344
+ status("failure");
345
+ }
346
+ } finally {
347
+ await activity.close();
348
+ release();
349
+ if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
350
+ resolve();
351
+ }
352
+ }
353
+
354
+ /** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
355
+ function schedule(): void {
356
+ for (let index = 0; index < queue.length && turns.size < concurrency; ) {
357
+ const pending = queue[index]!;
358
+ if (activeKeys.has(pending.key)) {
359
+ index++;
360
+ continue;
361
+ }
362
+ queue.splice(index, 1);
363
+ activeKeys.add(pending.key);
364
+ const turn = processRequest(pending).finally(() => {
365
+ activeKeys.delete(pending.key);
366
+ turns.delete(turn);
367
+ schedule();
368
+ });
369
+ turns.add(turn);
142
370
  }
143
- workerActive = false;
144
371
  }
145
372
 
146
373
  function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
@@ -155,11 +382,8 @@ export function createMessengerGateway(
155
382
  if (oldest !== undefined) seen.delete(oldest);
156
383
  }
157
384
  const { promise, resolve } = Promise.withResolvers<void>();
158
- queue.push({ message, adapter, signal: effectiveSignal, resolve });
159
- if (!workerActive) {
160
- workerActive = true;
161
- draining = drain();
162
- }
385
+ queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
386
+ schedule();
163
387
  return promise;
164
388
  }
165
389
 
@@ -191,7 +415,7 @@ export function createMessengerGateway(
191
415
  } finally {
192
416
  lifetime.abort();
193
417
  await Promise.allSettled(starts);
194
- await draining;
418
+ while (turns.size > 0) await Promise.allSettled(turns);
195
419
  signal.removeEventListener("abort", abort);
196
420
  }
197
421
  },
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage, UserMessage } from "jeopi-ai";
2
2
  import type { InteractiveModeContext } from "../modes/types";
3
+ import { createMessengerActivityTracker } from "./activity";
3
4
  import { messengerArtifactPrompt } from "./artifacts";
4
5
  import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
5
6
  import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
@@ -156,7 +157,7 @@ export function createLiveDiscordBridge(
156
157
  let localCapture: object | undefined;
157
158
  let close: () => Promise<void>;
158
159
  let revoke: () => Promise<void>;
159
- const reply: MessengerReply = async (message, signal) => {
160
+ const reply: MessengerReply = async (message, signal, onActivity) => {
160
161
  if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
161
162
  if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
162
163
  if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
@@ -190,8 +191,13 @@ export function createLiveDiscordBridge(
190
191
  void revoke();
191
192
  };
192
193
  operationSignal.addEventListener("abort", onAbort, { once: true });
194
+ const track = onActivity && createMessengerActivityTracker(onActivity);
193
195
  const unsubscribe = attachedSession.subscribe(event => {
194
- 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
+ }
195
201
  if (event.message.role === "user") {
196
202
  const text = userMessageText(event.message);
197
203
  if (!capture.promptSeen && text === capture.promptText) {
@@ -265,6 +271,7 @@ export function createLiveDiscordBridge(
265
271
  const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
266
272
  const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
267
273
  const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
274
+ const activity = adapter.activity;
268
275
  const guardedAdapter: MessengerAdapter = {
269
276
  ...adapter,
270
277
  send: async (message, text, signal) => {
@@ -277,6 +284,13 @@ export function createLiveDiscordBridge(
277
284
  }
278
285
  await adapter.send(message, text, sendSignal);
279
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
+ }),
280
294
  };
281
295
  const setRelayDestination = (channelId: string | undefined): void => {
282
296
  if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))