@agentconnect.md/daemon 1.0.0-rc.237 → 1.0.0-rc.238

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.
@@ -67655,7 +67655,7 @@ function clampTo$2(s, max) {
67655
67655
  const t = s.trim();
67656
67656
  return t.length > max ? `${t.slice(0, max - 1)}…` : t;
67657
67657
  }
67658
- function clampLabel(s) {
67658
+ function clampLabel$1(s) {
67659
67659
  return clampTo$2(s, MAX_LABEL$2);
67660
67660
  }
67661
67661
  /** Wrap tool activity (a command line, tool title, or tool output) in a CommonMark code
@@ -67722,7 +67722,7 @@ function planIcon$2(status) {
67722
67722
  /** Render an ACP plan (full entry list, resent on every update) as a compact summary.
67723
67723
  * Posted as a `markdown` block, so emphasis is CommonMark (`**bold**`), not mrkdwn. */
67724
67724
  function renderPlan$2(entries) {
67725
- return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon$2(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
67725
+ return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon$2(e.status)} ${clampLabel$1(e.content ?? "")}`)].join("\n");
67726
67726
  }
67727
67727
  /** Render the accumulated reasoning trace (high mode) as one in-place message. Posted as
67728
67728
  * a `markdown` block (verbatim CommonMark, so `**bold**`), tail-clamped to a compact cap
@@ -67733,7 +67733,7 @@ function renderReasoning$2(buf) {
67733
67733
  return `:thought_balloon: **Thinking**\n${trimmed.length > MAX_REASONING$2 ? `…${trimmed.slice(-2800)}` : trimmed}`;
67734
67734
  }
67735
67735
  /** Compact a token count as `1.2k` / `3.4M` (whole numbers under 1000 stay verbatim). */
67736
- function compactCount$1(n) {
67736
+ function compactCount$2(n) {
67737
67737
  if (n < 1e3) return String(n);
67738
67738
  if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
67739
67739
  return `${(n / 1e6).toFixed(1)}M`;
@@ -67749,9 +67749,9 @@ function renderStatusBar(info) {
67749
67749
  if (info.fastMode) parts.push("fast");
67750
67750
  if (info.contextUsed !== void 0 && info.contextSize !== void 0 && info.contextSize > 0) {
67751
67751
  const pct = Math.round(info.contextUsed / info.contextSize * 100);
67752
- parts.push(`ctx ${compactCount$1(info.contextUsed)}/${compactCount$1(info.contextSize)} (${pct}%)`);
67753
- } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$1(info.contextUsed)}`);
67754
- if (info.totalTokens !== void 0) parts.push(`${compactCount$1(info.totalTokens)} tok`);
67752
+ parts.push(`ctx ${compactCount$2(info.contextUsed)}/${compactCount$2(info.contextSize)} (${pct}%)`);
67753
+ } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$2(info.contextUsed)}`);
67754
+ if (info.totalTokens !== void 0) parts.push(`${compactCount$2(info.totalTokens)} tok`);
67755
67755
  return `:bar_chart: ${parts.length ? parts.join(" · ") : "—"}`;
67756
67756
  }
67757
67757
  /** Slack action_ids for the interactive status bar + modal (matched by the connection's
@@ -195004,12 +195004,14 @@ function humanizeDiscordText(text) {
195004
195004
  /** Normalize a Discord gateway message into the platform-agnostic contract. */
195005
195005
  function normalizeDiscordMessage(msg, ctx) {
195006
195006
  const attachments = msg.attachments.map(toAttachment).filter((a) => a !== null);
195007
+ const isDm = !msg.inGuild;
195007
195008
  return {
195008
195009
  msgId: `discord:${msg.channelId}:${msg.id}`,
195009
195010
  traceId: ctx.traceId,
195010
195011
  source: "user",
195011
195012
  platform: "discord",
195012
195013
  channel: msg.channelId,
195014
+ thread: msg.channelId,
195013
195015
  sender: {
195014
195016
  id: msg.authorId,
195015
195017
  isBot: msg.authorIsBot
@@ -195017,10 +195019,101 @@ function normalizeDiscordMessage(msg, ctx) {
195017
195019
  text: humanizeDiscordText(msg.content),
195018
195020
  mentionedBots: msg.mentionUserIds,
195019
195021
  ...attachments.length ? { attachments } : {},
195020
- isDm: !msg.inGuild
195022
+ isDm,
195023
+ ...!isDm && !msg.isThread ? { discordTopLevel: true } : {}
195021
195024
  };
195022
195025
  }
195023
195026
  //#endregion
195027
+ //#region src/discord/app-commands.ts
195028
+ /**
195029
+ * Native Discord application (slash) command definitions, registered globally on
195030
+ * connect (see DiscordConnection.start → registerCommands). They make the same
195031
+ * control vocabulary the daemon already parses from plain `/text` messages
195032
+ * (see commands.ts) show up in Discord's `/` autocomplete menu with descriptions
195033
+ * and typed arguments.
195034
+ *
195035
+ * These are NOT a second command engine: a slash invocation is reconstructed back
195036
+ * into its `/text` form (DiscordConnection.slashCommandText) and fed through the
195037
+ * SAME onInbound → parseCommand → handleCommand path as a typed message, so the
195038
+ * names/args here MUST stay in lockstep with parseCommand's vocabulary (guarded by
195039
+ * discord-app-commands.test.ts).
195040
+ *
195041
+ * PREREQUISITE: the bot must be invited with the `applications.commands` OAuth2
195042
+ * scope, else `commands.set` fails with 403 Missing Access and nothing appears.
195043
+ *
195044
+ * The select commands (`/models` `/effort` `/permission`) take an OPTIONAL free-text
195045
+ * argument — omitting it lists the choices as a tappable button card (buildDiscord-
195046
+ * SelectComponents); passing it selects directly. Choices aren't pre-declared because
195047
+ * the offered options are model-dependent and only known once a session is warm.
195048
+ */
195049
+ const DISCORD_APP_COMMANDS = [
195050
+ {
195051
+ name: "status",
195052
+ description: "Show this session's model, context and token usage"
195053
+ },
195054
+ {
195055
+ name: "models",
195056
+ description: "List models, or switch (leave blank for a tappable picker)",
195057
+ options: [{
195058
+ name: "model",
195059
+ description: "Model id, name, or list number",
195060
+ type: import_src.ApplicationCommandOptionType.String
195061
+ }]
195062
+ },
195063
+ {
195064
+ name: "effort",
195065
+ description: "List reasoning-effort levels, or switch",
195066
+ options: [{
195067
+ name: "level",
195068
+ description: "Effort level or list number",
195069
+ type: import_src.ApplicationCommandOptionType.String
195070
+ }]
195071
+ },
195072
+ {
195073
+ name: "permission",
195074
+ description: "List permission modes, or switch",
195075
+ options: [{
195076
+ name: "mode",
195077
+ description: "Permission mode or list number",
195078
+ type: import_src.ApplicationCommandOptionType.String
195079
+ }]
195080
+ },
195081
+ {
195082
+ name: "fast",
195083
+ description: "Toggle fast mode for this session",
195084
+ options: [{
195085
+ name: "state",
195086
+ description: "on or off",
195087
+ type: import_src.ApplicationCommandOptionType.String,
195088
+ choices: [{
195089
+ name: "on",
195090
+ value: "on"
195091
+ }, {
195092
+ name: "off",
195093
+ value: "off"
195094
+ }]
195095
+ }]
195096
+ },
195097
+ {
195098
+ name: "stop",
195099
+ description: "Stop the agent and mute this thread until @mentioned"
195100
+ },
195101
+ {
195102
+ name: "cancel",
195103
+ description: "Cancel the in-flight turn (session stays live)"
195104
+ },
195105
+ {
195106
+ name: "queue",
195107
+ description: "Queue a message to run once the agent is idle",
195108
+ options: [{
195109
+ name: "message",
195110
+ description: "The message to queue",
195111
+ type: import_src.ApplicationCommandOptionType.String,
195112
+ required: true
195113
+ }]
195114
+ }
195115
+ ];
195116
+ //#endregion
195024
195117
  //#region src/discord/render.ts
195025
195118
  /** Discord single-message hard cap (2000 chars) — vs Telegram's 4096, Slack's 12000-char block. */
195026
195119
  const DISCORD_MESSAGE_LIMIT = 2e3;
@@ -195100,6 +195193,23 @@ function renderReasoning$1(buf) {
195100
195193
  const trimmed = buf.trim();
195101
195194
  return `💭 **Thinking**\n${trimmed.length > MAX_REASONING$1 ? `…${trimmed.slice(-2800)}` : trimmed}`;
195102
195195
  }
195196
+ function compactCount$1(n) {
195197
+ if (n < 1e3) return String(n);
195198
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
195199
+ return `${(n / 1e6).toFixed(1)}M`;
195200
+ }
195201
+ /** The compact status line (markdown), e.g. `📊 **opus-4.8** · fast · ctx 120k/200k (60%) · 45.2k tok`. */
195202
+ function renderStatusText$1(info) {
195203
+ const parts = [];
195204
+ if (info.model) parts.push(`**${info.model}**`);
195205
+ if (info.fastMode) parts.push("fast");
195206
+ if (info.contextUsed !== void 0 && info.contextSize !== void 0 && info.contextSize > 0) {
195207
+ const pct = Math.round(info.contextUsed / info.contextSize * 100);
195208
+ parts.push(`ctx ${compactCount$1(info.contextUsed)}/${compactCount$1(info.contextSize)} (${pct}%)`);
195209
+ } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$1(info.contextUsed)}`);
195210
+ if (info.totalTokens !== void 0) parts.push(`${compactCount$1(info.totalTokens)} tok`);
195211
+ return `📊 ${parts.length ? parts.join(" · ") : "—"}`;
195212
+ }
195103
195213
  /** custom_id verbs for the status button row. Discord caps custom_id at 100 chars
195104
195214
  * but we keep the verb STATELESS (mirroring Telegram's callback_data) — the
195105
195215
  * connection maps the button's message (channel+message_id) back to its session
@@ -195123,6 +195233,78 @@ function parseDiscordCallback(data) {
195123
195233
  };
195124
195234
  return null;
195125
195235
  }
195236
+ /** One-char code ↔ kind for the compact select custom_id (`ac_sel:<code>:<index>`). */
195237
+ const DISCORD_SELECT_CODE = {
195238
+ model: "m",
195239
+ effort: "e",
195240
+ permission: "p"
195241
+ };
195242
+ const SELECT_CODE_KIND$1 = {
195243
+ m: "model",
195244
+ e: "effort",
195245
+ p: "permission"
195246
+ };
195247
+ const SELECT_PREFIX = "ac_sel";
195248
+ /** Discord caps a button label at 80 chars and an action row at 5 buttons / a message
195249
+ * at 5 rows → 25 options fit as a card; past that the daemon falls back to a text list. */
195250
+ const MAX_BUTTON_LABEL = 80;
195251
+ const MAX_BUTTONS_PER_ROW = 5;
195252
+ const MAX_SELECT_OPTIONS = MAX_BUTTONS_PER_ROW * 5;
195253
+ function clampLabel(s) {
195254
+ return s.length > MAX_BUTTON_LABEL ? `${s.slice(0, MAX_BUTTON_LABEL - 1)}…` : s;
195255
+ }
195256
+ /** Parse a select-button custom_id (`ac_sel:<code>:<index>`) into a kind + option index
195257
+ * (null if it isn't one). Stateless like the status verbs — the connection maps the
195258
+ * button's message back to its session key (see parseDiscordCallback's note). */
195259
+ function parseDiscordSelect(data) {
195260
+ const m = new RegExp(`^${SELECT_PREFIX}:([mep]):(\\d+)$`).exec(data);
195261
+ if (!m) return null;
195262
+ const kind = SELECT_CODE_KIND$1[m[1]];
195263
+ const index = Number(m[2]);
195264
+ if (!kind || !Number.isInteger(index)) return null;
195265
+ return {
195266
+ kind,
195267
+ index
195268
+ };
195269
+ }
195270
+ /**
195271
+ * Build a `/models` `/effort` `/permission` select as tappable button rows — one button
195272
+ * per option (the current flagged ✅, styled Success), custom_id = `ac_sel:<code>:<index>`.
195273
+ * The Discord analog of the daemon's buildSelectCard (Telegram inline keyboard). Returns
195274
+ * null when there are no options or too many to fit Discord's 25-button ceiling, so the
195275
+ * caller falls back to a numbered text list.
195276
+ */
195277
+ function buildDiscordSelectComponents(kind, current, options) {
195278
+ if (options.length === 0 || options.length > MAX_SELECT_OPTIONS) return null;
195279
+ const code = DISCORD_SELECT_CODE[kind];
195280
+ const rows = [];
195281
+ for (let i = 0; i < options.length; i += MAX_BUTTONS_PER_ROW) rows.push({
195282
+ type: 1,
195283
+ components: options.slice(i, i + MAX_BUTTONS_PER_ROW).map((o, j) => {
195284
+ const isCurrent = o === current;
195285
+ return {
195286
+ type: 2,
195287
+ style: isCurrent ? 3 : 2,
195288
+ label: clampLabel(`${isCurrent ? "✅ " : ""}${o}`),
195289
+ custom_id: `${SELECT_PREFIX}:${code}:${i + j}`
195290
+ };
195291
+ })
195292
+ });
195293
+ return rows;
195294
+ }
195295
+ /** A lone "View session" link-button row (Discord style-5). Used by `/status`, where a
195296
+ * raw Slack `<url|text>` link would render as literal text on Discord. */
195297
+ function buildLinkComponents(link, label = "🔗 View session") {
195298
+ return [{
195299
+ type: 1,
195300
+ components: [{
195301
+ type: 2,
195302
+ style: 5,
195303
+ label,
195304
+ url: link
195305
+ }]
195306
+ }];
195307
+ }
195126
195308
  var DiscordConverger = class {
195127
195309
  mode;
195128
195310
  buf = "";
@@ -195307,21 +195489,37 @@ var DiscordConnection = class {
195307
195489
  }
195308
195490
  });
195309
195491
  client.on(import_src.Events.InteractionCreate, (interaction) => {
195492
+ if (interaction.isChatInputCommand()) {
195493
+ this.onSlashCommand(interaction);
195494
+ return;
195495
+ }
195310
195496
  if (!interaction.isButton()) return;
195311
195497
  interaction.deferUpdate().catch(() => {});
195312
- const action = parseDiscordCallback(interaction.customId);
195313
- if (!action) return;
195314
- const sessionKey = this.statusKeys.get(`${interaction.channelId}:${interaction.message.id}`);
195498
+ const channel = interaction.channelId;
195499
+ const sessionKey = this.statusKeys.get(`${channel}:${interaction.message.id}`);
195315
195500
  if (!sessionKey) return;
195316
- if (action.kind === "cancel") this.deps.onStatusAction?.({
195317
- kind: "cancel",
195318
- sessionKey
195319
- });
195320
- else this.deps.onStatusAction?.({
195321
- kind: "set-fast",
195322
- sessionKey,
195323
- fastMode: action.fastMode
195324
- });
195501
+ const status = parseDiscordCallback(interaction.customId);
195502
+ if (status) {
195503
+ if (status.kind === "cancel") this.deps.onStatusAction?.({
195504
+ kind: "cancel",
195505
+ sessionKey
195506
+ });
195507
+ else this.deps.onStatusAction?.({
195508
+ kind: "set-fast",
195509
+ sessionKey,
195510
+ fastMode: status.fastMode
195511
+ });
195512
+ return;
195513
+ }
195514
+ const sel = parseDiscordSelect(interaction.customId);
195515
+ if (sel && this.deps.onSelectAction) {
195516
+ const rendered = this.deps.onSelectAction({
195517
+ kind: sel.kind,
195518
+ index: sel.index,
195519
+ sessionKey
195520
+ });
195521
+ if (rendered) this.updateMessage(channel, interaction.message.id, rendered.text, { keyboard: rendered.components });
195522
+ }
195325
195523
  });
195326
195524
  client.on(import_src.Events.Error, (err) => log?.debug(`discord: client error: ${err.message}`));
195327
195525
  log?.debug("discord: login → opening Gateway…");
@@ -195330,11 +195528,68 @@ var DiscordConnection = class {
195330
195528
  this.botUserId = c.user.id;
195331
195529
  this.botUsername = c.user.username;
195332
195530
  log?.debug(`discord: ready → bot @${this.botUsername} (id ${this.botUserId})`);
195531
+ this.registerCommands();
195333
195532
  resolve();
195334
195533
  });
195335
195534
  client.login(this.botToken).catch(reject);
195336
195535
  });
195337
195536
  }
195537
+ /** Register the control vocabulary as global Discord application commands so it shows
195538
+ * up in the `/` autocomplete menu. Global registration covers every guild + DMs;
195539
+ * first-time propagation can take up to ~1h (edits are near-instant thereafter).
195540
+ * Best-effort — a 403 almost always means the bot wasn't invited with the
195541
+ * `applications.commands` scope, so we log that hint rather than throwing. */
195542
+ async registerCommands() {
195543
+ try {
195544
+ await this.client.application?.commands.set(DISCORD_APP_COMMANDS);
195545
+ this.deps.log?.info(`discord: registered ${DISCORD_APP_COMMANDS.length} slash commands (global) for @${this.botUsername}`);
195546
+ } catch (err) {
195547
+ this.deps.log?.error(`discord: slash-command registration failed — is the bot invited with the applications.commands scope? (${err.message})`);
195548
+ }
195549
+ }
195550
+ /**
195551
+ * A native slash-command invocation. Ack within Discord's 3s window (an ephemeral
195552
+ * echo, seen only by the invoker), then reconstruct the `/text` form and feed it
195553
+ * through the SAME onMessage path as a typed command — so routing, authz, the reply,
195554
+ * and the tappable cards all reuse handleCommand. The real output posts to the
195555
+ * channel/thread (public), matching how a typed `/command` behaves today.
195556
+ */
195557
+ onSlashCommand(interaction) {
195558
+ const text = this.slashCommandText(interaction);
195559
+ interaction.reply({
195560
+ content: text,
195561
+ flags: import_src.MessageFlags.Ephemeral
195562
+ }).catch(() => {});
195563
+ try {
195564
+ const msg = normalizeDiscordMessage(this.slashToLike(interaction, text), { traceId: this.deps.newTraceId() });
195565
+ this.deps.log?.debug(`discord: slash ${text} ch=${msg.channel} user=${msg.sender.id}`);
195566
+ this.deps.onMessage(msg);
195567
+ } catch (err) {
195568
+ this.deps.log?.debug(`discord: slash command failed: ${err.message}`);
195569
+ }
195570
+ }
195571
+ /** Reconstruct a slash invocation into its `/text` form (`/models opus`, `/queue do X`)
195572
+ * so parseCommand handles it exactly like a typed message. Options are appended in
195573
+ * declaration order; our control commands take at most one. */
195574
+ slashCommandText(interaction) {
195575
+ const args = interaction.options.data.map((o) => o.value === void 0 || o.value === null ? "" : String(o.value)).join(" ").trim();
195576
+ return args ? `/${interaction.commandName} ${args}` : `/${interaction.commandName}`;
195577
+ }
195578
+ /** Adapt a slash interaction to the pure normalizer's plain-object view (text = the
195579
+ * reconstructed command). Mirrors toLike; no attachments/mentions on a command. */
195580
+ slashToLike(interaction, text) {
195581
+ return {
195582
+ id: interaction.id,
195583
+ channelId: interaction.channelId,
195584
+ content: text,
195585
+ authorId: interaction.user.id,
195586
+ authorIsBot: interaction.user.bot,
195587
+ inGuild: interaction.inGuild(),
195588
+ isThread: interaction.channel?.isThread() ?? false,
195589
+ mentionUserIds: [],
195590
+ attachments: []
195591
+ };
195592
+ }
195338
195593
  /** Adapt a live gateway Message to the pure normalizer's plain-object view. */
195339
195594
  toLike(message) {
195340
195595
  return {
@@ -195344,6 +195599,7 @@ var DiscordConnection = class {
195344
195599
  authorId: message.author.id,
195345
195600
  authorIsBot: message.author.bot,
195346
195601
  inGuild: message.inGuild(),
195602
+ isThread: message.channel.isThread(),
195347
195603
  mentionUserIds: [...message.mentions.users.keys()],
195348
195604
  attachments: [...message.attachments.values()].map((a) => ({
195349
195605
  id: a.id,
@@ -195361,6 +195617,25 @@ var DiscordConnection = class {
195361
195617
  return null;
195362
195618
  }
195363
195619
  /**
195620
+ * Open a thread off a channel message so a turn's reply + chrome live in the thread
195621
+ * rather than the parent channel (Slack-parity — see daemon's Discord ingress). The
195622
+ * created thread's id EQUALS the source message id (a Discord guarantee), so the
195623
+ * caller re-keys the session onto it and follow-ups posted in the thread (which
195624
+ * arrive with `channelId` = thread id) continue the same session. Best-effort:
195625
+ * returns the thread id, or undefined when the message/channel can't be resolved or
195626
+ * the bot lacks the Create Public Threads permission (caller then replies in-channel).
195627
+ */
195628
+ async createThread(channelId, messageId, name) {
195629
+ try {
195630
+ const ch = await this.sendableChannel(channelId);
195631
+ if (!ch?.messages) return void 0;
195632
+ return (await (await ch.messages.fetch(messageId)).startThread({ name: name.slice(0, 100) || "Agent thread" })).id;
195633
+ } catch (err) {
195634
+ this.deps.log?.debug(`discord: createThread failed (ch=${channelId} msg=${messageId}): ${err.message}`);
195635
+ return;
195636
+ }
195637
+ }
195638
+ /**
195364
195639
  * Post a message. `channel` is already the concrete channel/thread id (Discord
195365
195640
  * threads are their own channels — see normalize.ts), so `threadTs` is unused —
195366
195641
  * the session channel IS the reply target. Long text is chunked to the 2000-char
@@ -213463,6 +213738,12 @@ function pendingSessionKey(p) {
213463
213738
  channel: p.channel
213464
213739
  };
213465
213740
  }
213741
+ /** Name for a Discord thread opened off a top-level @mention: the first line of the
213742
+ * prompt, collapsed to one line and clamped to Discord's 100-char thread-name cap
213743
+ * (createThread also clamps). Empty prompts (e.g. attachment-only) get a default. */
213744
+ function discordThreadName(text) {
213745
+ return text.replace(/\s+/g, " ").trim().slice(0, 90) || "Agent thread";
213746
+ }
213466
213747
  var Daemon = class Daemon {
213467
213748
  opts;
213468
213749
  store;
@@ -213966,6 +214247,7 @@ var Daemon = class Daemon {
213966
214247
  newTraceId: () => randomUUID(),
213967
214248
  onMessage: (msg) => this.onInbound(msg),
213968
214249
  onStatusAction: (a) => this.handleStatusAction(a),
214250
+ onSelectAction: (a) => this.handleDiscordSelect(a),
213969
214251
  log: this.log
213970
214252
  });
213971
214253
  try {
@@ -214186,9 +214468,33 @@ var Daemon = class Daemon {
214186
214468
  this.log.info(`routing: agent "${result.agentId}" un-muted in ch=${msg.channel} (explicit @mention)`);
214187
214469
  }
214188
214470
  this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
214471
+ if (msg.platform === "discord" && msg.discordTopLevel) {
214472
+ this.dispatchDiscordTopLevel(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
214473
+ return;
214474
+ }
214189
214475
  this.dispatch(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
214190
214476
  }
214191
214477
  /**
214478
+ * Discord top-level channel @mention (§Slack-parity threading): open a thread off the
214479
+ * triggering message so the whole turn — reply + progress/status chrome — lands in a
214480
+ * thread instead of flooding the channel. The new thread's id equals the message id, so
214481
+ * we re-key the turn onto the thread channel; every follow-up posted there arrives with
214482
+ * `channelId` = thread id and normalizes to the SAME session, giving the conversation
214483
+ * continuity. Best-effort: if thread creation fails (e.g. the bot lacks Create Public
214484
+ * Threads), we fall back to replying in the channel (the pre-thread behavior).
214485
+ */
214486
+ async dispatchDiscordTopLevel(agentId, msg, integrationId) {
214487
+ const conn = this.dcConnByIntegration.get(integrationId);
214488
+ const messageId = msg.msgId.split(":").pop() ?? "";
214489
+ const threadId = conn ? await conn.createThread(msg.channel, messageId, discordThreadName(msg.text)) : void 0;
214490
+ if (threadId) {
214491
+ this.log.info(`discord: opened thread ${threadId} for ch=${msg.channel} msg=${messageId}`);
214492
+ msg.channel = threadId;
214493
+ msg.thread = threadId;
214494
+ } else this.log.debug(`discord: no thread opened for ch=${msg.channel} — replying in channel`);
214495
+ await this.dispatch(agentId, msg, integrationId);
214496
+ }
214497
+ /**
214192
214498
  * Handle an inbound webchat turn (C→D `webchat/message`). webchat is the one
214193
214499
  * integration whose data plane is the daemon↔CP control WS itself — the far end is
214194
214500
  * the CP/browser, so the body arrives inline and the reply streams back over the
@@ -214388,6 +214694,28 @@ var Daemon = class Daemon {
214388
214694
  if (a.outputMode) this.setOutputModeByKey(a.sessionKey, a.outputMode);
214389
214695
  }
214390
214696
  }
214697
+ /**
214698
+ * A tapped Discord select-card button (`/models` `/effort` `/permission`): resolve the
214699
+ * session from the button's key, apply the chosen option, and return the re-rendered
214700
+ * card so the connection edits the message in place (new current flagged). Undefined
214701
+ * when the session is gone or the option index is stale (options changed) — the
214702
+ * connection then leaves the card as-is. Mirrors handleTelegramCallback.
214703
+ */
214704
+ handleDiscordSelect(a) {
214705
+ const rec = this.store.getSession(a.sessionKey);
214706
+ if (!rec) return void 0;
214707
+ const info = this.statusInfoFrom(rec.agentId, a.sessionKey, rec.acpSessionId ?? void 0);
214708
+ const { options } = this.selectOptions(a.kind, info);
214709
+ const value = options[a.index];
214710
+ if (value === void 0) return void 0;
214711
+ this.applySelect(a.kind, a.sessionKey, value);
214712
+ const components = buildDiscordSelectComponents(a.kind, value, options);
214713
+ if (!components) return void 0;
214714
+ return {
214715
+ text: this.selectCardText(a.kind, value),
214716
+ components
214717
+ };
214718
+ }
214391
214719
  /** All of our own connections' bot identities (both `U…` user ids and `B…`
214392
214720
  * bot_ids), so we recognize self-echoes regardless of which field Slack sends. */
214393
214721
  ownBotIdentities() {
@@ -214532,6 +214860,7 @@ var Daemon = class Daemon {
214532
214860
  threadTs: replyThread,
214533
214861
  replyTo: tgReplyTo
214534
214862
  });
214863
+ else if (msg.platform === "discord") conn?.postChrome(msg.channel, renderStatusText$1(info), link ? { keyboard: buildLinkComponents(link) } : {});
214535
214864
  else reply(link ? `${renderStatusBar(info)} · <${link}|View session>` : renderStatusBar(info));
214536
214865
  return;
214537
214866
  }
@@ -214553,11 +214882,23 @@ var Daemon = class Daemon {
214553
214882
  reply("No active session here to configure.");
214554
214883
  return;
214555
214884
  }
214556
- const card = msg.platform === "telegram" && conn ? (text, buttons) => void conn.postCard(msg.channel, text, buttons, {
214557
- threadTs: replyThread,
214558
- replyTo: tgReplyTo
214559
- }) : void 0;
214560
- this.handleSelectCommand(command.kind, command.value, target.agentId, key, acpSessionId ?? void 0, reply, card);
214885
+ const renderCard = msg.platform === "telegram" && conn ? (kind, current, options) => {
214886
+ const { text, buttons } = this.buildSelectCard(kind, current, options);
214887
+ conn.postCard(msg.channel, text, buttons, {
214888
+ threadTs: replyThread,
214889
+ replyTo: tgReplyTo
214890
+ });
214891
+ return true;
214892
+ } : msg.platform === "discord" && conn ? (kind, current, options) => {
214893
+ const components = buildDiscordSelectComponents(kind, current, options);
214894
+ if (!components) return false;
214895
+ conn.postChrome(msg.channel, this.selectCardText(kind, current), {
214896
+ keyboard: components,
214897
+ sessionKey: key
214898
+ });
214899
+ return true;
214900
+ } : void 0;
214901
+ this.handleSelectCommand(command.kind, command.value, target.agentId, key, acpSessionId ?? void 0, reply, renderCard);
214561
214902
  return;
214562
214903
  }
214563
214904
  if (!command.text) {
@@ -214609,6 +214950,11 @@ var Daemon = class Daemon {
214609
214950
  else if (kind === "effort") this.setEffortByKey(key, value);
214610
214951
  else this.setPermissionModeByKey(key, value);
214611
214952
  }
214953
+ /** Header line for a select card (shared by the Telegram inline-keyboard card and the
214954
+ * Discord button card). */
214955
+ selectCardText(kind, current) {
214956
+ return `${this.selectLabel(kind)} — tap to switch (current: ${current ?? "default"}):`;
214957
+ }
214612
214958
  /** Build a session-control card: a header line + one tappable button per option (the
214613
214959
  * current one flagged), with `callback_data` = `<kindCode>:<optionIndex>`. */
214614
214960
  buildSelectCard(kind, current, options) {
@@ -214618,7 +214964,7 @@ var Daemon = class Daemon {
214618
214964
  callbackData: `${code}:${i}`
214619
214965
  }]);
214620
214966
  return {
214621
- text: `${this.selectLabel(kind)} — tap to switch (current: ${current ?? "default"}):`,
214967
+ text: this.selectCardText(kind, current),
214622
214968
  buttons
214623
214969
  };
214624
214970
  }
@@ -214630,7 +214976,7 @@ var Daemon = class Daemon {
214630
214976
  * live host's config selectors (statusInfoFrom); when the host is cold a given value is
214631
214977
  * accepted optimistically and takes effect on the next turn.
214632
214978
  */
214633
- handleSelectCommand(kind, value, agentId, key, acpSessionId, reply, card) {
214979
+ handleSelectCommand(kind, value, agentId, key, acpSessionId, reply, renderCard) {
214634
214980
  const label = this.selectLabel(kind);
214635
214981
  const info = this.statusInfoFrom(agentId, key, acpSessionId);
214636
214982
  const { current, options } = this.selectOptions(kind, info);
@@ -214640,11 +214986,7 @@ var Daemon = class Daemon {
214640
214986
  reply(current ? `${label}: ${current} (no other options offered${kind === "effort" ? " — the current model may not support effort" : ""}).` : `No ${label.toLowerCase()} options available yet — send me a message first, then try /${cmd} again.`);
214641
214987
  return;
214642
214988
  }
214643
- if (card) {
214644
- const { text, buttons } = this.buildSelectCard(kind, current, options);
214645
- card(text, buttons);
214646
- return;
214647
- }
214989
+ if (renderCard?.(kind, current, options)) return;
214648
214990
  reply(`${label} — reply \`/${cmd} <name or number>\`:\n${options.map((o, i) => `${i + 1}. ${o}${o === current ? " ✓ (current)" : ""}`).join("\n")}`);
214649
214991
  return;
214650
214992
  }
@@ -216065,4 +216407,4 @@ async function runForeground(opts, deps = {}) {
216065
216407
  //#endregion
216066
216408
  export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
216067
216409
 
216068
- //# sourceMappingURL=run-foreground-C389T7yc.js.map
216410
+ //# sourceMappingURL=run-foreground-R9O-KCZr.js.map