@songsid/agend 2.1.4-beta.5 → 2.1.4-beta.7

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.
@@ -100,4 +100,9 @@ export interface FleetContext {
100
100
  applyEffort?(instanceName: string, level: string): Promise<string>;
101
101
  /** TG inline-keyboard effort menu; same contract as promptModelMenu. */
102
102
  promptEffortMenu?(instanceName: string, userId: string, channelId: string, adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId?: string): Promise<string | null>;
103
+ /**
104
+ * Post a nonce-armed confirmation before the destructive `/clear` command.
105
+ * Returns null when the buttons were posted, otherwise a user-facing error.
106
+ */
107
+ promptClearConfirmation?(instanceName: string, channelId: string, adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId?: string): Promise<string | null>;
103
108
  }
@@ -563,6 +563,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
563
563
  private consumeNonceCallback;
564
564
  /** Collapse a consumed prompt's buttons into a final status line. */
565
565
  private retireNonceButtons;
566
+ /** Post the destructive `/clear` confirmation in the invoking channel. */
567
+ promptClearConfirmation(instanceName: string, channelId: string, adapter: ChannelAdapter, chatId: string, threadId?: string): Promise<string | null>;
568
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
569
+ private handleClearConfirmation;
566
570
  /**
567
571
  * Drop every pending prompt that refers to an instance being stopped or
568
572
  * restarted — a restart offer for an instance the operator just restarted
@@ -30,7 +30,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
30
30
  import { routeToolCall } from "./channel/tool-router.js";
31
31
  import { Scheduler } from "./scheduler/index.js";
32
32
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
33
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
33
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, CLEAR_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
34
34
  import { DailySummary } from "./daily-summary.js";
35
35
  import { WebhookEmitter } from "./webhook-emitter.js";
36
36
  import { TmuxControlClient } from "./tmux-control.js";
@@ -175,7 +175,9 @@ const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
175
175
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
176
176
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
177
177
  const HANG_CALLBACK_PREFIX = "hang:";
178
- /** One lifetime for every nonce-armed button prompt (hang / assist / exit). */
178
+ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
179
+ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
180
+ /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
179
181
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
180
182
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
181
183
  export class FleetManager {
@@ -600,7 +602,14 @@ export class FleetManager {
600
602
  await data.respond(t("classic.no_agent"));
601
603
  return;
602
604
  }
603
- await data.respond(await this.topicCommands.sendClear(name));
605
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
606
+ if (!adapter) {
607
+ await data.respond(t("clear.prompt_unavailable"));
608
+ return;
609
+ }
610
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
611
+ const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
612
+ await data.respond(fallback ?? t("clear.confirm_posted"));
604
613
  }
605
614
  /** Get the adapter bound to an instance, falling back to primary adapter */
606
615
  getAdapterForInstance(name) {
@@ -1482,9 +1491,8 @@ export class FleetManager {
1482
1491
  // routing does NOT go through the routing engine (single-key, can't hold two
1483
1492
  // bots in one channel) — it resolves per-bot via getInstanceByChannel.
1484
1493
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
1485
- const primaryCh = fleet.channels?.[0] ?? fleet.channel;
1486
- if (primaryCh)
1487
- this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
1494
+ const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
1495
+ this.classicChannels.configureAdapters(classicAdapters);
1488
1496
  // Restore the persisted bot binding so replies/cancel go through the right
1489
1497
  // bot after a restart (before this, inbound would re-bind lazily).
1490
1498
  for (const ch of this.classicChannels.getAll()) {
@@ -2120,6 +2128,8 @@ export class FleetManager {
2120
2128
  await this.handleInboundReaction(r);
2121
2129
  }, this.logger, "adapter.reaction"));
2122
2130
  this.adapter.on("callback_query", safeHandler(async (data) => {
2131
+ if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2132
+ return;
2123
2133
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
2124
2134
  return;
2125
2135
  if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
@@ -2410,6 +2420,8 @@ export class FleetManager {
2410
2420
  await this.handleInboundReaction(r);
2411
2421
  }, this.logger, `adapter[${adapterId}].reaction`));
2412
2422
  adapter.on("callback_query", safeHandler(async (data) => {
2423
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
2424
+ return;
2413
2425
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
2414
2426
  return;
2415
2427
  if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
@@ -3190,7 +3202,11 @@ export class FleetManager {
3190
3202
  await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3191
3203
  return;
3192
3204
  }
3193
- await msgAdapter?.sendText(chatId, await this.topicCommands.sendClear(clearName));
3205
+ if (!msgAdapter)
3206
+ return;
3207
+ const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
3208
+ if (fallback)
3209
+ await msgAdapter.sendText(chatId, fallback);
3194
3210
  return;
3195
3211
  }
3196
3212
  // Handle /cancel command
@@ -4490,12 +4506,12 @@ export class FleetManager {
4490
4506
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
4491
4507
  }
4492
4508
  }
4493
- // ── Nonce-armed button prompts (hang / interactive-assist / exit-restart) ──
4509
+ // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
4494
4510
  //
4495
4511
  // One shared lifecycle for every "notification with decision buttons":
4496
- // post with a 128-bit nonce, arm a 15-min expiry, bind the click to the
4512
+ // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4497
4513
  // exact adapter+chat+thread+message that created it, require fleet admin,
4498
- // consume exactly once. The three features differ only in what they post
4514
+ // consume exactly once. The features differ only in what they post
4499
4515
  // and what a consumed click does.
4500
4516
  /**
4501
4517
  * Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
@@ -4577,12 +4593,16 @@ export class FleetManager {
4577
4593
  }
4578
4594
  // Bind the capability to the exact message/world that created it. Telegram
4579
4595
  // keyboards are visible to everyone, so the click also requires fleet admin.
4596
+ const isAuthorized = data.userId
4597
+ ? pending.authChannelId
4598
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
4599
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
4600
+ : false;
4580
4601
  if (pending.adapterId !== callbackAdapterId
4581
4602
  || data.chatId !== pending.chatId
4582
4603
  || (pending.threadId != null && data.threadId !== pending.threadId)
4583
4604
  || (pending.messageId != null && data.messageId !== pending.messageId)
4584
- || !data.userId
4585
- || !this.isFleetAdmin(data.userId, callbackAdapterId)) {
4605
+ || !isAuthorized) {
4586
4606
  // Deliberately does NOT consume the nonce: the real admin can still click.
4587
4607
  this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
4588
4608
  return "consumed";
@@ -4609,6 +4629,57 @@ export class FleetManager {
4609
4629
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
4610
4630
  }
4611
4631
  }
4632
+ /** Post the destructive `/clear` confirmation in the invoking channel. */
4633
+ async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
4634
+ if (!this.topicCommands.supportsClear(instanceName))
4635
+ return CLEAR_UNSUPPORTED_MSG;
4636
+ const adapterId = adapter.id;
4637
+ if (!adapterId)
4638
+ return t("clear.prompt_unavailable");
4639
+ const nonce = await this.postNonceButtonPrompt({
4640
+ prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
4641
+ alertType: "clear_confirm",
4642
+ instanceName,
4643
+ adapter,
4644
+ adapterId,
4645
+ chatId,
4646
+ threadId,
4647
+ message: t("clear.confirm_message", instanceName),
4648
+ choices: [
4649
+ { action: "confirm", label: t("clear.confirm") },
4650
+ { action: "cancel", label: t("clear.cancel") },
4651
+ ],
4652
+ expiredText: t("clear.expired", instanceName),
4653
+ extra: { authChannelId: channelId },
4654
+ timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
4655
+ });
4656
+ return nonce ? null : t("clear.prompt_unavailable");
4657
+ }
4658
+ /** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
4659
+ async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
4660
+ const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
4661
+ if (claimed === null)
4662
+ return false;
4663
+ if (claimed === "consumed")
4664
+ return true;
4665
+ const { entry: pending, action } = claimed;
4666
+ this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
4667
+ if (action === "cancel") {
4668
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
4669
+ return true;
4670
+ }
4671
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
4672
+ try {
4673
+ const result = await this.topicCommands.sendClear(pending.instanceName);
4674
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
4675
+ }
4676
+ catch (err) {
4677
+ const message = err instanceof Error ? err.message : String(err);
4678
+ this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
4679
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("clear.failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show clear command error"));
4680
+ }
4681
+ return true;
4682
+ }
4612
4683
  /**
4613
4684
  * Drop every pending prompt that refers to an instance being stopped or
4614
4685
  * restarted — a restart offer for an instance the operator just restarted