@openclaw/clickclack 2026.7.2-beta.2 → 2026.7.2-beta.4

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.
@@ -0,0 +1,782 @@
1
+ import { d as createClickClackClient, f as isClickClackChannelNameConflict, u as ClickClackHttpError, x as resolveClickClackAccount, y as listClickClackAccountIds } from "./setup-surface-Baf26N-D.js";
2
+ import { C as reserveDiscussionBindingGeneration, S as recordPendingDiscussionOpen, _ as isDiscussionSessionKey, a as isClickClackDiscussionChannelRevoked, b as clearDiscussionBindingGeneration, c as discussionAccounts, d as bindingMatchesActiveSessionIncarnation, f as bindingMatchesSessionIncarnation, g as fallbackDiscussionLabel, h as discussionExternalRef, i as clearClickClackDiscussionChannelRevoked, l as normalizedServerBaseUrl, m as discussionCredentialFingerprint, o as markClickClackDiscussionChannelIdentityRevoked, p as getClickClackDiscussionBindingStore, s as markClickClackDiscussionChannelRevoked, u as resolveDiscussionBindingAccount, v as resolveDiscussionLabel, x as listPendingDiscussionOpens, y as slugifyDiscussionLabel } from "./channel-BDq0KWDB.js";
3
+ import "./api.js";
4
+ import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
5
+ import { randomUUID } from "node:crypto";
6
+ import { registerSessionDiscussionProvider } from "openclaw/plugin-sdk/session-discussion";
7
+ import { createSessionVisibilityChecker } from "openclaw/plugin-sdk/session-visibility";
8
+ import { textResult } from "openclaw/plugin-sdk/tool-results";
9
+ //#region extensions/clickclack/src/discussions/installation.ts
10
+ const INSTALLATION_NAMESPACE = "discussion-installation";
11
+ const INSTALLATION_KEY = "current";
12
+ /** Returns the durable installation namespace used in server-visible ownership refs. */
13
+ function getClickClackDiscussionInstallationId(runtime) {
14
+ const store = runtime.state.openSyncKeyedStore({
15
+ namespace: INSTALLATION_NAMESPACE,
16
+ maxEntries: 1,
17
+ overflowPolicy: "reject-new"
18
+ });
19
+ const existing = store.lookup(INSTALLATION_KEY)?.id;
20
+ if (existing) return existing;
21
+ const id = randomUUID();
22
+ store.registerIfAbsent(INSTALLATION_KEY, { id });
23
+ return store.lookup(INSTALLATION_KEY)?.id ?? id;
24
+ }
25
+ //#endregion
26
+ //#region extensions/clickclack/src/discussions/service-open.ts
27
+ const CHANNEL_NAME_MUTATION_ATTEMPTS$1 = 4;
28
+ function isDefinitiveNoCreateHttpError(error) {
29
+ if (!(error instanceof ClickClackHttpError) || error.status < 400 || error.status >= 500) return false;
30
+ return ![
31
+ 408,
32
+ 409,
33
+ 425,
34
+ 429
35
+ ].includes(error.status);
36
+ }
37
+ function controlSessionUrl(baseUrl, sessionKey) {
38
+ if (!baseUrl) return;
39
+ const url = new URL(baseUrl);
40
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/chat`;
41
+ url.hash = "";
42
+ url.searchParams.set("session", sessionKey);
43
+ return url.toString();
44
+ }
45
+ async function resolveAvailableChannelName(params) {
46
+ const desired = slugifyDiscussionLabel(params.label, params.sessionKey);
47
+ const channels = params.channels ?? await params.client.channels(params.workspaceId);
48
+ const occupied = new Set(channels.filter((channel) => channel.id !== params.ownChannelId).map((channel) => channel.name));
49
+ if (!occupied.has(desired)) return desired;
50
+ const fallback = fallbackDiscussionLabel(params.sessionKey);
51
+ if (!occupied.has(fallback)) return fallback;
52
+ for (let suffix = 2;; suffix += 1) {
53
+ const candidate = `${fallback}-${suffix}`;
54
+ if (!occupied.has(candidate)) return candidate;
55
+ }
56
+ }
57
+ function assertChannelPatch(channel, patch) {
58
+ for (const key of [
59
+ "archived",
60
+ "external_managed",
61
+ "external_ref",
62
+ "external_url",
63
+ "name",
64
+ "sidebar_section"
65
+ ]) {
66
+ const expected = patch[key];
67
+ if (expected === void 0) continue;
68
+ 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}`);
69
+ }
70
+ }
71
+ function assertManagedChannelContract(channel, expected) {
72
+ if (channel.external_managed !== true || (channel.external_ref ?? "") !== (expected.externalRef ?? "") || (channel.sidebar_section ?? "") !== (expected.section ?? "") || (channel.external_url ?? "") !== (expected.externalUrl ?? "")) throw new Error(`ClickClack server does not support the managed discussion channel contract for ${expected.sessionKey}`);
73
+ }
74
+ function assertManagedChannelListContract(channels) {
75
+ if (channels.some((channel) => typeof channel.external_managed !== "boolean" || channel.external_ref !== void 0 && typeof channel.external_ref !== "string" || channel.external_url !== void 0 && typeof channel.external_url !== "string" || channel.sidebar_section !== void 0 && typeof channel.sidebar_section !== "string")) throw new Error("ClickClack server does not advertise the managed discussion contract");
76
+ }
77
+ async function openClickClackDiscussionBinding(params) {
78
+ const { account, runtime, sessionKey, store } = params;
79
+ const entry = runtime.agent.session.getSessionEntry({
80
+ sessionKey,
81
+ readConsistency: "latest"
82
+ });
83
+ if (!entry) return;
84
+ if (!entry.sessionId?.trim()) throw new Error("OpenClaw session does not yet have a concrete session id");
85
+ const client = params.clientFactory(account);
86
+ const workspace = (await client.workspaces()).find((candidate) => candidate.id === account.discussions.workspace || candidate.slug === account.discussions.workspace || candidate.name === account.discussions.workspace);
87
+ if (!workspace) throw new Error(`ClickClack discussions workspace not found: ${account.discussions.workspace}`);
88
+ if (!workspace.route_id) throw new Error("ClickClack discussions workspace is missing its route id");
89
+ const serverBaseUrl = normalizedServerBaseUrl(account);
90
+ const credentialFingerprint = discussionCredentialFingerprint(account.token);
91
+ const unresolved = listPendingDiscussionOpens(runtime).find((pending) => pending.sessionKey === sessionKey);
92
+ if (unresolved && (unresolved.accountId !== account.accountId || unresolved.credentialFingerprint !== credentialFingerprint || unresolved.sessionId !== entry.sessionId || unresolved.serverBaseUrl !== serverBaseUrl || unresolved.workspaceId !== workspace.id)) {
93
+ await params.reconcilePendingOpen(unresolved);
94
+ if (listPendingDiscussionOpens(runtime).some((pending) => pending.sessionKey === sessionKey)) throw new Error("A previous ClickClack discussion open is still unresolved; restore its credential and retry");
95
+ }
96
+ const label = resolveDiscussionLabel(entry.label, sessionKey);
97
+ const section = entry.category?.trim() || account.discussions.section;
98
+ const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey);
99
+ const archived = entry.archivedAt !== void 0;
100
+ return await params.withChannelMutationLock(async () => {
101
+ if (!store.hasCapacity(sessionKey)) throw new Error("ClickClack discussion binding capacity is exhausted");
102
+ let channels = await client.channels(workspace.id);
103
+ assertManagedChannelListContract(channels);
104
+ const destinationIdentity = [serverBaseUrl, workspace.id].join("\0");
105
+ const bindingGeneration = reserveDiscussionBindingGeneration({
106
+ runtime,
107
+ sessionKey,
108
+ destinationIdentity,
109
+ createGeneration: params.bindingGenerationFactory
110
+ });
111
+ const externalRef = discussionExternalRef(params.installationId, sessionKey, entry.sessionId, destinationIdentity, bindingGeneration);
112
+ let adopted;
113
+ let managedFields;
114
+ let resolved;
115
+ for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1; attempt += 1) {
116
+ adopted = channels.find((candidate) => candidate.external_managed === true && candidate.external_ref === externalRef);
117
+ managedFields = {
118
+ name: await resolveAvailableChannelName({
119
+ client,
120
+ workspaceId: workspace.id,
121
+ label,
122
+ sessionKey,
123
+ channels,
124
+ ownChannelId: adopted?.id
125
+ }),
126
+ external_managed: true,
127
+ external_ref: externalRef,
128
+ external_url: externalUrl ?? "",
129
+ sidebar_section: section
130
+ };
131
+ recordPendingDiscussionOpen({
132
+ runtime,
133
+ sessionKey,
134
+ generation: bindingGeneration,
135
+ pending: {
136
+ accountId: account.accountId,
137
+ serverBaseUrl,
138
+ workspaceId: workspace.id,
139
+ sessionId: entry.sessionId,
140
+ externalRef,
141
+ credentialFingerprint
142
+ }
143
+ });
144
+ params.ensureTimer();
145
+ try {
146
+ if (adopted) {
147
+ markClickClackDiscussionChannelIdentityRevoked({
148
+ runtime,
149
+ accountId: account.accountId,
150
+ serverBaseUrl,
151
+ channelId: adopted.id
152
+ });
153
+ resolved = await client.updateChannel(adopted.id, {
154
+ ...managedFields,
155
+ archived
156
+ });
157
+ } else {
158
+ resolved = await client.createChannel(workspace.id, {
159
+ ...managedFields,
160
+ kind: "public"
161
+ });
162
+ markClickClackDiscussionChannelIdentityRevoked({
163
+ runtime,
164
+ accountId: account.accountId,
165
+ serverBaseUrl,
166
+ channelId: resolved.id
167
+ });
168
+ }
169
+ break;
170
+ } catch (error) {
171
+ if (isClickClackChannelNameConflict(error) && attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1 - 1) {
172
+ channels = await client.channels(workspace.id);
173
+ assertManagedChannelListContract(channels);
174
+ continue;
175
+ }
176
+ const definitiveNoCreate = isDefinitiveNoCreateHttpError(error);
177
+ try {
178
+ const relisted = await client.channels(workspace.id);
179
+ assertManagedChannelListContract(relisted);
180
+ const recovered = relisted.find((candidate) => candidate.external_managed === true && candidate.external_ref === externalRef);
181
+ if (recovered) {
182
+ adopted = recovered;
183
+ markClickClackDiscussionChannelIdentityRevoked({
184
+ runtime,
185
+ accountId: account.accountId,
186
+ serverBaseUrl,
187
+ channelId: recovered.id
188
+ });
189
+ resolved = await client.updateChannel(recovered.id, {
190
+ ...managedFields,
191
+ archived
192
+ });
193
+ break;
194
+ }
195
+ if (definitiveNoCreate) clearDiscussionBindingGeneration({
196
+ runtime,
197
+ sessionKey,
198
+ expectedGeneration: bindingGeneration
199
+ });
200
+ } catch {
201
+ if (definitiveNoCreate && !adopted) clearDiscussionBindingGeneration({
202
+ runtime,
203
+ sessionKey,
204
+ expectedGeneration: bindingGeneration
205
+ });
206
+ }
207
+ throw error;
208
+ }
209
+ }
210
+ if (!resolved || !managedFields) throw new Error("ClickClack discussion channel name retries were exhausted");
211
+ try {
212
+ assertManagedChannelContract(resolved, {
213
+ sessionKey,
214
+ externalRef,
215
+ section,
216
+ externalUrl
217
+ });
218
+ if (adopted) assertChannelPatch(resolved, {
219
+ ...managedFields,
220
+ archived
221
+ });
222
+ } catch (error) {
223
+ try {
224
+ assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
225
+ clearDiscussionBindingGeneration({
226
+ runtime,
227
+ sessionKey,
228
+ expectedGeneration: bindingGeneration
229
+ });
230
+ } catch (archiveError) {
231
+ params.warn(`failed to archive incompatible discussion channel ${resolved.id}: ${String(archiveError)}`);
232
+ }
233
+ throw error;
234
+ }
235
+ if (!resolved.route_id) {
236
+ try {
237
+ assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
238
+ clearDiscussionBindingGeneration({
239
+ runtime,
240
+ sessionKey,
241
+ expectedGeneration: bindingGeneration
242
+ });
243
+ } catch (archiveError) {
244
+ params.warn(`failed to archive route-less discussion channel ${resolved.id}: ${String(archiveError)}`);
245
+ }
246
+ throw new Error("ClickClack discussion channel is missing its route id");
247
+ }
248
+ let channel = resolved;
249
+ if (!adopted && archived) {
250
+ channel = await client.updateChannel(resolved.id, { archived: true });
251
+ assertChannelPatch(channel, { archived: true });
252
+ }
253
+ const nextBinding = {
254
+ accountId: account.accountId,
255
+ agentId: resolveAgentIdFromSessionKey(sessionKey),
256
+ sessionId: entry.sessionId,
257
+ serverBaseUrl,
258
+ credentialFingerprint,
259
+ externalRef,
260
+ externalUrl: externalUrl ?? "",
261
+ workspaceRef: account.discussions.workspace,
262
+ workspaceId: workspace.id,
263
+ channelId: channel.id,
264
+ channelRouteId: channel.route_id,
265
+ workspaceRouteId: workspace.route_id,
266
+ section,
267
+ archived,
268
+ label
269
+ };
270
+ const currentEntry = runtime.agent.session.getSessionEntry({
271
+ sessionKey,
272
+ readConsistency: "latest"
273
+ });
274
+ if (!currentEntry || currentEntry.sessionId !== entry.sessionId) {
275
+ try {
276
+ assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
277
+ clearDiscussionBindingGeneration({
278
+ runtime,
279
+ sessionKey,
280
+ expectedGeneration: bindingGeneration
281
+ });
282
+ } catch (archiveError) {
283
+ params.warn(`failed to archive superseded discussion channel ${channel.id}: ${String(archiveError)}`);
284
+ }
285
+ throw new Error("OpenClaw session changed while opening its ClickClack discussion");
286
+ }
287
+ try {
288
+ store.set(sessionKey, nextBinding);
289
+ } catch (error) {
290
+ try {
291
+ assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
292
+ clearDiscussionBindingGeneration({
293
+ runtime,
294
+ sessionKey,
295
+ expectedGeneration: bindingGeneration
296
+ });
297
+ } catch (archiveError) {
298
+ params.warn(`failed to archive unbound discussion channel ${channel.id}: ${String(archiveError)}`);
299
+ }
300
+ throw error;
301
+ }
302
+ params.finalizePendingBinding(sessionKey, nextBinding);
303
+ return nextBinding;
304
+ });
305
+ }
306
+ //#endregion
307
+ //#region extensions/clickclack/src/discussions/service.ts
308
+ const RECONCILE_INTERVAL_MS = 6e4;
309
+ const CHANNEL_NAME_MUTATION_ATTEMPTS = 4;
310
+ function discussionInfoForBinding(binding, account) {
311
+ const baseUrl = normalizedServerBaseUrl(account);
312
+ return {
313
+ state: "open",
314
+ embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`,
315
+ openUrl: `${baseUrl}/app/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`
316
+ };
317
+ }
318
+ function discussionRecordJson(value) {
319
+ return JSON.stringify(value).replace(/[\u0085\u2028\u2029]/gu, (separator) => `\\u${separator.charCodeAt(0).toString(16).padStart(4, "0")}`);
320
+ }
321
+ var ClickClackDiscussionService = class {
322
+ #runtime;
323
+ #store;
324
+ #clientFactory;
325
+ #installationId;
326
+ #bindingGenerationFactory;
327
+ #timersEnabled;
328
+ #sessionLocks = /* @__PURE__ */ new Map();
329
+ #channelMutationLock = Promise.resolve();
330
+ #timer;
331
+ #reconcileAllPromise;
332
+ constructor(runtime, options = {}) {
333
+ this.#runtime = runtime;
334
+ this.#store = getClickClackDiscussionBindingStore(runtime);
335
+ this.#clientFactory = options.clientFactory ?? ((account) => createClickClackClient({
336
+ baseUrl: account.apiEndpoint,
337
+ token: account.token
338
+ }));
339
+ this.#installationId = options.installationId ?? getClickClackDiscussionInstallationId(runtime);
340
+ this.#bindingGenerationFactory = options.bindingGenerationFactory ?? randomUUID;
341
+ this.#timersEnabled = options.startTimer !== false;
342
+ this.provider = {
343
+ id: "clickclack",
344
+ info: async ({ sessionKey }) => await this.info(sessionKey),
345
+ open: async ({ sessionKey }) => await this.open(sessionKey)
346
+ };
347
+ if (this.#timersEnabled) this.#ensureTimer();
348
+ }
349
+ hasEnabledAccount() {
350
+ return discussionAccounts(this.#currentConfig()).length === 1;
351
+ }
352
+ async info(sessionKey) {
353
+ return await this.#withSessionLock(sessionKey, async () => {
354
+ if (discussionAccounts(this.#currentConfig()).length !== 1) return { state: "none" };
355
+ const existing = this.#store.get(sessionKey);
356
+ if (existing) {
357
+ const resolved = await this.#resolveBindingForUse(existing);
358
+ if (resolved.state === "retargeted") {
359
+ this.#revokeAndDeleteBinding(sessionKey, existing);
360
+ return { state: "available" };
361
+ }
362
+ if (resolved.state === "stale") {
363
+ await this.#releaseStaleBinding(sessionKey, existing);
364
+ return { state: "available" };
365
+ }
366
+ if (resolved.state !== "active") return { state: "none" };
367
+ this.#finalizePendingBinding(sessionKey, existing);
368
+ await this.#reconcileBinding(sessionKey, existing, resolved.account);
369
+ const current = this.#store.get(sessionKey);
370
+ if (!current) return { state: this.hasEnabledAccount() ? "available" : "none" };
371
+ return discussionInfoForBinding(current, resolved.account);
372
+ }
373
+ return { state: "available" };
374
+ });
375
+ }
376
+ async open(sessionKey) {
377
+ return await this.#withSessionLock(sessionKey, async () => {
378
+ const accounts = discussionAccounts(this.#currentConfig());
379
+ if (accounts.length > 1) throw new Error("ClickClack discussions require exactly one enabled discussion account");
380
+ const account = accounts[0];
381
+ if (!account) return { state: "none" };
382
+ const existing = this.#store.get(sessionKey);
383
+ if (existing) {
384
+ const resolved = await this.#resolveBindingForUse(existing);
385
+ if (resolved.state === "retargeted") this.#revokeAndDeleteBinding(sessionKey, existing);
386
+ else if (resolved.state === "stale") await this.#releaseStaleBinding(sessionKey, existing);
387
+ else if (resolved.state === "active") {
388
+ this.#finalizePendingBinding(sessionKey, existing);
389
+ await this.#reconcileBinding(sessionKey, existing, resolved.account);
390
+ const current = this.#store.get(sessionKey);
391
+ if (current) return discussionInfoForBinding(current, resolved.account);
392
+ }
393
+ }
394
+ const binding = await openClickClackDiscussionBinding({
395
+ runtime: this.#runtime,
396
+ store: this.#store,
397
+ account,
398
+ clientFactory: this.#clientFactory,
399
+ installationId: this.#installationId,
400
+ bindingGenerationFactory: this.#bindingGenerationFactory,
401
+ sessionKey,
402
+ ensureTimer: () => this.#ensureTimer(),
403
+ reconcilePendingOpen: async (pending) => await this.#reconcilePendingOpen(pending, { allowRetry: false }),
404
+ withChannelMutationLock: async (run) => await this.#withChannelMutationLock(run),
405
+ finalizePendingBinding: (key, nextBinding) => this.#finalizePendingBinding(key, nextBinding),
406
+ warn: (message) => this.#logger().warn(message)
407
+ });
408
+ if (!binding) return { state: "available" };
409
+ this.#ensureTimer();
410
+ return discussionInfoForBinding(binding, account);
411
+ });
412
+ }
413
+ async reconcile(sessionKey) {
414
+ await this.#withSessionLock(sessionKey, async () => {
415
+ const binding = this.#store.get(sessionKey);
416
+ if (binding) await this.#reconcileBinding(sessionKey, binding);
417
+ });
418
+ }
419
+ async reconcileAll() {
420
+ if (this.#reconcileAllPromise) return await this.#reconcileAllPromise;
421
+ this.#reconcileAllPromise = (async () => {
422
+ for (const { sessionKey } of this.#store.entries()) try {
423
+ await this.reconcile(sessionKey);
424
+ } catch (error) {
425
+ this.#logger().warn(`discussion reconcile failed for ${sessionKey}: ${String(error)}`);
426
+ }
427
+ for (const pending of listPendingDiscussionOpens(this.#runtime)) try {
428
+ await this.#reconcilePendingOpen(pending);
429
+ } catch (error) {
430
+ this.#logger().warn(`discussion pending-open reconcile failed for ${pending.sessionKey}: ${String(error)}`);
431
+ }
432
+ })().finally(() => {
433
+ this.#reconcileAllPromise = void 0;
434
+ });
435
+ return await this.#reconcileAllPromise;
436
+ }
437
+ async readLatestMessages(sessionKey, limit) {
438
+ const binding = this.#store.get(sessionKey);
439
+ if (!binding) return { text: "No discussion is bound to this session." };
440
+ const resolved = await this.#resolveBindingForUse(binding);
441
+ if (resolved.state === "retargeted") return { text: "No discussion is bound to this session." };
442
+ if (resolved.state === "stale") return { text: "No discussion is bound to this session." };
443
+ if (resolved.state !== "active") return { text: "No discussion is bound to this session." };
444
+ if (!bindingMatchesSessionIncarnation(this.#runtime, sessionKey, binding)) return { text: "No discussion is bound to this session." };
445
+ if (isClickClackDiscussionChannelRevoked({
446
+ runtime: this.#runtime,
447
+ serverBaseUrl: binding.serverBaseUrl,
448
+ channelId: binding.channelId
449
+ })) return { text: "No discussion is bound to this session." };
450
+ const history = await this.#clientFactory(resolved.account).latestChannelMessages(binding.channelId, limit);
451
+ const text = history.messages.map((message) => {
452
+ const author = message.author?.display_name || message.author?.handle || message.author_id || "Unknown";
453
+ return `timestamp=${discussionRecordJson(message.created_at)} [Author ${discussionRecordJson(author)} id=${discussionRecordJson(message.author_id)}] text=${discussionRecordJson(message.body)}`;
454
+ }).join("\n");
455
+ const truncationNote = history.truncated ? "\n[History scan reached its safety bound; older active threads may be omitted.]" : "";
456
+ return {
457
+ binding,
458
+ text: text ? `${text}${truncationNote}` : "The bound discussion has no messages yet."
459
+ };
460
+ }
461
+ cleanup() {
462
+ if (this.#timer) {
463
+ clearInterval(this.#timer);
464
+ this.#timer = void 0;
465
+ }
466
+ }
467
+ async #reconcileBinding(sessionKey, binding, resolvedAccount) {
468
+ this.#finalizePendingBinding(sessionKey, binding);
469
+ if (isClickClackDiscussionChannelRevoked({
470
+ runtime: this.#runtime,
471
+ serverBaseUrl: binding.serverBaseUrl,
472
+ channelId: binding.channelId
473
+ })) {
474
+ this.#store.delete(sessionKey);
475
+ return;
476
+ }
477
+ const resolved = resolvedAccount ? {
478
+ state: "active",
479
+ account: resolvedAccount
480
+ } : await this.#resolveBindingForUse(binding);
481
+ if (resolved.state === "retargeted") {
482
+ this.#revokeAndDeleteBinding(sessionKey, binding);
483
+ return;
484
+ }
485
+ if (resolved.state === "stale") {
486
+ await this.#releaseStaleBinding(sessionKey, binding);
487
+ return;
488
+ }
489
+ if (resolved.state !== "active") return;
490
+ const account = resolved.account;
491
+ if (!account.baseUrl || !account.token) throw new Error(`ClickClack discussion account is no longer configured: ${binding.accountId}`);
492
+ const entry = this.#runtime.agent.session.getSessionEntry({
493
+ sessionKey,
494
+ readConsistency: "latest"
495
+ });
496
+ if (entry && (!binding.sessionId || entry.sessionId !== binding.sessionId)) {
497
+ await this.#archiveAndDeleteBinding(sessionKey, binding, account);
498
+ return;
499
+ }
500
+ const archived = entry ? entry.archivedAt !== void 0 : true;
501
+ const deleted = entry === void 0;
502
+ const label = entry ? resolveDiscussionLabel(entry.label, sessionKey) : binding.label;
503
+ const section = entry?.category?.trim() || account.discussions.section;
504
+ const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey) ?? "";
505
+ const patch = {};
506
+ if (archived !== binding.archived) patch.archived = archived;
507
+ const labelChanged = label !== binding.label;
508
+ if (section !== binding.section) patch.sidebar_section = section;
509
+ if (externalUrl !== binding.externalUrl) patch.external_url = externalUrl;
510
+ if (Object.keys(patch).length === 0 && !labelChanged) {
511
+ if (deleted) this.#revokeAndDeleteBinding(sessionKey, binding);
512
+ return;
513
+ }
514
+ const client = this.#clientFactory(account);
515
+ if (labelChanged) await this.#withChannelMutationLock(async () => {
516
+ for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
517
+ patch.name = await resolveAvailableChannelName({
518
+ client,
519
+ workspaceId: binding.workspaceId,
520
+ label,
521
+ sessionKey,
522
+ ownChannelId: binding.channelId
523
+ });
524
+ try {
525
+ assertChannelPatch(await client.updateChannel(binding.channelId, patch), patch);
526
+ return;
527
+ } catch (error) {
528
+ if (!isClickClackChannelNameConflict(error) || attempt === CHANNEL_NAME_MUTATION_ATTEMPTS - 1) throw error;
529
+ }
530
+ }
531
+ });
532
+ else assertChannelPatch(await client.updateChannel(binding.channelId, patch), patch);
533
+ if (deleted) {
534
+ this.#revokeAndDeleteBinding(sessionKey, binding);
535
+ return;
536
+ }
537
+ this.#store.set(sessionKey, {
538
+ ...binding,
539
+ archived,
540
+ externalUrl,
541
+ label,
542
+ section
543
+ });
544
+ }
545
+ async #reconcilePendingOpen(pending, options = {}) {
546
+ const currentBinding = this.#store.get(pending.sessionKey);
547
+ if (currentBinding?.externalRef === pending.externalRef) {
548
+ this.#finalizePendingBinding(pending.sessionKey, currentBinding);
549
+ return;
550
+ }
551
+ const cfg = this.#currentConfig();
552
+ const account = listClickClackAccountIds(cfg).map((accountId) => resolveClickClackAccount({
553
+ cfg,
554
+ accountId
555
+ })).find((candidate) => candidate.configured && normalizedServerBaseUrl(candidate) === pending.serverBaseUrl && discussionCredentialFingerprint(candidate.token) === pending.credentialFingerprint);
556
+ if (!account) return;
557
+ const client = this.#clientFactory(account);
558
+ const entry = this.#runtime.agent.session.getSessionEntry({
559
+ sessionKey: pending.sessionKey,
560
+ readConsistency: "latest"
561
+ });
562
+ const activeAccounts = discussionAccounts(cfg);
563
+ const retryAccount = activeAccounts.length === 1 ? activeAccounts[0] : void 0;
564
+ if (options.allowRetry !== false && entry?.sessionId === pending.sessionId && retryAccount && normalizedServerBaseUrl(retryAccount) === pending.serverBaseUrl && discussionCredentialFingerprint(retryAccount.token) === pending.credentialFingerprint) {
565
+ 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) {
566
+ await this.open(pending.sessionKey);
567
+ return;
568
+ }
569
+ }
570
+ const channels = await client.channels(pending.workspaceId);
571
+ assertManagedChannelListContract(channels);
572
+ const channel = channels.find((candidate) => candidate.external_managed === true && candidate.external_ref === pending.externalRef);
573
+ if (channel) {
574
+ markClickClackDiscussionChannelIdentityRevoked({
575
+ runtime: this.#runtime,
576
+ accountId: pending.accountId,
577
+ serverBaseUrl: pending.serverBaseUrl,
578
+ channelId: channel.id
579
+ });
580
+ assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
581
+ }
582
+ clearDiscussionBindingGeneration({
583
+ runtime: this.#runtime,
584
+ sessionKey: pending.sessionKey,
585
+ expectedGeneration: pending.generation
586
+ });
587
+ }
588
+ async #releaseStaleBinding(sessionKey, binding) {
589
+ clearDiscussionBindingGeneration({
590
+ runtime: this.#runtime,
591
+ sessionKey
592
+ });
593
+ const boundAccount = resolveClickClackAccount({
594
+ cfg: this.#currentConfig(),
595
+ accountId: binding.accountId
596
+ });
597
+ if (!boundAccount.configured || binding.serverBaseUrl !== normalizedServerBaseUrl(boundAccount) || !binding.credentialFingerprint || binding.credentialFingerprint !== discussionCredentialFingerprint(boundAccount.token)) {
598
+ this.#revokeAndDeleteBinding(sessionKey, binding);
599
+ return;
600
+ }
601
+ assertChannelPatch(await this.#clientFactory(boundAccount).updateChannel(binding.channelId, { archived: true }), { archived: true });
602
+ this.#revokeAndDeleteBinding(sessionKey, binding);
603
+ }
604
+ async #archiveAndDeleteBinding(sessionKey, binding, account) {
605
+ clearDiscussionBindingGeneration({
606
+ runtime: this.#runtime,
607
+ sessionKey
608
+ });
609
+ assertChannelPatch(await this.#clientFactory(account).updateChannel(binding.channelId, { archived: true }), { archived: true });
610
+ this.#revokeAndDeleteBinding(sessionKey, binding);
611
+ }
612
+ #revokeAndDeleteBinding(sessionKey, binding) {
613
+ markClickClackDiscussionChannelRevoked(this.#runtime, binding);
614
+ this.#store.delete(sessionKey);
615
+ }
616
+ #finalizePendingBinding(sessionKey, binding) {
617
+ const pending = listPendingDiscussionOpens(this.#runtime).find((candidate) => candidate.sessionKey === sessionKey && candidate.externalRef === binding.externalRef);
618
+ if (pending) {
619
+ clearClickClackDiscussionChannelRevoked({
620
+ runtime: this.#runtime,
621
+ serverBaseUrl: binding.serverBaseUrl,
622
+ channelId: binding.channelId
623
+ });
624
+ clearDiscussionBindingGeneration({
625
+ runtime: this.#runtime,
626
+ sessionKey,
627
+ expectedGeneration: pending.generation
628
+ });
629
+ }
630
+ }
631
+ async #resolveBindingForUse(binding) {
632
+ const resolved = resolveDiscussionBindingAccount(this.#currentConfig(), binding);
633
+ if (resolved.state !== "active") return resolved;
634
+ return (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)?.id === binding.workspaceId ? resolved : { state: "retargeted" };
635
+ }
636
+ #currentConfig() {
637
+ return this.#runtime.config.current();
638
+ }
639
+ #ensureTimer() {
640
+ if (!this.#timersEnabled || this.#timer || this.#store.entries().length === 0 && listPendingDiscussionOpens(this.#runtime).length === 0) return;
641
+ this.#timer = setInterval(() => {
642
+ this.reconcileAll().catch((error) => {
643
+ this.#logger().warn(`discussion reconcile pass failed: ${String(error)}`);
644
+ }).finally(() => {
645
+ if (this.#store.entries().length === 0 && listPendingDiscussionOpens(this.#runtime).length === 0 && this.#timer) {
646
+ clearInterval(this.#timer);
647
+ this.#timer = void 0;
648
+ }
649
+ });
650
+ }, RECONCILE_INTERVAL_MS);
651
+ this.#timer.unref?.();
652
+ }
653
+ #logger() {
654
+ return this.#runtime.logging.getChildLogger({
655
+ plugin: "clickclack",
656
+ feature: "discussions"
657
+ });
658
+ }
659
+ async #withSessionLock(sessionKey, run) {
660
+ const current = (this.#sessionLocks.get(sessionKey) ?? Promise.resolve()).catch(() => void 0).then(run);
661
+ this.#sessionLocks.set(sessionKey, current);
662
+ try {
663
+ return await current;
664
+ } finally {
665
+ if (this.#sessionLocks.get(sessionKey) === current) this.#sessionLocks.delete(sessionKey);
666
+ }
667
+ }
668
+ async #withChannelMutationLock(run) {
669
+ const current = this.#channelMutationLock.catch(() => void 0).then(run);
670
+ this.#channelMutationLock = current;
671
+ return await current;
672
+ }
673
+ };
674
+ //#endregion
675
+ //#region extensions/clickclack/src/discussions/tool-policy.ts
676
+ const TARGETED_SESSION_TOOLS = /* @__PURE__ */ new Set([
677
+ "sessions_history",
678
+ "sessions_send",
679
+ "session_status"
680
+ ]);
681
+ function blockedResult() {
682
+ return {
683
+ block: true,
684
+ blockReason: `ClickClack discussion sessions may use ${[...TARGETED_SESSION_TOOLS].join(", ")} only with their attached main session.`
685
+ };
686
+ }
687
+ function isClickClackDiscussionSessionTarget(params) {
688
+ const matched = getClickClackDiscussionBindingStore(params.runtime).getByDiscussionSession(params.requesterSessionKey);
689
+ if (matched && matched.sessionKey === params.targetSessionKey && !isClickClackDiscussionChannelRevoked({
690
+ runtime: params.runtime,
691
+ serverBaseUrl: matched.binding.serverBaseUrl,
692
+ channelId: matched.binding.channelId
693
+ }) && !matched.binding.archived && bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding) && resolveDiscussionBindingAccount(params.runtime.config.current(), matched.binding).state === "active") return matched;
694
+ }
695
+ /** Restricts a discussion side session's session tools to its attached main session. */
696
+ function enforceClickClackDiscussionToolTarget(params) {
697
+ const callerSessionKey = params.context.sessionKey;
698
+ if (!callerSessionKey) return;
699
+ const { toolName } = params.event;
700
+ if (toolName !== "session_status" && !toolName.startsWith("sessions_")) return;
701
+ const matched = getClickClackDiscussionBindingStore(params.runtime).getByDiscussionSession(callerSessionKey);
702
+ if (!matched) return isDiscussionSessionKey(callerSessionKey) ? blockedResult() : void 0;
703
+ const targetsMain = Boolean(isClickClackDiscussionSessionTarget({
704
+ runtime: params.runtime,
705
+ requesterSessionKey: callerSessionKey,
706
+ targetSessionKey: matched.sessionKey
707
+ })) && TARGETED_SESSION_TOOLS.has(toolName) && params.event.params.sessionKey === matched.sessionKey;
708
+ const usesAlternateSendTarget = toolName === "sessions_send" && (params.event.params.label !== void 0 || params.event.params.agentId !== void 0);
709
+ const mutatesStatus = toolName === "session_status" && params.event.params.model !== void 0;
710
+ const selectsHistoryIncarnation = toolName === "sessions_history" && params.event.params.sessionId !== void 0;
711
+ if (targetsMain && !usesAlternateSendTarget && !mutatesStatus && !selectsHistoryIncarnation) return;
712
+ return blockedResult();
713
+ }
714
+ //#endregion
715
+ //#region extensions/clickclack/src/discussions/tool.ts
716
+ const DEFAULT_MESSAGE_LIMIT = 30;
717
+ const MAX_MESSAGE_LIMIT = 200;
718
+ function createClickClackDiscussionTool(params) {
719
+ return {
720
+ name: "discussion",
721
+ label: "Discussion",
722
+ description: "Read the latest messages from the ClickClack discussion bound to this session.",
723
+ parameters: {
724
+ type: "object",
725
+ properties: { limit: {
726
+ type: "integer",
727
+ minimum: 1,
728
+ maximum: MAX_MESSAGE_LIMIT,
729
+ description: `Maximum messages to return (default ${DEFAULT_MESSAGE_LIMIT}).`
730
+ } },
731
+ additionalProperties: false
732
+ },
733
+ async execute(_toolCallId, input) {
734
+ if (!params.sessionKey) return textResult("No discussion is bound to this session.", { bound: false });
735
+ const requested = typeof input === "object" && input !== null && "limit" in input ? Number(input.limit) : DEFAULT_MESSAGE_LIMIT;
736
+ const limit = Number.isInteger(requested) ? Math.max(1, Math.min(MAX_MESSAGE_LIMIT, requested)) : DEFAULT_MESSAGE_LIMIT;
737
+ const result = await params.service.readLatestMessages(params.sessionKey, limit);
738
+ return textResult(result.text, {
739
+ bound: Boolean(result.binding),
740
+ limit,
741
+ ...result.binding ? { channelId: result.binding.channelId } : {}
742
+ });
743
+ }
744
+ };
745
+ }
746
+ //#endregion
747
+ //#region extensions/clickclack/src/discussions/register.ts
748
+ function registerClickClackDiscussions(api) {
749
+ if (api.registrationMode === "tool-discovery") {
750
+ api.registerTool(() => null, { name: "discussion" });
751
+ return;
752
+ }
753
+ const service = new ClickClackDiscussionService(api.runtime);
754
+ api.registerTool((context) => createClickClackDiscussionTool({
755
+ service,
756
+ sessionKey: context.sessionKey
757
+ }));
758
+ api.on("before_tool_call", (event, context) => enforceClickClackDiscussionToolTarget({
759
+ runtime: api.runtime,
760
+ event,
761
+ context
762
+ }));
763
+ const unregisterSessionAccess = createSessionVisibilityChecker.registerScopedAccessProvider(({ requesterSessionKey, targetSessionKey }) => {
764
+ const target = isClickClackDiscussionSessionTarget({
765
+ runtime: api.runtime,
766
+ requesterSessionKey,
767
+ targetSessionKey
768
+ });
769
+ return target ? { expectedSessionId: target.binding.sessionId } : void 0;
770
+ });
771
+ registerSessionDiscussionProvider(service.provider);
772
+ api.lifecycle.registerRuntimeLifecycle({
773
+ id: "clickclack-discussions",
774
+ description: "Stops the lifecycle reconciler for managed ClickClack discussions.",
775
+ cleanup: () => {
776
+ unregisterSessionAccess();
777
+ service.cleanup();
778
+ }
779
+ });
780
+ }
781
+ //#endregion
782
+ export { registerClickClackDiscussions as t };