@openclaw/clickclack 2026.9.8 → 2026.10.1-beta.2

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.
@@ -1,10 +1,12 @@
1
- import { h as resolveClickClackAccount, p as listClickClackAccountIds } from "./setup-surface-BW1Q06w5.mjs";
2
- import { C as slugifyDiscussionLabel, D as listPendingDiscussionOpens, E as clearPendingDiscussionOpen, O as recordPendingDiscussionOpen, S as resolveDiscussionLabel, T as clearDiscussionBindingGeneration, _ as getClickClackDiscussionBindingStore, b as fallbackDiscussionLabel, c as markClickClackDiscussionChannelIdentityRevoked, d as discussionInfoForBinding, f as normalizedServerBaseUrl, g as bindingMatchesActiveSessionIncarnation, h as attachBindingToCurrentActiveSession, k as reserveDiscussionBindingGeneration, l as markClickClackDiscussionChannelRevoked, o as clearClickClackDiscussionChannelRevoked, p as resolveDiscussionBindingAccount, s as isClickClackDiscussionChannelRevoked, u as discussionAccounts, v as discussionCredentialFingerprint, w as truncateDiscussionDisplayTitle, x as isDiscussionSessionKey, y as discussionExternalRef } from "./channel-Bcwtohk7.mjs";
3
- import { a as isClickClackChannelNameConflict, i as createClickClackClient, r as ClickClackHttpError } from "./resolve-BHGLohni.mjs";
1
+ import { h as resolveClickClackAccount, p as listClickClackAccountIds } from "./setup-surface-DcF_7-VV.mjs";
2
+ import { C as slugifyDiscussionLabel, D as listPendingDiscussionOpens, E as clearPendingDiscussionOpen, O as recordPendingDiscussionOpen, S as resolveDiscussionLabel, T as clearDiscussionBindingGeneration, _ as getClickClackDiscussionBindingStore, b as fallbackDiscussionLabel, c as markClickClackDiscussionChannelIdentityRevoked, d as discussionInfoForBinding, f as normalizedServerBaseUrl, g as bindingMatchesActiveSessionIncarnation, h as attachBindingToCurrentActiveSession, k as reserveDiscussionBindingGeneration, l as markClickClackDiscussionChannelRevoked, o as clearClickClackDiscussionChannelRevoked, p as resolveDiscussionBindingAccount, s as isClickClackDiscussionChannelRevoked, u as discussionAccounts, v as discussionCredentialFingerprint, w as truncateDiscussionDisplayTitle, x as isDiscussionSessionKey, y as discussionExternalRef } from "./channel-DI3hKiSg.mjs";
3
+ import { a as createClickClackClient, i as ClickClackHttpError, o as isClickClackChannelNameConflict, t as findClickClackWorkspace } from "./resolve-DPtjHQHt.mjs";
4
4
  import "../api.js";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
6
7
  import { buildControlUiSessionPath, registerSessionDiscussionProvider } from "openclaw/plugin-sdk/session-discussion";
7
8
  import { createSessionVisibilityChecker } from "openclaw/plugin-sdk/session-visibility";
9
+ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
8
10
  import { resolveSessionAgentIdStrict } from "openclaw/plugin-sdk/agent-scope-runtime";
9
11
  import { textResult } from "openclaw/plugin-sdk/tool-results";
10
12
  //#region extensions/clickclack/src/discussions/binding-retention.ts
@@ -121,77 +123,55 @@ const RECONCILE_RETRY_MAX_MS = 6e4;
121
123
  * traffic can never postpone one); a failed run sets a not-before floor with
122
124
  * growing backoff that later events cannot undercut; events during an
123
125
  * in-flight run collapse into one follow-up scheduled after settlement; and
124
- * `supersede()` invalidates callbacks from earlier activations so stop/start
125
- * cycles cannot corrupt the bookkeeping of the next one.
126
+ * each service activation owns a separate scheduler scope and retry state.
126
127
  */
127
128
  var DiscussionReconcileScheduler = class {
128
129
  #hooks;
129
- #timers = /* @__PURE__ */ new Map();
130
130
  #retryState = /* @__PURE__ */ new Map();
131
131
  #inFlight = /* @__PURE__ */ new Set();
132
132
  #followUps = /* @__PURE__ */ new Set();
133
- #generation = 0;
134
- constructor(hooks) {
133
+ constructor(scheduler, hooks) {
134
+ this.scheduler = scheduler;
135
135
  this.#hooks = hooks;
136
136
  }
137
- /** Invalidates callbacks scheduled by prior activations. */
138
- supersede() {
139
- this.#generation += 1;
140
- }
141
- clear() {
142
- for (const scheduled of this.#timers.values()) clearTimeout(scheduled.timer);
143
- this.#timers.clear();
144
- this.#retryState.clear();
145
- this.#inFlight.clear();
146
- this.#followUps.clear();
147
- }
148
137
  schedule(sessionKey, delayMs = RECONCILE_DEBOUNCE_MS) {
149
- if (!this.#hooks.shouldSchedule(sessionKey)) return;
138
+ if (this.scheduler.signal.aborted) return;
150
139
  if (this.#inFlight.has(sessionKey)) {
151
140
  this.#followUps.add(sessionKey);
152
141
  return;
153
142
  }
154
143
  const retry = this.#retryState.get(sessionKey);
155
- const fireAt = Math.max(Date.now() + delayMs, retry?.notBefore ?? 0);
156
- const existing = this.#timers.get(sessionKey);
157
- if (existing) {
158
- if (existing.fireAt <= fireAt) return;
159
- clearTimeout(existing.timer);
160
- }
161
- const generation = this.#generation;
162
- const timer = setTimeout(() => {
163
- if (generation !== this.#generation) return;
164
- this.#timers.delete(sessionKey);
165
- this.#inFlight.add(sessionKey);
166
- this.#hooks.run(sessionKey).then(() => {
167
- if (generation !== this.#generation) return;
168
- this.#retryState.delete(sessionKey);
169
- }, (error) => {
170
- if (generation !== this.#generation || !this.#hooks.shouldSchedule(sessionKey)) return;
171
- const previous = this.#retryState.get(sessionKey);
172
- const nextRetryMs = Math.min(previous === void 0 ? RECONCILE_RETRY_MS : previous.delayMs * 2, RECONCILE_RETRY_MAX_MS);
173
- this.#retryState.set(sessionKey, {
174
- delayMs: nextRetryMs,
175
- notBefore: Date.now() + nextRetryMs
176
- });
177
- this.#hooks.warn(`discussion event reconcile failed for ${sessionKey}; retrying in ${nextRetryMs}ms: ${String(error)}`);
178
- this.#followUps.add(sessionKey);
179
- }).finally(() => {
180
- if (generation !== this.#generation) return;
181
- this.#inFlight.delete(sessionKey);
182
- if (this.#followUps.delete(sessionKey)) this.schedule(sessionKey);
183
- });
184
- }, Math.max(fireAt - Date.now(), 0));
185
- timer.unref?.();
186
- this.#timers.set(sessionKey, {
187
- fireAt,
188
- timer
144
+ const fireAt = Math.max(this.scheduler.now() + delayMs, retry?.notBefore ?? 0);
145
+ this.scheduler.schedule({
146
+ id: `session:${sessionKey}`,
147
+ atMs: fireAt,
148
+ mode: "earliest",
149
+ run: async () => {
150
+ this.#inFlight.add(sessionKey);
151
+ try {
152
+ await this.#hooks.run(sessionKey);
153
+ this.#retryState.delete(sessionKey);
154
+ } catch (error) {
155
+ if (this.scheduler.signal.aborted) return;
156
+ const previous = this.#retryState.get(sessionKey);
157
+ const nextRetryMs = Math.min(previous === void 0 ? RECONCILE_RETRY_MS : previous.delayMs * 2, RECONCILE_RETRY_MAX_MS);
158
+ this.#retryState.set(sessionKey, {
159
+ delayMs: nextRetryMs,
160
+ notBefore: this.scheduler.now() + nextRetryMs
161
+ });
162
+ this.#hooks.warn(`discussion event reconcile failed for ${sessionKey}; retrying in ${nextRetryMs}ms: ${String(error)}`);
163
+ this.#followUps.add(sessionKey);
164
+ } finally {
165
+ this.#inFlight.delete(sessionKey);
166
+ if (!this.scheduler.signal.aborted && this.#followUps.delete(sessionKey)) this.schedule(sessionKey);
167
+ }
168
+ }
189
169
  });
190
170
  }
191
171
  };
192
172
  //#endregion
193
173
  //#region extensions/clickclack/src/discussions/service-open.ts
194
- const CHANNEL_NAME_MUTATION_ATTEMPTS$1 = 4;
174
+ const CHANNEL_NAME_MUTATION_ATTEMPTS = 4;
195
175
  function isDefinitiveNoCreateHttpError(error) {
196
176
  if (!(error instanceof ClickClackHttpError) || error.status < 400 || error.status >= 500) return false;
197
177
  return ![
@@ -233,6 +213,26 @@ function assertChannelPatch(channel, patch) {
233
213
  if ((key === "external_ref" || key === "external_url" || key === "sidebar_section" ? channel[key] ?? "" : channel[key]) !== expected) throw new Error(`ClickClack channel update did not apply ${key}`);
234
214
  }
235
215
  }
216
+ async function renameDiscussionChannel(params) {
217
+ for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
218
+ const patch = {
219
+ ...params.patch,
220
+ name: await resolveAvailableChannelName({
221
+ ...params,
222
+ ownChannelId: params.channelId
223
+ })
224
+ };
225
+ try {
226
+ params.assertCurrentAuthority?.();
227
+ const channel = await params.client.updateChannel(params.channelId, patch);
228
+ assertChannelPatch(channel, patch);
229
+ return channel;
230
+ } catch (error) {
231
+ if (!isClickClackChannelNameConflict(error) || attempt === 3) throw error;
232
+ }
233
+ }
234
+ throw new Error("ClickClack discussion channel name retries were exhausted");
235
+ }
236
236
  function assertManagedChannelContract(channel, expected) {
237
237
  const displayTitleMatches = !("display_title" in channel) || channel.display_title === expected.displayTitle;
238
238
  if (channel.external_managed !== true || (channel.external_ref ?? "") !== (expected.externalRef ?? "") || (channel.sidebar_section ?? "") !== (expected.section ?? "") || (channel.external_url ?? "") !== (expected.externalUrl ?? "") || !displayTitleMatches) throw new Error(`ClickClack server does not support the managed discussion channel contract for ${expected.sessionKey}`);
@@ -249,7 +249,8 @@ async function openClickClackDiscussionBinding(params) {
249
249
  if (!entry || entry.archivedAt !== void 0) return;
250
250
  if (!entry.sessionId?.trim()) throw new Error("OpenClaw session does not yet have a concrete session id");
251
251
  const client = params.clientFactory(account);
252
- const workspace = (await client.workspaces()).find((candidate) => candidate.id === account.discussions.workspace || candidate.slug === account.discussions.workspace || candidate.name === account.discussions.workspace);
252
+ const workspaces = await client.workspaces();
253
+ const workspace = findClickClackWorkspace(workspaces, account.discussions.workspace);
253
254
  if (!workspace) throw new Error(`ClickClack discussions workspace not found: ${account.discussions.workspace}`);
254
255
  if (!workspace.route_id) throw new Error("ClickClack discussions workspace is missing its route id");
255
256
  const serverBaseUrl = normalizedServerBaseUrl(account);
@@ -291,11 +292,16 @@ async function openClickClackDiscussionBinding(params) {
291
292
  destinationIdentity,
292
293
  createGeneration: params.bindingGenerationFactory
293
294
  });
295
+ const generationScope = {
296
+ runtime,
297
+ sessionKey,
298
+ expectedGeneration: bindingGeneration
299
+ };
294
300
  const externalRef = discussionExternalRef(params.installationId, sessionKey, destinationIdentity, bindingGeneration);
295
301
  let adopted;
296
302
  let managedFields;
297
303
  let resolved;
298
- for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1; attempt += 1) {
304
+ for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
299
305
  adopted = channels.find((candidate) => candidate.external_managed === true && candidate.external_ref === externalRef);
300
306
  managedFields = {
301
307
  name: await resolveAvailableChannelName({
@@ -373,17 +379,9 @@ async function openClickClackDiscussionBinding(params) {
373
379
  resolved = await client.updateChannel(recovered.id, managedFields);
374
380
  break;
375
381
  }
376
- if (definitiveNoCreate) await clearDiscussionBindingGeneration({
377
- runtime,
378
- sessionKey,
379
- expectedGeneration: bindingGeneration
380
- });
382
+ if (definitiveNoCreate) await clearDiscussionBindingGeneration(generationScope);
381
383
  } catch {
382
- if (definitiveNoCreate && !adopted) await clearDiscussionBindingGeneration({
383
- runtime,
384
- sessionKey,
385
- expectedGeneration: bindingGeneration
386
- });
384
+ if (definitiveNoCreate && !adopted) await clearDiscussionBindingGeneration(generationScope);
387
385
  }
388
386
  throw error;
389
387
  }
@@ -399,20 +397,12 @@ async function openClickClackDiscussionBinding(params) {
399
397
  });
400
398
  if (adopted) assertChannelPatch(resolved, managedFields);
401
399
  } catch (error) {
402
- await clearPendingDiscussionOpen({
403
- runtime,
404
- sessionKey,
405
- expectedGeneration: bindingGeneration
406
- });
400
+ await clearPendingDiscussionOpen(generationScope);
407
401
  params.warn(`incompatible discussion channel remains quarantined: ${resolved.id}`);
408
402
  throw error;
409
403
  }
410
404
  if (!resolved.route_id) {
411
- await clearPendingDiscussionOpen({
412
- runtime,
413
- sessionKey,
414
- expectedGeneration: bindingGeneration
415
- });
405
+ await clearPendingDiscussionOpen(generationScope);
416
406
  params.warn(`route-less discussion channel remains quarantined: ${resolved.id}`);
417
407
  throw new Error("ClickClack discussion channel is missing its route id");
418
408
  }
@@ -422,11 +412,7 @@ async function openClickClackDiscussionBinding(params) {
422
412
  readConsistency: "latest"
423
413
  });
424
414
  if (!currentEntry?.sessionId || currentEntry.archivedAt !== void 0) {
425
- await clearPendingDiscussionOpen({
426
- runtime,
427
- sessionKey,
428
- expectedGeneration: bindingGeneration
429
- });
415
+ await clearPendingDiscussionOpen(generationScope);
430
416
  params.warn(`unattached discussion channel remains quarantined: ${channel.id}`);
431
417
  throw new Error("OpenClaw session became inactive while opening its ClickClack discussion");
432
418
  }
@@ -436,36 +422,23 @@ async function openClickClackDiscussionBinding(params) {
436
422
  const currentExternalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? agentId, config.session?.mainKey, currentLabel) ?? "";
437
423
  let currentChannel = channel;
438
424
  if (currentEntry.sessionId !== entry.sessionId || currentLabel !== label || currentDisplayTitle !== displayTitle || currentSection !== section || currentExternalUrl !== (externalUrl ?? "")) try {
439
- for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1; attempt += 1) {
440
- const latestManagedFields = {
425
+ currentChannel = await renameDiscussionChannel({
426
+ client,
427
+ workspaceId: workspace.id,
428
+ channelId: channel.id,
429
+ label: currentLabel,
430
+ sessionKey,
431
+ agentId,
432
+ patch: {
441
433
  ...managedFields,
442
- name: await resolveAvailableChannelName({
443
- client,
444
- workspaceId: workspace.id,
445
- label: currentLabel,
446
- sessionKey,
447
- agentId,
448
- ownChannelId: channel.id
449
- }),
450
434
  external_url: currentExternalUrl,
451
435
  sidebar_section: currentSection,
452
436
  display_title: currentDisplayTitle
453
- };
454
- try {
455
- assertCurrentAuthority();
456
- currentChannel = await client.updateChannel(channel.id, latestManagedFields);
457
- assertChannelPatch(currentChannel, latestManagedFields);
458
- break;
459
- } catch (error) {
460
- if (!isClickClackChannelNameConflict(error) || attempt === 3) throw error;
461
- }
462
- }
463
- } catch (error) {
464
- await clearPendingDiscussionOpen({
465
- runtime,
466
- sessionKey,
467
- expectedGeneration: bindingGeneration
437
+ },
438
+ assertCurrentAuthority
468
439
  });
440
+ } catch (error) {
441
+ await clearPendingDiscussionOpen(generationScope);
469
442
  params.warn(`unattached discussion channel remains quarantined: ${channel.id}`);
470
443
  throw error;
471
444
  }
@@ -491,11 +464,7 @@ async function openClickClackDiscussionBinding(params) {
491
464
  assertCurrentAuthority();
492
465
  store.set(sessionKey, nextBinding);
493
466
  } catch (error) {
494
- await clearPendingDiscussionOpen({
495
- runtime,
496
- sessionKey,
497
- expectedGeneration: bindingGeneration
498
- });
467
+ await clearPendingDiscussionOpen(generationScope);
499
468
  params.warn(`unbound discussion channel remains quarantined: ${channel.id}`);
500
469
  throw error;
501
470
  }
@@ -524,7 +493,8 @@ async function reconcilePendingDiscussionOpen(params) {
524
493
  const activeAccounts = discussionAccounts(cfg);
525
494
  const retryAccount = activeAccounts.length === 1 ? activeAccounts[0] : void 0;
526
495
  if (params.allowRetry !== false && entry?.sessionId && entry.archivedAt === void 0 && retryAccount && normalizedServerBaseUrl(retryAccount) === pending.serverBaseUrl && discussionCredentialFingerprint(retryAccount.token) === pending.credentialFingerprint) {
527
- if ((await params.clientFactory(retryAccount).workspaces()).find((candidate) => candidate.id === retryAccount.discussions.workspace || candidate.slug === retryAccount.discussions.workspace || candidate.name === retryAccount.discussions.workspace)?.id === pending.workspaceId) {
496
+ const workspaces = await params.clientFactory(retryAccount).workspaces();
497
+ if (findClickClackWorkspace(workspaces, retryAccount.discussions.workspace)?.id === pending.workspaceId) {
528
498
  await params.open(pending.sessionKey);
529
499
  return;
530
500
  }
@@ -555,7 +525,6 @@ async function reconcilePendingDiscussionOpen(params) {
555
525
  //#endregion
556
526
  //#region extensions/clickclack/src/discussions/service.ts
557
527
  const RECONCILE_INTERVAL_MS = 6e4;
558
- const CHANNEL_NAME_MUTATION_ATTEMPTS = 4;
559
528
  var ClickClackDiscussionService = class {
560
529
  #runtime;
561
530
  #store;
@@ -563,14 +532,10 @@ var ClickClackDiscussionService = class {
563
532
  #installationId;
564
533
  #bindingGenerationFactory;
565
534
  #detachedBindings;
566
- #timersEnabled;
567
- #sessionLocks = /* @__PURE__ */ new Map();
568
- #reconcileScheduler = new DiscussionReconcileScheduler({
569
- shouldSchedule: () => !this.#closed,
570
- run: async (sessionKey) => await this.reconcile(sessionKey),
571
- warn: (message) => this.#logger().warn(message)
572
- });
573
- #channelMutationLock = Promise.resolve();
535
+ #sessionQueue = new KeyedAsyncQueue();
536
+ #scheduler;
537
+ #reconcileScheduler;
538
+ #withChannelMutationLock = createAsyncLock();
574
539
  #timer;
575
540
  #reconcileAllPromise;
576
541
  #unsubscribeSessionsChanged;
@@ -592,31 +557,31 @@ var ClickClackDiscussionService = class {
592
557
  store: this.#store,
593
558
  maxRetained: options.maxRetainedDetachedBindings ?? 1e3
594
559
  });
595
- this.#timersEnabled = options.startTimer !== false;
596
560
  this.provider = {
597
561
  id: "clickclack",
598
562
  info: async ({ sessionKey }) => await this.info(sessionKey),
599
563
  open: async ({ sessionKey }) => await this.open(sessionKey)
600
564
  };
601
- if (options.gatewayEvents) this.bindGatewayEvents(options.gatewayEvents).catch((error) => {
602
- this.#logger().warn(`discussion activation failed: ${String(error)}`);
603
- });
604
- if (this.#timersEnabled && !options.gatewayEvents) this.#withOperation(() => this.#ensureTimer()).catch((error) => {
605
- this.#logger().warn(`discussion timer initialization failed: ${String(error)}`);
606
- });
607
565
  }
608
- async bindGatewayEvents(gatewayEvents) {
566
+ async bindGatewayEvents(gatewayEvents, scheduler) {
609
567
  const activation = ++this.#activation;
610
568
  if (this.#cleanupPromise) await this.#cleanupPromise;
611
569
  if (activation !== this.#activation) return;
570
+ if (this.#scheduler) await this.#scheduler.stop();
571
+ if (activation !== this.#activation) return;
612
572
  this.#cleanupPromise = void 0;
613
573
  this.#unsubscribeSessionsChanged?.();
614
574
  this.#closed = false;
615
- this.#reconcileScheduler.supersede();
575
+ this.#scheduler = scheduler.scope();
576
+ this.#timer = void 0;
577
+ this.#reconcileScheduler = new DiscussionReconcileScheduler(this.#scheduler, {
578
+ run: (sessionKey) => this.reconcile(sessionKey),
579
+ warn: (message) => this.#logger().warn(message)
580
+ });
616
581
  this.#unsubscribeSessionsChanged = gatewayEvents?.onSessionsChanged((event) => {
617
582
  this.#withOperation(async () => {
618
583
  if (this.#store.get(event.sessionKey) || (await listPendingDiscussionOpens(this.#runtime)).some((pending) => pending.sessionKey === event.sessionKey)) {
619
- if (activation === this.#activation) this.#reconcileScheduler.schedule(event.sessionKey);
584
+ if (activation === this.#activation) this.#reconcileScheduler?.schedule(event.sessionKey);
620
585
  }
621
586
  }).catch((error) => {
622
587
  this.#logger().warn(`discussion event admission failed: ${String(error)}`);
@@ -625,7 +590,7 @@ var ClickClackDiscussionService = class {
625
590
  await this.#withOperation(async () => {
626
591
  for (const sessionKey of await this.#listReconcileSessionKeys()) {
627
592
  if (activation !== this.#activation) return;
628
- this.#reconcileScheduler.schedule(sessionKey, 0);
593
+ this.#reconcileScheduler?.schedule(sessionKey, 0);
629
594
  }
630
595
  await this.#ensureTimer();
631
596
  });
@@ -634,7 +599,7 @@ var ClickClackDiscussionService = class {
634
599
  return discussionAccounts(this.#currentConfig()).length === 1;
635
600
  }
636
601
  async info(sessionKey) {
637
- return await this.#withOperation(() => this.#withSessionLock(sessionKey, async () => {
602
+ return await this.#withOperation(() => this.#sessionQueue.enqueue(sessionKey, async () => {
638
603
  if (discussionAccounts(this.#currentConfig()).length !== 1) return { state: "none" };
639
604
  const existing = this.#store.get(sessionKey);
640
605
  if (existing) {
@@ -658,7 +623,7 @@ var ClickClackDiscussionService = class {
658
623
  }));
659
624
  }
660
625
  async open(sessionKey) {
661
- return await this.#withOperation(() => this.#withSessionLock(sessionKey, () => this.#open(sessionKey)));
626
+ return await this.#withOperation(() => this.#sessionQueue.enqueue(sessionKey, () => this.#open(sessionKey)));
662
627
  }
663
628
  async #open(sessionKey) {
664
629
  this.#installationId ??= await getClickClackDiscussionInstallationId(this.#runtime);
@@ -706,7 +671,7 @@ var ClickClackDiscussionService = class {
706
671
  }
707
672
  async #reconcile(sessionKey) {
708
673
  try {
709
- await this.#withSessionLock(sessionKey, async () => {
674
+ await this.#sessionQueue.enqueue(sessionKey, async () => {
710
675
  const binding = this.#store.get(sessionKey);
711
676
  if (binding) await this.#reconcileBinding(sessionKey, binding);
712
677
  const pending = (await listPendingDiscussionOpens(this.#runtime)).find((candidate) => candidate.sessionKey === sessionKey);
@@ -736,12 +701,10 @@ var ClickClackDiscussionService = class {
736
701
  });
737
702
  }
738
703
  async readLatestMessages(sessionKey, limit) {
739
- return await this.#withOperation(() => this.#withSessionLock(sessionKey, async () => {
704
+ return await this.#withOperation(() => this.#sessionQueue.enqueue(sessionKey, async () => {
740
705
  const binding = this.#store.get(sessionKey);
741
706
  if (!binding) return { text: "No discussion is bound to this session." };
742
707
  const resolved = await this.#resolveBindingForUse(binding);
743
- if (resolved.state === "retargeted") return { text: "No discussion is bound to this session." };
744
- if (resolved.state === "stale") return { text: "No discussion is bound to this session." };
745
708
  if (resolved.state !== "active") return { text: "No discussion is bound to this session." };
746
709
  const attached = this.#refreshSessionAttachment(sessionKey, binding);
747
710
  if (!attached) return { text: "No discussion is bound to this session." };
@@ -761,13 +724,9 @@ var ClickClackDiscussionService = class {
761
724
  this.#activation += 1;
762
725
  this.#unsubscribeSessionsChanged?.();
763
726
  this.#unsubscribeSessionsChanged = void 0;
764
- this.#reconcileScheduler.supersede();
765
- this.#reconcileScheduler.clear();
766
- if (this.#timer) {
767
- clearInterval(this.#timer);
768
- this.#timer = void 0;
769
- }
770
- this.#cleanupPromise ??= Promise.allSettled(this.#operations).then(() => void 0);
727
+ this.#reconcileScheduler = void 0;
728
+ this.#timer = void 0;
729
+ this.#cleanupPromise ??= Promise.allSettled([this.#scheduler?.stop(), ...this.#operations]).then(() => void 0);
771
730
  return this.#cleanupPromise;
772
731
  }
773
732
  async #reconcileBinding(sessionKey, binding, resolvedAccount) {
@@ -821,26 +780,15 @@ var ClickClackDiscussionService = class {
821
780
  if (Object.keys(patch).length === 0 && !labelChanged) return;
822
781
  const client = this.#clientFactory(account);
823
782
  let updated;
824
- if (labelChanged) updated = await this.#withChannelMutationLock(async () => {
825
- for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
826
- patch.name = await resolveAvailableChannelName({
827
- client,
828
- workspaceId: currentBinding.workspaceId,
829
- label,
830
- sessionKey,
831
- agentId: currentBinding.agentId,
832
- ownChannelId: currentBinding.channelId
833
- });
834
- try {
835
- const renamed = await client.updateChannel(currentBinding.channelId, patch);
836
- assertChannelPatch(renamed, patch);
837
- return renamed;
838
- } catch (error) {
839
- if (!isClickClackChannelNameConflict(error) || attempt === 3) throw error;
840
- }
841
- }
842
- throw new Error("ClickClack discussion channel name retries were exhausted");
843
- });
783
+ if (labelChanged) updated = await this.#withChannelMutationLock(() => renameDiscussionChannel({
784
+ client,
785
+ workspaceId: currentBinding.workspaceId,
786
+ channelId: currentBinding.channelId,
787
+ label,
788
+ sessionKey,
789
+ agentId: currentBinding.agentId,
790
+ patch
791
+ }));
844
792
  else {
845
793
  updated = await client.updateChannel(currentBinding.channelId, patch);
846
794
  assertChannelPatch(updated, patch);
@@ -910,7 +858,8 @@ var ClickClackDiscussionService = class {
910
858
  async #resolveBindingForUse(binding) {
911
859
  const resolved = resolveDiscussionBindingAccount(this.#currentConfig(), binding);
912
860
  if (resolved.state !== "active") return resolved;
913
- const workspace = (await this.#clientFactory(resolved.account).workspaces()).find((candidate) => candidate.id === resolved.account.discussions.workspace || candidate.slug === resolved.account.discussions.workspace || candidate.name === resolved.account.discussions.workspace);
861
+ const workspaces = await this.#clientFactory(resolved.account).workspaces();
862
+ const workspace = findClickClackWorkspace(workspaces, resolved.account.discussions.workspace);
914
863
  const current = resolveDiscussionBindingAccount(this.#currentConfig(), binding);
915
864
  if (current.state !== "active") return current;
916
865
  return workspace?.id === binding.workspaceId ? current : { state: "retargeted" };
@@ -924,18 +873,20 @@ var ClickClackDiscussionService = class {
924
873
  const needsBindingPoll = this.#unsubscribeSessionsChanged === void 0 && this.#store.count() > 0;
925
874
  if (this.#closed || !hasPendingOpens && !needsBindingPoll) {
926
875
  if (this.#timer) {
927
- clearInterval(this.#timer);
876
+ this.#timer.cancel();
928
877
  this.#timer = void 0;
929
878
  }
930
879
  return;
931
880
  }
932
- if (!this.#timersEnabled || this.#timer) return;
933
- this.#timer = setInterval(() => {
934
- this.reconcileAll().catch((error) => {
881
+ if (!this.#scheduler || this.#scheduler.signal.aborted || this.#timer) return;
882
+ this.#timer = this.#scheduler.schedule({
883
+ id: "reconcile-all",
884
+ delayMs: RECONCILE_INTERVAL_MS,
885
+ everyMs: RECONCILE_INTERVAL_MS,
886
+ run: () => this.reconcileAll().catch((error) => {
935
887
  this.#logger().warn(`discussion reconcile pass failed: ${String(error)}`);
936
- });
937
- }, RECONCILE_INTERVAL_MS);
938
- this.#timer.unref?.();
888
+ })
889
+ });
939
890
  }
940
891
  async #listReconcileSessionKeys() {
941
892
  return /* @__PURE__ */ new Set([...this.#store.entries().map(({ sessionKey }) => sessionKey), ...(await listPendingDiscussionOpens(this.#runtime)).map(({ sessionKey }) => sessionKey)]);
@@ -953,20 +904,6 @@ var ClickClackDiscussionService = class {
953
904
  operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
954
905
  return operation;
955
906
  }
956
- async #withSessionLock(sessionKey, run) {
957
- const current = (this.#sessionLocks.get(sessionKey) ?? Promise.resolve()).catch(() => void 0).then(run);
958
- this.#sessionLocks.set(sessionKey, current);
959
- try {
960
- return await current;
961
- } finally {
962
- if (this.#sessionLocks.get(sessionKey) === current) this.#sessionLocks.delete(sessionKey);
963
- }
964
- }
965
- async #withChannelMutationLock(run) {
966
- const current = this.#channelMutationLock.catch(() => void 0).then(run);
967
- this.#channelMutationLock = current;
968
- return await current;
969
- }
970
907
  };
971
908
  //#endregion
972
909
  //#region extensions/clickclack/src/discussions/tool-policy.ts
@@ -1050,7 +987,8 @@ function registerClickClackDiscussions(api) {
1050
987
  const service = new ClickClackDiscussionService(api.runtime);
1051
988
  api.registerService({
1052
989
  id: "clickclack-discussion-session-events",
1053
- start: ({ gatewayEvents }) => service.bindGatewayEvents(gatewayEvents),
990
+ apiVersion: 2,
991
+ start: ({ gatewayEvents, scheduler }) => service.bindGatewayEvents(gatewayEvents, scheduler),
1054
992
  stop: () => service.cleanup()
1055
993
  });
1056
994
  api.registerTool((context) => createClickClackDiscussionTool({
@@ -0,0 +1,26 @@
1
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
2
+ import { collectConditionalChannelFieldAssignments, createChannelSecretContract, hasConfiguredSecretInputValue } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
3
+ //#region extensions/clickclack/src/secret-contract.ts
4
+ const channelSecrets = createChannelSecretContract({
5
+ channelKey: "clickclack",
6
+ account: ["token"],
7
+ channel: ["token"],
8
+ collect(params) {
9
+ const { channel: clickclack } = params;
10
+ const baseTokenFile = normalizeOptionalString(clickclack.tokenFile) ?? "";
11
+ const accountTokenFile = (account) => normalizeOptionalString(account.tokenFile) ?? "";
12
+ const hasImplicitDefault = Boolean(normalizeOptionalString(clickclack.baseUrl)) && Boolean(normalizeOptionalString(clickclack.workspace));
13
+ collectConditionalChannelFieldAssignments({
14
+ ...params,
15
+ field: "token",
16
+ topLevelActiveWithoutAccounts: baseTokenFile.length === 0,
17
+ topLevelInheritedAccountActive: ({ account, enabled }) => hasImplicitDefault && baseTokenFile.length === 0 || enabled && baseTokenFile.length === 0 && accountTokenFile(account).length === 0 && !hasConfiguredSecretInputValue(account.token, params.defaults),
18
+ accountActive: ({ account, enabled }) => enabled && accountTokenFile(account).length === 0,
19
+ topInactiveReason: "no enabled ClickClack account inherits this top-level token (tokenFile is configured).",
20
+ accountInactiveReason: "ClickClack account is disabled or tokenFile is configured."
21
+ });
22
+ }
23
+ });
24
+ const { secretTargetRegistryEntries, collectRuntimeConfigAssignments } = channelSecrets;
25
+ //#endregion
26
+ export { collectRuntimeConfigAssignments as n, secretTargetRegistryEntries as r, channelSecrets as t };
@@ -1,3 +1,3 @@
1
- import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries, t as channelSecrets } from "./secret-contract-BKkzzjBA.mjs";
1
+ import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries, t as channelSecrets } from "./secret-contract-CF7TKtkU.mjs";
2
2
  import "../secret-contract-api.js";
3
3
  export { channelSecrets, collectRuntimeConfigAssignments, secretTargetRegistryEntries };
@@ -1,4 +1,4 @@
1
- import { a as isClickClackSetupLoopbackHost, i as buildClickClackSetupClaimUrl, o as requireClickClackSetupApiBaseUrl } from "./setup-surface-BW1Q06w5.mjs";
1
+ import { a as isClickClackSetupLoopbackHost, i as buildClickClackSetupClaimUrl, o as requireClickClackSetupApiBaseUrl } from "./setup-surface-DcF_7-VV.mjs";
2
2
  import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
3
3
  import { createProviderOperationDeadline, readProviderJsonResponse, readResponseTextLimited, resolveProviderOperationTimeoutMs } from "openclaw/plugin-sdk/provider-http";
4
4
  import { fetchWithSsrFGuard, resolvePinnedHostnameWithPolicy, ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -1,2 +1,2 @@
1
- import { t as clickClackSetupPlugin } from "./setup-plugin-api-C9QcBJuW.mjs";
1
+ import { t as clickClackSetupPlugin } from "./setup-plugin-api-CqfAb-CU.mjs";
2
2
  export { clickClackSetupPlugin };
@@ -1,4 +1,4 @@
1
- import { l as clickClackConfigAdapter, n as clickClackSetupContract, s as clickClackConfigSchema, t as clickClackSetupWizard, u as clickClackMeta } from "./setup-surface-BW1Q06w5.mjs";
1
+ import { l as clickClackConfigAdapter, n as clickClackSetupContract, s as clickClackConfigSchema, t as clickClackSetupWizard, u as clickClackMeta } from "./setup-surface-DcF_7-VV.mjs";
2
2
  //#region extensions/clickclack/src/channel.setup.ts
3
3
  const clickClackSetupPlugin = {
4
4
  id: "clickclack",