@songsid/agend 2.3.3-beta.8 → 2.3.3-beta.9

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.
@@ -2849,6 +2849,21 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
2849
2849
  * would have to make this async and change every caller.
2850
2850
  */
2851
2851
  notifyInstanceTopic(instanceName: string, text: string, extraOpts?: import("./channel/types.js").SendOpts): boolean;
2852
+ /**
2853
+ * notifyInstanceTopic's routing, handing back the send itself — and which world sent it — for a caller that must
2854
+ * edit or delete the message later (#2897). Null when there is no adapter or no postable target (already logged).
2855
+ */
2856
+ private sendToInstanceTopic;
2857
+ private parkedNoticesStore;
2858
+ private get parkedNotices();
2859
+ /**
2860
+ * A held CLI dialog's notices: the instance-topic one and the General one, each edited in place while the
2861
+ * dialog stays (one live message per place, never a pile), and taken back by resolveParkedNotice.
2862
+ */
2863
+ showParkedNotice(instanceName: string, topicText: string | null, generalText: string | null): void;
2864
+ /** The held dialog is resolved, or the instance stopped or restarted: delete its notices (no-op when none). */
2865
+ resolveParkedNotice(instanceName: string): void;
2866
+ private postParkedNotice;
2852
2867
  /**
2853
2868
  * Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
2854
2869
  * The entry is registered before the send and rolled back if the send
@@ -80,6 +80,7 @@ import { TmuxManager } from "./tmux-manager.js";
80
80
  import { AccessManager } from "./channel/access-manager.js";
81
81
  import { IpcClient } from "./channel/ipc-bridge.js";
82
82
  import { usesChannelAddress } from "./channel/mcp-tools.js";
83
+ import { ParkedNotices, PARKED_NOTICE_LEDGER_FILE } from "./parked-notices.js";
83
84
  import { formatFleetTime } from "./tz-utils.js";
84
85
  import { createAdapter } from "./channel/factory.js";
85
86
  import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
@@ -3079,6 +3080,7 @@ export class FleetManager {
3079
3080
  if (this.reloadPending)
3080
3081
  this.scheduleReconcile();
3081
3082
  void this.sweepOrphanedCancelButtons();
3083
+ void this.parkedNotices.sweep();
3082
3084
  }
3083
3085
  /**
3084
3086
  * We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
@@ -14278,11 +14280,22 @@ export class FleetManager {
14278
14280
  * would have to make this async and change every caller.
14279
14281
  */
14280
14282
  notifyInstanceTopic(instanceName, text, extraOpts) {
14283
+ const send = this.sendToInstanceTopic(instanceName, text, extraOpts);
14284
+ if (!send)
14285
+ return false;
14286
+ send.sent.catch(e => this.logger.warn({ err: e, instanceName }, send.failure));
14287
+ return true;
14288
+ }
14289
+ /**
14290
+ * notifyInstanceTopic's routing, handing back the send itself — and which world sent it — for a caller that must
14291
+ * edit or delete the message later (#2897). Null when there is no adapter or no postable target (already logged).
14292
+ */
14293
+ sendToInstanceTopic(instanceName, text, extraOpts) {
14281
14294
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
14282
14295
  if (!adapter) {
14283
14296
  // Early startup: adapters are created well after the fleet object exists.
14284
14297
  this.logger.warn({ instanceName }, "No adapter yet — instance topic notification not sent");
14285
- return false;
14298
+ return null;
14286
14299
  }
14287
14300
  const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
14288
14301
  const groupId = channelCfg?.group_id;
@@ -14291,17 +14304,13 @@ export class FleetManager {
14291
14304
  const worldId = this.getInstanceAdapterId(instanceName);
14292
14305
  if (threadId != null && groupId) {
14293
14306
  const out = this.renderPlaces(worldId, text, { threadId: String(threadId), ...extraOpts });
14294
- adapter.sendText(String(groupId), out.text, out.opts)
14295
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
14296
- return true;
14307
+ return { adapterId: worldId, sent: adapter.sendText(String(groupId), out.text, out.opts), failure: "Failed to send instance topic notification" };
14297
14308
  }
14298
14309
  // Classic instance: find its channelId from the classic manager
14299
14310
  const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
14300
14311
  if (classicChatId) {
14301
14312
  // #1990: a ClassicBot room's readers need not be in the fleet's group, so its notices name places as plain text.
14302
- adapter.sendText(classicChatId, stripPlaceRefs(text), extraOpts)
14303
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
14304
- return true;
14313
+ return { adapterId: worldId, sent: adapter.sendText(classicChatId, stripPlaceRefs(text), extraOpts), failure: "Failed to send classic notification" };
14305
14314
  }
14306
14315
  // Fallback: the instance has neither a topic nor a classic channel, so post
14307
14316
  // where fleet-wide notices go. Not the bare group id: on Discord that is a
@@ -14309,12 +14318,67 @@ export class FleetManager {
14309
14318
  const target = this.fleetNoticeTarget(worldId);
14310
14319
  if (target) {
14311
14320
  const out = this.renderPlaces(worldId, text, { ...target.opts, ...extraOpts });
14312
- adapter.sendText(target.chatId, out.text, out.opts)
14313
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
14314
- return true;
14321
+ return { adapterId: worldId, sent: adapter.sendText(target.chatId, out.text, out.opts), failure: "Failed to send notification (no topic)" };
14315
14322
  }
14316
14323
  this.logger.warn({ instanceName }, "No postable target — instance topic notification not sent");
14317
- return false;
14324
+ return null;
14325
+ }
14326
+ // ── Parked-dialog notices (#2897) ──
14327
+ parkedNoticesStore = null;
14328
+ get parkedNotices() {
14329
+ this.parkedNoticesStore ??= new ParkedNotices({
14330
+ file: join(this.dataDir, PARKED_NOTICE_LEDGER_FILE),
14331
+ logger: this.logger,
14332
+ post: (instanceName, place, text) => this.postParkedNotice(instanceName, place, text),
14333
+ adapterFor: adapterId => this.worlds.get(adapterId)?.adapter
14334
+ ?? (adapterId === this.getPrimaryAdapterId() ? this.adapter ?? undefined : undefined),
14335
+ resolvedText: () => t("fleet.dialog_parked_resolved"),
14336
+ // A ClassicBot room names places as plain text (#1990), as its post did.
14337
+ render: (row, text) => row.place === "topic" && this.classicChannels?.getChannelIdByInstance(row.instanceName) === row.chatId
14338
+ ? stripPlaceRefs(text) : this.renderPlaces(row.adapterId, text).text,
14339
+ });
14340
+ return this.parkedNoticesStore;
14341
+ }
14342
+ /**
14343
+ * A held CLI dialog's notices: the instance-topic one and the General one, each edited in place while the
14344
+ * dialog stays (one live message per place, never a pile), and taken back by resolveParkedNotice.
14345
+ */
14346
+ showParkedNotice(instanceName, topicText, generalText) {
14347
+ if (topicText !== null)
14348
+ void this.parkedNotices.show(instanceName, "topic", topicText);
14349
+ if (generalText !== null)
14350
+ void this.parkedNotices.show(instanceName, "general", generalText);
14351
+ }
14352
+ /** The held dialog is resolved, or the instance stopped or restarted: delete its notices (no-op when none). */
14353
+ resolveParkedNotice(instanceName) {
14354
+ void this.parkedNotices.resolve(instanceName);
14355
+ }
14356
+ async postParkedNotice(instanceName, place, text) {
14357
+ let send;
14358
+ if (place === "topic") {
14359
+ send = this.sendToInstanceTopic(instanceName, text);
14360
+ }
14361
+ else {
14362
+ // Where notifyFleetError posts, without its text throttle: the ledger is what keeps this to one message.
14363
+ const general = Object.entries(this.fleetConfig?.instances ?? {})
14364
+ .find(([, config]) => config.general_topic === true)?.[0];
14365
+ const target = general ? null : this.fleetNoticeTarget();
14366
+ if (general)
14367
+ send = this.sendToInstanceTopic(general, text);
14368
+ else if (this.adapter && target) {
14369
+ const out = this.renderPlaces(this.getPrimaryAdapterId(), text, target.opts);
14370
+ send = { adapterId: this.getPrimaryAdapterId(), sent: this.adapter.sendText(target.chatId, out.text, out.opts) };
14371
+ }
14372
+ else
14373
+ send = null;
14374
+ }
14375
+ if (!send)
14376
+ return null;
14377
+ const sent = await send.sent;
14378
+ const adapterId = send.adapterId ?? this.getPrimaryAdapterId();
14379
+ if (!adapterId)
14380
+ return null;
14381
+ return { adapterId, chatId: sent.chatId, ...(sent.threadId !== undefined ? { threadId: sent.threadId } : {}), messageId: sent.messageId };
14318
14382
  }
14319
14383
  // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
14320
14384
  //
@@ -21215,6 +21279,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21215
21279
  for (const timer of this.cancelButtonIdleRetireTimers.values())
21216
21280
  clearTimeout(timer);
21217
21281
  this.cancelButtonIdleRetireTimers.clear();
21282
+ // #2897: notices still being retired stay in the ledger; the next fleet's sweep finishes them.
21283
+ this.parkedNoticesStore?.dispose();
21218
21284
  if (this.topicCleanupTimer) {
21219
21285
  clearInterval(this.topicCleanupTimer);
21220
21286
  this.topicCleanupTimer = null;