@openclaw/clickclack 2026.7.2-beta.7 → 2026.8.1-beta.3

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,104 +1,70 @@
1
- import { d as createClickClackClient, f as isClickClackChannelNameConflict, u as ClickClackHttpError, x as resolveClickClackAccount, y as listClickClackAccountIds } from "./setup-surface-DAy8vDJK.js";
2
- import { C as truncateDiscussionDisplayTitle, D as reserveDiscussionBindingGeneration, E as recordPendingDiscussionOpen, S as slugifyDiscussionLabel, T as listPendingDiscussionOpens, _ as discussionCredentialFingerprint, b as isDiscussionSessionKey, c as markClickClackDiscussionChannelIdentityRevoked, d as discussionInfoForBinding, f as normalizedServerBaseUrl, g as getClickClackDiscussionBindingStore, h as bindingMatchesSessionIncarnation, l as markClickClackDiscussionChannelRevoked, m as bindingMatchesActiveSessionIncarnation, o as clearClickClackDiscussionChannelRevoked, p as resolveDiscussionBindingAccount, s as isClickClackDiscussionChannelRevoked, u as discussionAccounts, v as discussionExternalRef, w as clearDiscussionBindingGeneration, x as resolveDiscussionLabel, y as fallbackDiscussionLabel } from "./channel-CECIpaS0.js";
1
+ import { b as resolveClickClackAccount, d as isClickClackChannelNameConflict, l as ClickClackHttpError, u as createClickClackClient, v as listClickClackAccountIds } from "./setup-surface-BQ1Zyohu.js";
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-ChO3E9i2.js";
3
3
  import "./api.js";
4
- import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
5
4
  import { randomUUID } from "node:crypto";
6
- import { registerSessionDiscussionProvider } from "openclaw/plugin-sdk/session-discussion";
5
+ import { buildControlUiSessionPath, registerSessionDiscussionProvider } from "openclaw/plugin-sdk/session-discussion";
7
6
  import { createSessionVisibilityChecker } from "openclaw/plugin-sdk/session-visibility";
8
- import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
7
+ import { resolveSessionAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
9
8
  import { textResult } from "openclaw/plugin-sdk/tool-results";
10
- //#region extensions/clickclack/src/discussions/control-session-url.ts
11
- const SESSION_UUID_SUFFIX_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/iu;
12
- const SHORT_SESSION_REF_RE = /^(?:.*-)?([0-9a-f]{8,32})$/iu;
13
- const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/iu;
14
- const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/giu;
15
- const FIXED_RESERVED_SESSION_RESTS = /* @__PURE__ */ new Set([
16
- "main",
17
- "global",
18
- "boot",
19
- "sessions"
20
- ]);
21
- const SESSION_SLUG_MAX_LENGTH = 48;
22
- const DEFAULT_AGENT_ID = "main";
23
- const DEFAULT_MAIN_KEY = "main";
24
- function optionalString(value) {
25
- return (value?.trim() ?? "") || null;
26
- }
27
- function normalizeAgentId$1(value) {
28
- const trimmed = value.trim();
29
- if (!trimmed) return DEFAULT_AGENT_ID;
30
- if (VALID_AGENT_ID_RE.test(trimmed)) return trimmed.toLowerCase();
31
- return trimmed.toLowerCase().replace(INVALID_AGENT_ID_CHARS_RE, "-").replace(/^-+|-+$/gu, "").slice(0, 64) || DEFAULT_AGENT_ID;
32
- }
33
- function normalizeBasePath(basePath) {
34
- const trimmed = basePath.trim().replace(/^\/+|\/+$/gu, "");
35
- return trimmed ? `/${trimmed}` : "";
36
- }
37
- function agentSessionKeyParts(sessionKey) {
38
- const parts = sessionKey.split(":");
39
- if (parts.length < 3 || parts[0]?.toLowerCase() !== "agent") return null;
40
- const agentId = optionalString(parts[1]);
41
- const restSegments = parts.slice(2);
42
- if (!agentId || restSegments.some((segment) => !segment)) return null;
43
- return {
44
- agentId: normalizeAgentId$1(agentId),
45
- rest: restSegments.join(":")
46
- };
47
- }
48
- function encodePathSegment(segment) {
49
- if (segment === ".") return "~dot";
50
- if (segment === "..") return "~dotdot";
51
- const encoded = encodeURIComponent(segment).replaceAll(".", "%2E");
52
- return encoded.startsWith("~") ? `~${encoded}` : encoded;
53
- }
54
- function isReservedSessionRest(rest, mainKey) {
55
- const normalized = rest.toLowerCase();
56
- return FIXED_RESERVED_SESSION_RESTS.has(normalized) || normalized === (optionalString(mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY);
57
- }
58
- function sessionSlug(displayName) {
59
- const tokens = (displayName ?? "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").split("-").filter(Boolean);
60
- while (tokens.length > 0 && /^[0-9a-f]+$/u.test(tokens.at(-1) ?? "")) tokens.pop();
61
- return tokens.join("-").slice(0, SESSION_SLUG_MAX_LENGTH).replace(/-+$/gu, "");
62
- }
63
- function sessionKeyUuid(sessionKey) {
64
- const uuid = (agentSessionKeyParts(sessionKey)?.rest)?.match(SESSION_UUID_SUFFIX_RE)?.[1];
65
- return uuid ? uuid.toLowerCase().replaceAll("-", "") : null;
66
- }
67
- function sessionPath(params) {
68
- const rawKey = optionalString(params.sessionKey);
69
- const parsed = rawKey ? agentSessionKeyParts(rawKey) : null;
70
- const fallbackAgentId = optionalString(params.fallbackAgentId);
71
- const agentId = parsed?.agentId ?? (fallbackAgentId ? normalizeAgentId$1(fallbackAgentId) : null);
72
- if (!rawKey || !agentId || !parsed && rawKey.toLowerCase().startsWith("agent:")) return null;
73
- const namespace = `${normalizeBasePath(params.basePath)}/chat`;
74
- const encodedAgentId = encodePathSegment(agentId);
75
- const rest = parsed?.rest ?? rawKey;
76
- const normalizedRest = rest.toLowerCase();
77
- const mainKey = optionalString(params.mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY;
78
- if (!parsed && normalizedRest === DEFAULT_MAIN_KEY || normalizedRest === mainKey || normalizedRest === "global") return `${namespace}/${encodedAgentId}`;
79
- const uuid = parsed ? sessionKeyUuid(rawKey) : null;
80
- if (uuid) {
81
- let length = 8;
82
- const slug = sessionSlug(params.displayName);
83
- let sessionRef = `${slug ? `${slug}-` : ""}${uuid.slice(0, length)}`;
84
- while (length < uuid.length && isReservedSessionRest(sessionRef, params.mainKey)) {
85
- length += 1;
86
- sessionRef = `${slug ? `${slug}-` : ""}${uuid.slice(0, length)}`;
9
+ //#region extensions/clickclack/src/discussions/binding-retention.ts
10
+ var DetachedDiscussionBindingRetention = class {
11
+ #runtime;
12
+ #store;
13
+ #maxRetained;
14
+ constructor(options) {
15
+ this.#runtime = options.runtime;
16
+ this.#store = options.store;
17
+ this.#maxRetained = options.maxRetained;
18
+ }
19
+ mark(sessionKey, binding) {
20
+ const current = this.#store.get(sessionKey);
21
+ if (!current || !this.#sameRoom(current, binding)) return;
22
+ if (current.detachedAt === void 0) this.#store.set(sessionKey, {
23
+ ...current,
24
+ detachedAt: Date.now()
25
+ });
26
+ while (this.#store.detachedCount() > this.#maxRetained) if (!this.#pruneOldest()) throw new Error("ClickClack detached discussion binding retention could not be reduced");
27
+ }
28
+ clear(sessionKey, binding) {
29
+ const current = this.#store.get(sessionKey);
30
+ if (!current || !this.#sameRoom(current, binding)) return;
31
+ if (current.detachedAt === void 0) return current;
32
+ const { detachedAt: _detachedAt, ...retained } = current;
33
+ this.#store.set(sessionKey, retained);
34
+ return retained;
35
+ }
36
+ ensureCapacity(sessionKey) {
37
+ while (!this.#store.hasCapacity(sessionKey)) if (!this.#pruneOldest()) throw new Error("ClickClack discussion binding capacity is exhausted");
38
+ }
39
+ #pruneOldest() {
40
+ for (;;) {
41
+ const oldest = this.#store.oldestDetached();
42
+ if (!oldest) return false;
43
+ const current = this.#store.get(oldest.sessionKey);
44
+ if (!current || current.detachedAt === void 0) continue;
45
+ if (this.#runtime.agent.session.getSessionEntry({
46
+ sessionKey: oldest.sessionKey,
47
+ readConsistency: "latest"
48
+ })) {
49
+ this.clear(oldest.sessionKey, current);
50
+ continue;
51
+ }
52
+ markClickClackDiscussionChannelRevoked(this.#runtime, current);
53
+ this.#store.delete(oldest.sessionKey);
54
+ return true;
87
55
  }
88
- return isReservedSessionRest(sessionRef, params.mainKey) ? null : `${namespace}/${encodedAgentId}/${sessionRef}`;
89
56
  }
90
- const segments = rest.split(":");
91
- if (segments.some((segment) => !segment)) return null;
92
- if (segments.length === 1) {
93
- const segment = segments[0] ?? "";
94
- if (!isReservedSessionRest(segment, params.mainKey) && SHORT_SESSION_REF_RE.test(segment)) return `${namespace}/${encodedAgentId}/~key/${encodePathSegment(segment)}`;
57
+ #sameRoom(left, right) {
58
+ return left.serverBaseUrl === right.serverBaseUrl && left.channelId === right.channelId && left.externalRef === right.externalRef;
95
59
  }
96
- return `${namespace}/${encodedAgentId}/${segments.map(encodePathSegment).join("/")}`;
97
- }
60
+ };
61
+ //#endregion
62
+ //#region extensions/clickclack/src/discussions/control-session-url.ts
98
63
  function controlSessionUrl(baseUrl, sessionKey, fallbackAgentId, mainKey, displayName) {
99
64
  if (!baseUrl) return;
100
65
  const url = new URL(baseUrl);
101
- const path = sessionPath({
66
+ const path = buildControlUiSessionPath({
67
+ namespace: "chat",
102
68
  sessionKey,
103
69
  fallbackAgentId,
104
70
  basePath: url.pathname,
@@ -277,7 +243,7 @@ async function openClickClackDiscussionBinding(params) {
277
243
  sessionKey,
278
244
  readConsistency: "latest"
279
245
  });
280
- if (!entry) return;
246
+ if (!entry || entry.archivedAt !== void 0) return;
281
247
  if (!entry.sessionId?.trim()) throw new Error("OpenClaw session does not yet have a concrete session id");
282
248
  const client = params.clientFactory(account);
283
249
  const workspace = (await client.workspaces()).find((candidate) => candidate.id === account.discussions.workspace || candidate.slug === account.discussions.workspace || candidate.name === account.discussions.workspace);
@@ -286,30 +252,34 @@ async function openClickClackDiscussionBinding(params) {
286
252
  const serverBaseUrl = normalizedServerBaseUrl(account);
287
253
  const credentialFingerprint = discussionCredentialFingerprint(account.token);
288
254
  const unresolved = listPendingDiscussionOpens(runtime).find((pending) => pending.sessionKey === sessionKey);
289
- if (unresolved && (unresolved.accountId !== account.accountId || unresolved.credentialFingerprint !== credentialFingerprint || unresolved.sessionId !== entry.sessionId || unresolved.serverBaseUrl !== serverBaseUrl || unresolved.workspaceId !== workspace.id)) {
255
+ if (unresolved && (unresolved.accountId !== account.accountId || unresolved.credentialFingerprint !== credentialFingerprint || unresolved.serverBaseUrl !== serverBaseUrl || unresolved.workspaceId !== workspace.id)) {
290
256
  await params.reconcilePendingOpen(unresolved);
291
257
  if (listPendingDiscussionOpens(runtime).some((pending) => pending.sessionKey === sessionKey)) throw new Error("A previous ClickClack discussion open is still unresolved; restore its credential and retry");
292
258
  }
293
259
  const config = runtime.config.current();
294
- const agentId = resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config));
260
+ const agentId = resolveSessionAgentId({
261
+ config,
262
+ sessionKey
263
+ });
295
264
  const fallback = fallbackDiscussionLabel(sessionKey, agentId);
296
265
  const label = resolveDiscussionLabel(entry, sessionKey, agentId);
297
266
  const displayTitle = label === fallback ? "" : truncateDiscussionDisplayTitle(label);
298
267
  const section = entry.category?.trim() || account.discussions.section;
299
- const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? "main", config.session?.mainKey, label);
300
- const archived = entry.archivedAt !== void 0;
268
+ const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? agentId, config.session?.mainKey, label);
301
269
  return await params.withChannelMutationLock(async () => {
302
- if (!store.hasCapacity(sessionKey)) throw new Error("ClickClack discussion binding capacity is exhausted");
270
+ params.ensureBindingCapacity(sessionKey);
303
271
  let channels = await client.channels(workspace.id);
304
272
  assertManagedChannelListContract(channels);
305
273
  const destinationIdentity = [serverBaseUrl, workspace.id].join("\0");
306
274
  const bindingGeneration = reserveDiscussionBindingGeneration({
307
275
  runtime,
308
276
  sessionKey,
277
+ accountId: account.accountId,
278
+ credentialFingerprint,
309
279
  destinationIdentity,
310
280
  createGeneration: params.bindingGenerationFactory
311
281
  });
312
- const externalRef = discussionExternalRef(params.installationId, sessionKey, entry.sessionId, destinationIdentity, bindingGeneration);
282
+ const externalRef = discussionExternalRef(params.installationId, sessionKey, destinationIdentity, bindingGeneration);
313
283
  let adopted;
314
284
  let managedFields;
315
285
  let resolved;
@@ -353,10 +323,7 @@ async function openClickClackDiscussionBinding(params) {
353
323
  serverBaseUrl,
354
324
  channelId: adopted.id
355
325
  });
356
- resolved = await client.updateChannel(adopted.id, {
357
- ...managedFields,
358
- archived
359
- });
326
+ resolved = await client.updateChannel(adopted.id, managedFields);
360
327
  } else {
361
328
  resolved = await client.createChannel(workspace.id, {
362
329
  ...managedFields,
@@ -389,10 +356,7 @@ async function openClickClackDiscussionBinding(params) {
389
356
  serverBaseUrl,
390
357
  channelId: recovered.id
391
358
  });
392
- resolved = await client.updateChannel(recovered.id, {
393
- ...managedFields,
394
- archived
395
- });
359
+ resolved = await client.updateChannel(recovered.id, managedFields);
396
360
  break;
397
361
  }
398
362
  if (definitiveNoCreate) clearDiscussionBindingGeneration({
@@ -419,89 +383,104 @@ async function openClickClackDiscussionBinding(params) {
419
383
  externalUrl,
420
384
  displayTitle
421
385
  });
422
- if (adopted) assertChannelPatch(resolved, {
423
- ...managedFields,
424
- archived
425
- });
386
+ if (adopted) assertChannelPatch(resolved, managedFields);
426
387
  } catch (error) {
427
- try {
428
- assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
429
- clearDiscussionBindingGeneration({
430
- runtime,
431
- sessionKey,
432
- expectedGeneration: bindingGeneration
433
- });
434
- } catch (archiveError) {
435
- params.warn(`failed to archive incompatible discussion channel ${resolved.id}: ${String(archiveError)}`);
436
- }
388
+ clearPendingDiscussionOpen({
389
+ runtime,
390
+ sessionKey,
391
+ expectedGeneration: bindingGeneration
392
+ });
393
+ params.warn(`incompatible discussion channel remains quarantined: ${resolved.id}`);
437
394
  throw error;
438
395
  }
439
396
  if (!resolved.route_id) {
440
- try {
441
- assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
442
- clearDiscussionBindingGeneration({
443
- runtime,
444
- sessionKey,
445
- expectedGeneration: bindingGeneration
446
- });
447
- } catch (archiveError) {
448
- params.warn(`failed to archive route-less discussion channel ${resolved.id}: ${String(archiveError)}`);
449
- }
397
+ clearPendingDiscussionOpen({
398
+ runtime,
399
+ sessionKey,
400
+ expectedGeneration: bindingGeneration
401
+ });
402
+ params.warn(`route-less discussion channel remains quarantined: ${resolved.id}`);
450
403
  throw new Error("ClickClack discussion channel is missing its route id");
451
404
  }
452
- let channel = resolved;
453
- if (!adopted && archived) {
454
- channel = await client.updateChannel(resolved.id, { archived: true });
455
- assertChannelPatch(channel, { archived: true });
405
+ const channel = resolved;
406
+ const currentEntry = runtime.agent.session.getSessionEntry({
407
+ sessionKey,
408
+ readConsistency: "latest"
409
+ });
410
+ if (!currentEntry?.sessionId || currentEntry.archivedAt !== void 0) {
411
+ clearPendingDiscussionOpen({
412
+ runtime,
413
+ sessionKey,
414
+ expectedGeneration: bindingGeneration
415
+ });
416
+ params.warn(`unattached discussion channel remains quarantined: ${channel.id}`);
417
+ throw new Error("OpenClaw session became inactive while opening its ClickClack discussion");
418
+ }
419
+ const currentLabel = resolveDiscussionLabel(currentEntry, sessionKey, agentId);
420
+ const currentDisplayTitle = currentLabel === fallback ? "" : truncateDiscussionDisplayTitle(currentLabel);
421
+ const currentSection = currentEntry.category?.trim() || account.discussions.section;
422
+ const currentExternalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? agentId, config.session?.mainKey, currentLabel) ?? "";
423
+ let currentChannel = channel;
424
+ if (currentEntry.sessionId !== entry.sessionId || currentLabel !== label || currentDisplayTitle !== displayTitle || currentSection !== section || currentExternalUrl !== (externalUrl ?? "")) try {
425
+ for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1; attempt += 1) {
426
+ const latestManagedFields = {
427
+ ...managedFields,
428
+ name: await resolveAvailableChannelName({
429
+ client,
430
+ workspaceId: workspace.id,
431
+ label: currentLabel,
432
+ sessionKey,
433
+ agentId,
434
+ ownChannelId: channel.id
435
+ }),
436
+ external_url: currentExternalUrl,
437
+ sidebar_section: currentSection,
438
+ display_title: currentDisplayTitle
439
+ };
440
+ try {
441
+ currentChannel = await client.updateChannel(channel.id, latestManagedFields);
442
+ assertChannelPatch(currentChannel, latestManagedFields);
443
+ break;
444
+ } catch (error) {
445
+ if (!isClickClackChannelNameConflict(error) || attempt === CHANNEL_NAME_MUTATION_ATTEMPTS$1 - 1) throw error;
446
+ }
447
+ }
448
+ } catch (error) {
449
+ clearPendingDiscussionOpen({
450
+ runtime,
451
+ sessionKey,
452
+ expectedGeneration: bindingGeneration
453
+ });
454
+ params.warn(`unattached discussion channel remains quarantined: ${channel.id}`);
455
+ throw error;
456
456
  }
457
457
  const nextBinding = {
458
458
  accountId: account.accountId,
459
459
  agentId,
460
- sessionId: entry.sessionId,
460
+ sessionId: currentEntry.sessionId,
461
461
  serverBaseUrl,
462
462
  credentialFingerprint,
463
463
  externalRef,
464
- externalUrl: externalUrl ?? "",
464
+ externalUrl: currentExternalUrl,
465
465
  workspaceRef: account.discussions.workspace,
466
466
  workspaceId: workspace.id,
467
467
  channelId: channel.id,
468
468
  channelRouteId: channel.route_id,
469
469
  workspaceRouteId: workspace.route_id,
470
- section,
471
- archived,
472
- label,
473
- displayTitle: "display_title" in channel ? channel.display_title : void 0
470
+ section: currentSection,
471
+ archived: false,
472
+ label: currentLabel,
473
+ ...currentChannel.display_title !== void 0 ? { displayTitle: currentChannel.display_title } : {}
474
474
  };
475
- const currentEntry = runtime.agent.session.getSessionEntry({
476
- sessionKey,
477
- readConsistency: "latest"
478
- });
479
- if (!currentEntry || currentEntry.sessionId !== entry.sessionId) {
480
- try {
481
- assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
482
- clearDiscussionBindingGeneration({
483
- runtime,
484
- sessionKey,
485
- expectedGeneration: bindingGeneration
486
- });
487
- } catch (archiveError) {
488
- params.warn(`failed to archive superseded discussion channel ${channel.id}: ${String(archiveError)}`);
489
- }
490
- throw new Error("OpenClaw session changed while opening its ClickClack discussion");
491
- }
492
475
  try {
493
476
  store.set(sessionKey, nextBinding);
494
477
  } catch (error) {
495
- try {
496
- assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
497
- clearDiscussionBindingGeneration({
498
- runtime,
499
- sessionKey,
500
- expectedGeneration: bindingGeneration
501
- });
502
- } catch (archiveError) {
503
- params.warn(`failed to archive unbound discussion channel ${channel.id}: ${String(archiveError)}`);
504
- }
478
+ clearPendingDiscussionOpen({
479
+ runtime,
480
+ sessionKey,
481
+ expectedGeneration: bindingGeneration
482
+ });
483
+ params.warn(`unbound discussion channel remains quarantined: ${channel.id}`);
505
484
  throw error;
506
485
  }
507
486
  params.finalizePendingBinding(sessionKey, nextBinding);
@@ -518,6 +497,7 @@ var ClickClackDiscussionService = class {
518
497
  #clientFactory;
519
498
  #installationId;
520
499
  #bindingGenerationFactory;
500
+ #detachedBindings;
521
501
  #timersEnabled;
522
502
  #sessionLocks = /* @__PURE__ */ new Map();
523
503
  #reconcileScheduler = new DiscussionReconcileScheduler({
@@ -539,6 +519,11 @@ var ClickClackDiscussionService = class {
539
519
  }));
540
520
  this.#installationId = options.installationId ?? getClickClackDiscussionInstallationId(runtime);
541
521
  this.#bindingGenerationFactory = options.bindingGenerationFactory ?? randomUUID;
522
+ this.#detachedBindings = new DetachedDiscussionBindingRetention({
523
+ runtime,
524
+ store: this.#store,
525
+ maxRetained: options.maxRetainedDetachedBindings ?? 1e3
526
+ });
542
527
  this.#timersEnabled = options.startTimer !== false;
543
528
  this.provider = {
544
529
  id: "clickclack",
@@ -616,6 +601,7 @@ var ClickClackDiscussionService = class {
616
601
  ensureTimer: () => this.#ensureTimer(),
617
602
  reconcilePendingOpen: async (pending) => await this.#reconcilePendingOpen(pending, { allowRetry: false }),
618
603
  withChannelMutationLock: async (run) => await this.#withChannelMutationLock(run),
604
+ ensureBindingCapacity: (key) => this.#detachedBindings.ensureCapacity(key),
619
605
  finalizePendingBinding: (key, nextBinding) => this.#finalizePendingBinding(key, nextBinding),
620
606
  warn: (message) => this.#logger().warn(message)
621
607
  });
@@ -658,15 +644,16 @@ var ClickClackDiscussionService = class {
658
644
  if (resolved.state === "retargeted") return { text: "No discussion is bound to this session." };
659
645
  if (resolved.state === "stale") return { text: "No discussion is bound to this session." };
660
646
  if (resolved.state !== "active") return { text: "No discussion is bound to this session." };
661
- if (!bindingMatchesSessionIncarnation(this.#runtime, sessionKey, binding)) return { text: "No discussion is bound to this session." };
647
+ const attached = this.#refreshSessionAttachment(sessionKey, binding);
648
+ if (!attached) return { text: "No discussion is bound to this session." };
662
649
  if (isClickClackDiscussionChannelRevoked({
663
650
  runtime: this.#runtime,
664
651
  serverBaseUrl: binding.serverBaseUrl,
665
652
  channelId: binding.channelId
666
653
  })) return { text: "No discussion is bound to this session." };
667
654
  return {
668
- binding,
669
- text: formatDiscussionHistory(await this.#clientFactory(resolved.account).latestChannelMessages(binding.channelId, limit)) || "The bound discussion has no messages yet."
655
+ binding: attached,
656
+ text: formatDiscussionHistory(await this.#clientFactory(resolved.account).latestChannelMessages(attached.channelId, limit)) || "The bound discussion has no messages yet."
670
657
  };
671
658
  }
672
659
  cleanup() {
@@ -690,62 +677,62 @@ var ClickClackDiscussionService = class {
690
677
  this.#store.delete(sessionKey);
691
678
  return;
692
679
  }
680
+ const entry = this.#runtime.agent.session.getSessionEntry({
681
+ sessionKey,
682
+ readConsistency: "latest"
683
+ });
684
+ if (!entry) {
685
+ this.#detachedBindings.mark(sessionKey, binding);
686
+ return;
687
+ }
688
+ const activeBinding = this.#detachedBindings.clear(sessionKey, binding);
689
+ if (!activeBinding) return;
693
690
  const resolved = resolvedAccount ? {
694
691
  state: "active",
695
692
  account: resolvedAccount
696
- } : await this.#resolveBindingForUse(binding);
693
+ } : await this.#resolveBindingForUse(activeBinding);
697
694
  if (resolved.state === "retargeted") {
698
- this.#revokeAndDeleteBinding(sessionKey, binding);
695
+ this.#revokeAndDeleteBinding(sessionKey, activeBinding);
699
696
  return;
700
697
  }
701
698
  if (resolved.state === "stale") {
702
- await this.#releaseStaleBinding(sessionKey, binding);
699
+ await this.#releaseStaleBinding(sessionKey, activeBinding);
703
700
  return;
704
701
  }
705
702
  if (resolved.state !== "active") return;
706
703
  const account = resolved.account;
707
- if (!account.baseUrl || !account.token) throw new Error(`ClickClack discussion account is no longer configured: ${binding.accountId}`);
708
- const entry = this.#runtime.agent.session.getSessionEntry({
709
- sessionKey,
710
- readConsistency: "latest"
711
- });
712
- if (entry && (!binding.sessionId || entry.sessionId !== binding.sessionId)) {
713
- await this.#archiveAndDeleteBinding(sessionKey, binding, account);
714
- return;
715
- }
716
- const archived = entry ? entry.archivedAt !== void 0 : true;
717
- const deleted = entry === void 0;
718
- const fallback = fallbackDiscussionLabel(sessionKey, binding.agentId);
719
- const label = entry ? resolveDiscussionLabel(entry, sessionKey, binding.agentId) : binding.label;
720
- const section = entry?.category?.trim() || account.discussions.section;
704
+ if (!account.baseUrl || !account.token) throw new Error(`ClickClack discussion account is no longer configured: ${activeBinding.accountId}`);
705
+ if (entry.archivedAt !== void 0) return;
706
+ const attached = this.#refreshSessionAttachment(sessionKey, activeBinding);
707
+ if (!attached) return;
708
+ const currentBinding = attached;
709
+ const fallback = fallbackDiscussionLabel(sessionKey, currentBinding.agentId);
710
+ const label = resolveDiscussionLabel(entry, sessionKey, currentBinding.agentId);
711
+ const section = entry.category?.trim() || account.discussions.section;
721
712
  const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? "main", this.#currentConfig().session?.mainKey, label) ?? "";
722
713
  const patch = {};
723
- if (archived !== binding.archived) patch.archived = archived;
724
- const labelChanged = label !== binding.label;
714
+ const labelChanged = label !== currentBinding.label;
725
715
  const desiredDisplayTitle = label === fallback ? "" : truncateDiscussionDisplayTitle(label);
726
- const serverSupportsDisplayTitle = this.#store.entries().some(({ binding: candidate }) => candidate.displayTitle !== void 0 && candidate.serverBaseUrl === binding.serverBaseUrl && candidate.accountId === binding.accountId);
727
- const shouldBackfillDisplayTitle = desiredDisplayTitle !== "" && binding.displayTitle !== desiredDisplayTitle && serverSupportsDisplayTitle;
716
+ const serverSupportsDisplayTitle = this.#store.entries().some(({ binding: candidate }) => candidate.displayTitle !== void 0 && candidate.serverBaseUrl === currentBinding.serverBaseUrl && candidate.accountId === currentBinding.accountId);
717
+ const shouldBackfillDisplayTitle = desiredDisplayTitle !== "" && currentBinding.displayTitle !== desiredDisplayTitle && serverSupportsDisplayTitle;
728
718
  if (labelChanged || shouldBackfillDisplayTitle) patch.display_title = desiredDisplayTitle;
729
- if (section !== binding.section) patch.sidebar_section = section;
730
- if (externalUrl !== binding.externalUrl) patch.external_url = externalUrl;
731
- if (Object.keys(patch).length === 0 && !labelChanged) {
732
- if (deleted) this.#revokeAndDeleteBinding(sessionKey, binding);
733
- return;
734
- }
719
+ if (section !== currentBinding.section) patch.sidebar_section = section;
720
+ if (externalUrl !== currentBinding.externalUrl) patch.external_url = externalUrl;
721
+ if (Object.keys(patch).length === 0 && !labelChanged) return;
735
722
  const client = this.#clientFactory(account);
736
723
  let updated;
737
724
  if (labelChanged) updated = await this.#withChannelMutationLock(async () => {
738
725
  for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
739
726
  patch.name = await resolveAvailableChannelName({
740
727
  client,
741
- workspaceId: binding.workspaceId,
728
+ workspaceId: currentBinding.workspaceId,
742
729
  label,
743
730
  sessionKey,
744
- agentId: binding.agentId,
745
- ownChannelId: binding.channelId
731
+ agentId: currentBinding.agentId,
732
+ ownChannelId: currentBinding.channelId
746
733
  });
747
734
  try {
748
- const renamed = await client.updateChannel(binding.channelId, patch);
735
+ const renamed = await client.updateChannel(currentBinding.channelId, patch);
749
736
  assertChannelPatch(renamed, patch);
750
737
  return renamed;
751
738
  } catch (error) {
@@ -755,21 +742,33 @@ var ClickClackDiscussionService = class {
755
742
  throw new Error("ClickClack discussion channel name retries were exhausted");
756
743
  });
757
744
  else {
758
- updated = await client.updateChannel(binding.channelId, patch);
745
+ updated = await client.updateChannel(currentBinding.channelId, patch);
759
746
  assertChannelPatch(updated, patch);
760
747
  }
761
- if (deleted) {
762
- this.#revokeAndDeleteBinding(sessionKey, binding);
763
- return;
764
- }
765
- this.#store.set(sessionKey, {
766
- ...binding,
767
- archived,
748
+ const latestBinding = this.#store.get(sessionKey);
749
+ if (!latestBinding || latestBinding.serverBaseUrl !== currentBinding.serverBaseUrl || latestBinding.channelId !== currentBinding.channelId || latestBinding.externalRef !== currentBinding.externalRef) return;
750
+ const nextBinding = {
751
+ ...latestBinding,
768
752
  externalUrl,
769
753
  label,
770
754
  section,
771
- displayTitle: "display_title" in updated ? updated.display_title : void 0
772
- });
755
+ ...updated.display_title !== void 0 ? { displayTitle: updated.display_title } : {}
756
+ };
757
+ if (updated.display_title === void 0) delete nextBinding.displayTitle;
758
+ this.#store.set(sessionKey, nextBinding);
759
+ }
760
+ #refreshSessionAttachment(sessionKey, binding) {
761
+ try {
762
+ return attachBindingToCurrentActiveSession({
763
+ runtime: this.#runtime,
764
+ store: this.#store,
765
+ sessionKey,
766
+ binding
767
+ });
768
+ } catch (error) {
769
+ this.#logger().warn(`discussion attachment refresh failed for ${sessionKey}: ${String(error)}`);
770
+ return;
771
+ }
773
772
  }
774
773
  async #reconcilePendingOpen(pending, options = {}) {
775
774
  const currentBinding = this.#store.get(pending.sessionKey);
@@ -790,7 +789,7 @@ var ClickClackDiscussionService = class {
790
789
  });
791
790
  const activeAccounts = discussionAccounts(cfg);
792
791
  const retryAccount = activeAccounts.length === 1 ? activeAccounts[0] : void 0;
793
- if (options.allowRetry !== false && entry?.sessionId === pending.sessionId && retryAccount && normalizedServerBaseUrl(retryAccount) === pending.serverBaseUrl && discussionCredentialFingerprint(retryAccount.token) === pending.credentialFingerprint) {
792
+ if (options.allowRetry !== false && entry?.sessionId && entry.archivedAt === void 0 && retryAccount && normalizedServerBaseUrl(retryAccount) === pending.serverBaseUrl && discussionCredentialFingerprint(retryAccount.token) === pending.credentialFingerprint) {
794
793
  if ((await this.#clientFactory(retryAccount).workspaces()).find((candidate) => candidate.id === retryAccount.discussions.workspace || candidate.slug === retryAccount.discussions.workspace || candidate.name === retryAccount.discussions.workspace)?.id === pending.workspaceId) {
795
794
  await this.open(pending.sessionKey);
796
795
  return;
@@ -806,7 +805,12 @@ var ClickClackDiscussionService = class {
806
805
  serverBaseUrl: pending.serverBaseUrl,
807
806
  channelId: channel.id
808
807
  });
809
- assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
808
+ clearPendingDiscussionOpen({
809
+ runtime: this.#runtime,
810
+ sessionKey: pending.sessionKey,
811
+ expectedGeneration: pending.generation
812
+ });
813
+ return;
810
814
  }
811
815
  clearDiscussionBindingGeneration({
812
816
  runtime: this.#runtime,
@@ -819,23 +823,6 @@ var ClickClackDiscussionService = class {
819
823
  runtime: this.#runtime,
820
824
  sessionKey
821
825
  });
822
- const boundAccount = resolveClickClackAccount({
823
- cfg: this.#currentConfig(),
824
- accountId: binding.accountId
825
- });
826
- if (!boundAccount.configured || binding.serverBaseUrl !== normalizedServerBaseUrl(boundAccount) || !binding.credentialFingerprint || binding.credentialFingerprint !== discussionCredentialFingerprint(boundAccount.token)) {
827
- this.#revokeAndDeleteBinding(sessionKey, binding);
828
- return;
829
- }
830
- assertChannelPatch(await this.#clientFactory(boundAccount).updateChannel(binding.channelId, { archived: true }), { archived: true });
831
- this.#revokeAndDeleteBinding(sessionKey, binding);
832
- }
833
- async #archiveAndDeleteBinding(sessionKey, binding, account) {
834
- clearDiscussionBindingGeneration({
835
- runtime: this.#runtime,
836
- sessionKey
837
- });
838
- assertChannelPatch(await this.#clientFactory(account).updateChannel(binding.channelId, { archived: true }), { archived: true });
839
826
  this.#revokeAndDeleteBinding(sessionKey, binding);
840
827
  }
841
828
  #revokeAndDeleteBinding(sessionKey, binding) {
@@ -928,7 +915,7 @@ function isClickClackDiscussionSessionTarget(params) {
928
915
  runtime: params.runtime,
929
916
  serverBaseUrl: matched.binding.serverBaseUrl,
930
917
  channelId: matched.binding.channelId
931
- }) && !matched.binding.archived && bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding) && resolveDiscussionBindingAccount(params.runtime.config.current(), matched.binding).state === "active") return matched;
918
+ }) && bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding) && resolveDiscussionBindingAccount(params.runtime.config.current(), matched.binding).state === "active") return matched;
932
919
  }
933
920
  /** Restricts a discussion side session's session tools to its attached main session. */
934
921
  function enforceClickClackDiscussionToolTarget(params) {
@@ -1,4 +1,4 @@
1
- import { d as createClickClackClient, x as resolveClickClackAccount } from "./setup-surface-DAy8vDJK.js";
2
- import { a as setClickClackRuntime, r as parseClickClackTarget } from "./channel-CECIpaS0.js";
3
- import { t as registerClickClackDiscussions } from "./runtime-api-CZ1rmHjv.js";
1
+ import { b as resolveClickClackAccount, u as createClickClackClient } from "./setup-surface-BQ1Zyohu.js";
2
+ import { a as setClickClackRuntime, r as parseClickClackTarget } from "./channel-ChO3E9i2.js";
3
+ import { t as registerClickClackDiscussions } from "./runtime-api-B0ChZLhm.js";
4
4
  export { createClickClackClient, parseClickClackTarget, registerClickClackDiscussions, resolveClickClackAccount, setClickClackRuntime };