@openclaw/clickclack 2026.7.2-beta.7 → 2026.8.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,17 +1,23 @@
1
- import { _ as clickClackMeta, b as listEnabledClickClackAccounts, c as resolveChannelId, d as createClickClackClient, g as clickClackConfigAdapter, h as CLICKCLACK_CHANNEL_ID, l as resolveWorkspaceId, m as clickClackConfigSchema, n as clickClackSetupAdapter, p as normalizeClickClackCorrelationId, r as clickClackSetupContract, t as clickClackSetupWizard, v as DEFAULT_ACCOUNT_ID, x as resolveClickClackAccount } from "./setup-surface-DAy8vDJK.js";
1
+ import { _ as DEFAULT_ACCOUNT_ID, b as resolveClickClackAccount, c as resolveWorkspaceId, f as normalizeClickClackCorrelationId, g as clickClackMeta, h as clickClackConfigAdapter, l as ClickClackHttpError, m as CLICKCLACK_CHANNEL_ID, n as clickClackSetupContract, p as clickClackConfigSchema, s as resolveChannelId, t as clickClackSetupWizard, u as createClickClackClient, y as listEnabledClickClackAccounts } from "./setup-surface-DjT-gmMe.js";
2
2
  import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "./secret-contract-BKkzzjBA.js";
3
+ import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime";
4
+ import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
5
+ import { readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
3
6
  import { buildChannelOutboundSessionRoute, buildThreadAwareOutboundSessionRoute, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
4
7
  import { buildChannelProgressDraftLine, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
5
8
  import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
6
9
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
10
+ import { channelReadyPatch, channelStoppedPatch } from "openclaw/plugin-sdk/gateway-runtime";
7
11
  import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
12
+ import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
8
13
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
9
14
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
10
15
  import { createHash, randomUUID } from "node:crypto";
11
16
  import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-mention-gating";
12
17
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
13
18
  import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/native-command-registry";
14
- import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
19
+ import { buildChannelInboundEventContext, createChannelInboundEnvelopeBuilder, recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
20
+ import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
15
21
  import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
16
22
  import { FormatCapabilityProfile, renderMarkdownWithMarkers, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
17
23
  //#region extensions/clickclack/src/discussions/binding-generation.ts
@@ -33,9 +39,20 @@ function getGenerationStore(runtime) {
33
39
  function reserveDiscussionBindingGeneration(params) {
34
40
  const store = getGenerationStore(params.runtime);
35
41
  const existing = store.lookup(params.sessionKey);
36
- if (existing?.destinationIdentity === params.destinationIdentity) return existing.generation;
42
+ const existingAccountId = existing?.accountId ?? existing?.pending?.accountId;
43
+ const existingCredentialFingerprint = existing?.credentialFingerprint ?? existing?.pending?.credentialFingerprint;
44
+ if (existing?.destinationIdentity === params.destinationIdentity && existingAccountId === params.accountId && existingCredentialFingerprint === params.credentialFingerprint) {
45
+ if (!existing.accountId || !existing.credentialFingerprint) store.register(params.sessionKey, {
46
+ ...existing,
47
+ accountId: params.accountId,
48
+ credentialFingerprint: params.credentialFingerprint
49
+ });
50
+ return existing.generation;
51
+ }
37
52
  const generation = (params.createGeneration ?? randomUUID)();
38
53
  store.register(params.sessionKey, {
54
+ accountId: params.accountId,
55
+ credentialFingerprint: params.credentialFingerprint,
39
56
  destinationIdentity: params.destinationIdentity,
40
57
  generation
41
58
  });
@@ -54,6 +71,7 @@ function recordPendingDiscussionOpen(params) {
54
71
  const store = getGenerationStore(params.runtime);
55
72
  const existing = store.lookup(params.sessionKey);
56
73
  if (!existing || existing.generation !== params.generation) throw new Error("ClickClack discussion generation changed before channel creation");
74
+ if (existing.accountId !== params.pending.accountId || existing.credentialFingerprint !== params.pending.credentialFingerprint) throw new Error("ClickClack discussion ownership changed before channel creation");
57
75
  store.register(params.sessionKey, {
58
76
  ...existing,
59
77
  pending: params.pending
@@ -66,6 +84,18 @@ function listPendingDiscussionOpens(runtime) {
66
84
  ...entry.value.pending
67
85
  }] : []);
68
86
  }
87
+ /** Stops destination-wide quarantine after the exact remote channel is known. */
88
+ function clearPendingDiscussionOpen(params) {
89
+ const store = getGenerationStore(params.runtime);
90
+ const existing = store.lookup(params.sessionKey);
91
+ if (!existing || existing.generation !== params.expectedGeneration || !existing.pending) return;
92
+ store.register(params.sessionKey, {
93
+ accountId: existing.accountId ?? existing.pending.accountId,
94
+ credentialFingerprint: existing.credentialFingerprint ?? existing.pending.credentialFingerprint,
95
+ destinationIdentity: existing.destinationIdentity,
96
+ generation: existing.generation
97
+ });
98
+ }
69
99
  function hasPendingDiscussionOpenForDestination(params) {
70
100
  const serverBaseUrl = params.serverBaseUrl.replace(/\/+$/u, "");
71
101
  return listPendingDiscussionOpens(params.runtime).some((pending) => pending.serverBaseUrl === serverBaseUrl && pending.workspaceId === params.workspaceId);
@@ -104,10 +134,9 @@ function discussionSessionPeerId(identity) {
104
134
  function isDiscussionSessionKey(sessionKey) {
105
135
  return sessionKey.includes(":clickclack:") && /:channel:disc-[0-9a-f]{32}$/u.test(sessionKey);
106
136
  }
107
- function discussionExternalRef(installationId, mainSessionKey, sessionId, destinationIdentity, bindingGeneration) {
137
+ function discussionExternalRef(installationId, mainSessionKey, destinationIdentity, bindingGeneration) {
108
138
  return `openclaw:${installationId}:${shortSessionHash([
109
139
  mainSessionKey,
110
- sessionId,
111
140
  destinationIdentity,
112
141
  bindingGeneration
113
142
  ].join("\0"))}`;
@@ -128,22 +157,41 @@ function discussionSessionKey(params) {
128
157
  }
129
158
  //#endregion
130
159
  //#region extensions/clickclack/src/discussions/binding-store.ts
131
- function bindingMatchesSessionIncarnation(runtime, sessionKey, binding) {
160
+ function bindingMatchesActiveSessionIncarnation(runtime, sessionKey, binding) {
132
161
  const entry = runtime.agent.session.getSessionEntry({
133
162
  sessionKey,
134
163
  readConsistency: "latest"
135
164
  });
136
- return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId);
165
+ return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId && entry.archivedAt === void 0);
137
166
  }
138
- function bindingMatchesActiveSessionIncarnation(runtime, sessionKey, binding) {
139
- const entry = runtime.agent.session.getSessionEntry({
140
- sessionKey,
167
+ /**
168
+ * Refresh the replaceable session attachment without changing the durable room identity.
169
+ * The store registers persisted state before reindexing, so a failed write leaves the
170
+ * previous attachment authoritative in both persistence and memory.
171
+ */
172
+ function attachBindingToCurrentActiveSession(params) {
173
+ const entry = params.runtime.agent.session.getSessionEntry({
174
+ sessionKey: params.sessionKey,
141
175
  readConsistency: "latest"
142
176
  });
143
- return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId && entry.archivedAt === void 0);
177
+ if (!entry?.sessionId || entry.archivedAt !== void 0) return;
178
+ if (entry.sessionId === params.binding.sessionId) {
179
+ if (params.binding.detachedAt === void 0) return params.binding;
180
+ const { detachedAt: _detachedAt, ...attached } = params.binding;
181
+ params.store.set(params.sessionKey, attached);
182
+ return attached;
183
+ }
184
+ const { detachedAt: _detachedAt, ...retained } = params.binding;
185
+ const attached = {
186
+ ...retained,
187
+ sessionId: entry.sessionId
188
+ };
189
+ params.store.set(params.sessionKey, attached);
190
+ return attached;
144
191
  }
145
192
  const DISCUSSION_BINDINGS_NAMESPACE = "discussion-bindings";
146
193
  const MAX_DISCUSSION_BINDINGS = 1e4;
194
+ const MAX_RETAINED_DETACHED_DISCUSSION_BINDINGS = 1e3;
147
195
  const storesByRuntime$1 = /* @__PURE__ */ new WeakMap();
148
196
  function channelKey(serverBaseUrl, channelId) {
149
197
  return `${serverBaseUrl.replace(/\/+$/u, "")}\0${channelId}`;
@@ -153,6 +201,7 @@ var ClickClackDiscussionBindingStore = class {
153
201
  #store;
154
202
  #sessionByChannel = /* @__PURE__ */ new Map();
155
203
  #mainByDiscussionSession = /* @__PURE__ */ new Map();
204
+ #detachedAtBySession = /* @__PURE__ */ new Map();
156
205
  #runtime;
157
206
  constructor(runtime) {
158
207
  this.#runtime = runtime;
@@ -210,6 +259,27 @@ var ClickClackDiscussionBindingStore = class {
210
259
  binding: entry.value
211
260
  }));
212
261
  }
262
+ detachedCount() {
263
+ return this.#detachedAtBySession.size;
264
+ }
265
+ oldestDetached() {
266
+ let oldestSessionKey;
267
+ let oldestDetachedAt = Number.POSITIVE_INFINITY;
268
+ for (const [sessionKey, detachedAt] of this.#detachedAtBySession) if (detachedAt < oldestDetachedAt || detachedAt === oldestDetachedAt && (oldestSessionKey === void 0 || sessionKey < oldestSessionKey)) {
269
+ oldestSessionKey = sessionKey;
270
+ oldestDetachedAt = detachedAt;
271
+ }
272
+ if (!oldestSessionKey) return;
273
+ const binding = this.get(oldestSessionKey);
274
+ if (!binding || binding.detachedAt === void 0) {
275
+ this.#detachedAtBySession.delete(oldestSessionKey);
276
+ return this.oldestDetached();
277
+ }
278
+ return {
279
+ sessionKey: oldestSessionKey,
280
+ binding
281
+ };
282
+ }
213
283
  #index(sessionKey, binding) {
214
284
  this.#sessionByChannel.set(channelKey(binding.serverBaseUrl, binding.channelId), sessionKey);
215
285
  const sideSessionKey = discussionSessionKey({
@@ -223,6 +293,7 @@ var ClickClackDiscussionBindingStore = class {
223
293
  externalRef: binding.externalRef
224
294
  });
225
295
  if (sideSessionKey) this.#mainByDiscussionSession.set(sideSessionKey, sessionKey);
296
+ if (binding.detachedAt !== void 0) this.#detachedAtBySession.set(sessionKey, binding.detachedAt);
226
297
  }
227
298
  #unindex(sessionKey, binding) {
228
299
  this.#sessionByChannel.delete(channelKey(binding.serverBaseUrl, binding.channelId));
@@ -237,6 +308,7 @@ var ClickClackDiscussionBindingStore = class {
237
308
  externalRef: binding.externalRef
238
309
  });
239
310
  if (sideSessionKey) this.#mainByDiscussionSession.delete(sideSessionKey);
311
+ this.#detachedAtBySession.delete(sessionKey);
240
312
  }
241
313
  };
242
314
  function getClickClackDiscussionBindingStore(runtime) {
@@ -327,28 +399,43 @@ function isClickClackDiscussionChannelRevoked(params) {
327
399
  //#region extensions/clickclack/src/discussions/routing.ts
328
400
  function resolveClickClackDiscussionRoute(params) {
329
401
  if (isClickClackDiscussionChannelRevoked(params)) return { state: "revoked" };
330
- const matched = getClickClackDiscussionBindingStore(params.runtime).getByChannel(params.serverBaseUrl, params.channelId);
402
+ const store = getClickClackDiscussionBindingStore(params.runtime);
403
+ const matched = store.getByChannel(params.serverBaseUrl, params.channelId);
331
404
  if (!matched) return { state: hasPendingDiscussionOpenForDestination(params) ? "revoked" : "unbound" };
332
405
  if (matched.binding.accountId !== params.accountId) return { state: "revoked" };
333
406
  if (matched.binding.serverBaseUrl !== params.serverBaseUrl.replace(/\/+$/u, "")) return { state: "revoked" };
334
- if (matched.binding.archived) return { state: "revoked" };
335
407
  if (resolveDiscussionBindingAccount(params.config, matched.binding).state !== "active") return { state: "revoked" };
336
- if (!bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding)) return { state: "revoked" };
408
+ let binding;
409
+ try {
410
+ binding = attachBindingToCurrentActiveSession({
411
+ runtime: params.runtime,
412
+ store,
413
+ sessionKey: matched.sessionKey,
414
+ binding: matched.binding
415
+ });
416
+ } catch (error) {
417
+ params.runtime.logging.getChildLogger({
418
+ plugin: "clickclack",
419
+ feature: "discussions"
420
+ }).warn(`discussion attachment refresh failed for channel ${params.channelId}: ${String(error)}`);
421
+ return { state: "revoked" };
422
+ }
423
+ if (!binding) return { state: "revoked" };
337
424
  const sessionKey = discussionSessionKey({
338
425
  runtime: params.runtime,
339
- agentId: matched.binding.agentId,
426
+ agentId: binding.agentId,
340
427
  mainSessionKey: matched.sessionKey,
341
- sessionId: matched.binding.sessionId,
428
+ sessionId: binding.sessionId,
342
429
  accountId: params.accountId,
343
- serverBaseUrl: matched.binding.serverBaseUrl,
344
- channelId: matched.binding.channelId,
345
- externalRef: matched.binding.externalRef
430
+ serverBaseUrl: binding.serverBaseUrl,
431
+ channelId: binding.channelId,
432
+ externalRef: binding.externalRef
346
433
  });
347
434
  if (!sessionKey) return { state: "revoked" };
348
435
  return {
349
436
  state: "active",
350
437
  route: {
351
- agentId: matched.binding.agentId,
438
+ agentId: binding.agentId,
352
439
  sessionKey,
353
440
  systemPrompt: [
354
441
  "You are the side agent for a ClickClack discussion attached to an OpenClaw session.",
@@ -363,6 +450,21 @@ function resolveClickClackDiscussionRoute(params) {
363
450
  //#endregion
364
451
  //#region extensions/clickclack/src/group-policy.ts
365
452
  /**
453
+ * Resolves bot-authored message policy using the same exact, wildcard, and
454
+ * account-level precedence as mention gating.
455
+ */
456
+ function resolveClickClackBotPolicy(params) {
457
+ const { account, channelId } = params;
458
+ const channelKey = channelId?.trim();
459
+ const groups = channelKey ? account.groups : void 0;
460
+ const wildcard = groups?.["*"];
461
+ const exact = channelKey ? groups?.[channelKey] : void 0;
462
+ return {
463
+ allowBots: exact?.allowBots ?? wildcard?.allowBots ?? account.allowBots ?? false,
464
+ botLoopProtection: mergePairLoopGuardConfig(account.botLoopProtection, wildcard?.botLoopProtection, exact?.botLoopProtection)
465
+ };
466
+ }
467
+ /**
366
468
  * Resolves the effective group policy for a ClickClack channel.
367
469
  *
368
470
  * Lookup order:
@@ -377,9 +479,10 @@ function resolveClickClackGroupPolicy(params) {
377
479
  requireMention: account.requireMention === true,
378
480
  mentionPatterns: account.mentionPatterns ?? []
379
481
  };
380
- const wildcard = account.groups?.["*"];
381
482
  const channelKey = channelId?.trim();
382
- const exact = channelKey ? account.groups?.[channelKey] : void 0;
483
+ const groups = channelKey ? account.groups : void 0;
484
+ const wildcard = groups?.["*"];
485
+ const exact = channelKey ? groups?.[channelKey] : void 0;
383
486
  return {
384
487
  requireMention: exact?.requireMention ?? wildcard?.requireMention ?? accountPolicy.requireMention,
385
488
  mentionPatterns: exact?.mentionPatterns ?? wildcard?.mentionPatterns ?? accountPolicy.mentionPatterns
@@ -531,6 +634,10 @@ const clickClackIngressIdentity = {
531
634
  isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
532
635
  entryIdPrefix: "clickclack-user"
533
636
  };
637
+ function resolveClickClackBotLoopConversationId(params) {
638
+ if (params.message.parent_message_id && params.message.thread_root_id) return params.message.thread_root_id;
639
+ return params.isDirect ? params.message.direct_conversation_id ?? params.message.author_id : params.message.channel_id ?? params.message.thread_root_id ?? params.message.author_id;
640
+ }
534
641
  function resolveAccountAgentRoute(params) {
535
642
  const runtime = getClickClackRuntime();
536
643
  const peer = {
@@ -633,6 +740,41 @@ async function resolveClickClackInboundAccess(params) {
633
740
  agentId: preparedRoute.route.agentId,
634
741
  channelId: params.message.channel_id
635
742
  });
743
+ if (params.message.kind !== void 0 && params.message.kind !== "message") return {
744
+ shouldDispatch: false,
745
+ commandAuthorized: false,
746
+ requireMention: effectiveGroupPolicy.requireMention,
747
+ mentionFacts,
748
+ preparedRoute
749
+ };
750
+ const effectiveBotPolicy = resolveClickClackBotPolicy({
751
+ account: params.account,
752
+ channelId: params.message.channel_id
753
+ });
754
+ const isBotAuthor = params.message.author?.kind === "bot";
755
+ const ingressAllowFrom = isBotAuthor ? params.account.allowFrom.filter((entry) => normalizeClickClackUserId(entry) !== "*") : params.account.allowFrom;
756
+ if (!(!isBotAuthor || effectiveBotPolicy.allowBots === true || effectiveBotPolicy.allowBots === "mentions" && (preparedRoute.isDirect || mentionFacts.wasMentioned))) return {
757
+ shouldDispatch: false,
758
+ commandAuthorized: false,
759
+ requireMention: effectiveGroupPolicy.requireMention,
760
+ mentionFacts,
761
+ preparedRoute
762
+ };
763
+ const botLoopNowMs = parseDateStringTimestampMs(params.message.created_at);
764
+ const botLoopProtection = isBotAuthor && params.message.author_id !== params.account.botUserId && params.account.botUserId ? {
765
+ scopeId: params.account.workspace,
766
+ conversationId: resolveClickClackBotLoopConversationId({
767
+ message: params.message,
768
+ isDirect: preparedRoute.isDirect
769
+ }),
770
+ senderId: params.message.author_id,
771
+ receiverId: params.account.botUserId,
772
+ eventId: params.message.id,
773
+ ...botLoopNowMs !== void 0 ? { nowMs: botLoopNowMs } : {},
774
+ config: effectiveBotPolicy.botLoopProtection,
775
+ defaultsConfig: cfg.channels?.defaults?.botLoopProtection,
776
+ defaultEnabled: true
777
+ } : void 0;
636
778
  const allowTextCommands = params.account.replyMode === "agent" && runtime.channel.commands.shouldHandleTextCommands({
637
779
  cfg,
638
780
  surface: CHANNEL_ID$2,
@@ -648,7 +790,13 @@ async function resolveClickClackInboundAccess(params) {
648
790
  kind: preparedRoute.isDirect ? "direct" : "group",
649
791
  id: preparedRoute.isDirect ? params.message.direct_conversation_id ?? params.message.author_id : params.message.channel_id ?? params.message.thread_root_id
650
792
  },
651
- allowFrom: params.account.allowFrom,
793
+ contextBinding: {
794
+ agentId: preparedRoute.route.agentId,
795
+ sessionKey: preparedRoute.route.sessionKey,
796
+ messageId: params.message.id,
797
+ inboundEventKind: "user_request"
798
+ },
799
+ allowFrom: ingressAllowFrom,
652
800
  dmPolicy: "allowlist",
653
801
  groupPolicy: "allowlist",
654
802
  mentionFacts,
@@ -666,7 +814,9 @@ async function resolveClickClackInboundAccess(params) {
666
814
  commandAuthorized: resolved.commandAccess.requested ? resolved.commandAccess.authorized : resolved.senderAccess.allowed,
667
815
  requireMention: effectiveGroupPolicy.requireMention,
668
816
  mentionFacts,
669
- preparedRoute
817
+ botLoopProtection,
818
+ preparedRoute,
819
+ channelIngress: resolved
670
820
  };
671
821
  }
672
822
  //#endregion
@@ -795,7 +945,8 @@ function activityBody(payload) {
795
945
  status: payload.status,
796
946
  summary: payload.summary,
797
947
  progressText: payload.progressText,
798
- meta: payload.meta
948
+ meta: payload.meta,
949
+ commandBearing: payload.commandBearing
799
950
  })?.text?.trim();
800
951
  if (line) return line;
801
952
  const head = payload.name?.trim() || payload.title?.trim();
@@ -908,10 +1059,11 @@ function createClickClackActivityPublisher(params) {
908
1059
  const kind = normalizedItemKind(payload);
909
1060
  if (STREAMING_COMMENTARY_ITEM_KINDS.has(kind)) {
910
1061
  handleCommentary(payload);
911
- return;
1062
+ return false;
912
1063
  }
913
- if (SKIPPED_ITEM_KINDS.has(kind)) return;
1064
+ if (SKIPPED_ITEM_KINDS.has(kind)) return false;
914
1065
  handleDiscreteItem(payload);
1066
+ return false;
915
1067
  },
916
1068
  setProvenance: (next) => {
917
1069
  provenance = next;
@@ -1080,9 +1232,9 @@ async function sendClickClackMedia(params) {
1080
1232
  mediaLocalRoots: params.mediaLocalRoots,
1081
1233
  mediaReadFile: params.mediaReadFile
1082
1234
  });
1083
- const filename = media.fileName?.trim() || "attachment";
1084
- mediaFilename = filename;
1085
1235
  const contentType = media.contentType?.trim() || "application/octet-stream";
1236
+ const filename = media.fileName?.trim() || `attachment${extensionForMime(contentType) ?? ""}`;
1237
+ mediaFilename = filename;
1086
1238
  await dispatch();
1087
1239
  upload = await client.createUpload({
1088
1240
  workspaceId,
@@ -1215,6 +1367,219 @@ async function reconcileClickClackUnknownSend(ctx) {
1215
1367
  };
1216
1368
  }
1217
1369
  //#endregion
1370
+ //#region extensions/clickclack/src/progress.ts
1371
+ /**
1372
+ * Publishes ClickClack's native ephemeral agent.progress signal for one
1373
+ * OpenClaw turn. ClickClack renders this as its compact "Agent is
1374
+ * responding" status and the detailed progress lines above the composer.
1375
+ */
1376
+ function normalizedKind(payload) {
1377
+ const kind = payload.kind?.trim().toLowerCase();
1378
+ if (!kind || kind === "preamble" || kind === "analysis" || kind === "thinking" || kind === "reasoning" || kind === "missing") return "commentary";
1379
+ return kind;
1380
+ }
1381
+ function progressText(payload) {
1382
+ const line = buildChannelProgressDraftLine({
1383
+ event: "item",
1384
+ itemId: payload.itemId,
1385
+ toolCallId: payload.toolCallId,
1386
+ itemKind: payload.kind,
1387
+ title: payload.title,
1388
+ name: payload.name,
1389
+ phase: payload.phase,
1390
+ status: payload.status,
1391
+ summary: payload.summary,
1392
+ progressText: payload.progressText,
1393
+ meta: payload.meta,
1394
+ commandBearing: payload.commandBearing
1395
+ })?.text?.trim();
1396
+ if (line) return line;
1397
+ return payload.progressText?.trim() || payload.summary?.trim() || payload.title?.trim() || payload.name?.trim() || payload.meta?.trim() || payload.status?.trim() || "Working";
1398
+ }
1399
+ function isFinal(payload) {
1400
+ const phase = payload.phase?.trim().toLowerCase();
1401
+ const status = payload.status?.trim().toLowerCase();
1402
+ return phase === "end" || status === "completed" || status === "failed" || status === "blocked";
1403
+ }
1404
+ function createLineIdResolver() {
1405
+ const lineIdsByIdentity = /* @__PURE__ */ new Map();
1406
+ const anonymousLinesByKind = /* @__PURE__ */ new Map();
1407
+ let anonymousSequence = 0;
1408
+ return (payload) => {
1409
+ const identities = [payload.itemId?.replace(/^(tool|command):/, ""), payload.toolCallId].map((value) => value?.trim()).filter((value) => Boolean(value));
1410
+ const existingId = identities.map((identity) => lineIdsByIdentity.get(identity)).find(Boolean);
1411
+ if (existingId) {
1412
+ for (const identity of identities) lineIdsByIdentity.set(identity, existingId);
1413
+ return existingId;
1414
+ }
1415
+ if (identities.length > 0) {
1416
+ const id = `item:${identities[0]}`;
1417
+ for (const identity of identities) lineIdsByIdentity.set(identity, id);
1418
+ return id;
1419
+ }
1420
+ const kind = normalizedKind(payload);
1421
+ const anonymousLines = anonymousLinesByKind.get(kind) ?? [];
1422
+ const line = (payload.phase?.trim().toLowerCase() === "start" ? void 0 : anonymousLines.toReversed().find((line) => line.active)) ?? (() => {
1423
+ const created = {
1424
+ id: `item:${kind}:${++anonymousSequence}`,
1425
+ active: true
1426
+ };
1427
+ anonymousLines.push(created);
1428
+ anonymousLinesByKind.set(kind, anonymousLines);
1429
+ return created;
1430
+ })();
1431
+ if (isFinal(payload)) line.active = false;
1432
+ return line.id;
1433
+ };
1434
+ }
1435
+ const CLICKCLACK_PROGRESS_UPDATE_INTERVAL_MS = 100;
1436
+ const CLICKCLACK_PROGRESS_FINALIZE_GRACE_MS = 1e3;
1437
+ function createClickClackAgentProgressPublisher(params) {
1438
+ let sequence = 0;
1439
+ const queue = [];
1440
+ const queuedLines = /* @__PURE__ */ new Map();
1441
+ let drainPromise;
1442
+ let lineDrainTimer;
1443
+ let started = false;
1444
+ let cleared = false;
1445
+ const seenLines = /* @__PURE__ */ new Set();
1446
+ const resolveLineId = createLineIdResolver();
1447
+ const drain = () => {
1448
+ if (drainPromise) return drainPromise;
1449
+ drainPromise = (async () => {
1450
+ while (queue.length > 0) {
1451
+ const frame = queue.shift();
1452
+ if (!frame) continue;
1453
+ if (frame.lineId) queuedLines.delete(frame.lineId);
1454
+ try {
1455
+ await params.client.publishEphemeral({
1456
+ ...params.target,
1457
+ type: "agent.progress",
1458
+ payload: {
1459
+ turn_id: params.turnId,
1460
+ seq: ++sequence,
1461
+ ...frame.payload
1462
+ }
1463
+ });
1464
+ } catch (error) {
1465
+ try {
1466
+ params.onError?.(error);
1467
+ } catch {}
1468
+ }
1469
+ }
1470
+ })().finally(() => {
1471
+ drainPromise = void 0;
1472
+ if (queue.length > 0) drain();
1473
+ });
1474
+ return drainPromise;
1475
+ };
1476
+ const enqueue = (payload) => {
1477
+ queue.push({ payload });
1478
+ drain();
1479
+ };
1480
+ const flushQueuedLines = () => {
1481
+ for (const [lineId, frame] of queuedLines) {
1482
+ queuedLines.delete(lineId);
1483
+ queue.push(frame);
1484
+ }
1485
+ drain();
1486
+ };
1487
+ const discardQueuedLines = () => {
1488
+ queuedLines.clear();
1489
+ const controlFrames = queue.filter((frame) => !frame.lineId);
1490
+ queue.splice(0, queue.length, ...controlFrames);
1491
+ };
1492
+ const waitForDrainWithinFinalizeGrace = async (pending) => {
1493
+ let timeout;
1494
+ let drained = false;
1495
+ try {
1496
+ await Promise.race([pending.then(() => {
1497
+ drained = true;
1498
+ }), new Promise((resolve) => {
1499
+ timeout = setTimeout(resolve, CLICKCLACK_PROGRESS_FINALIZE_GRACE_MS);
1500
+ })]);
1501
+ return drained;
1502
+ } finally {
1503
+ if (timeout) clearTimeout(timeout);
1504
+ }
1505
+ };
1506
+ const scheduleLineDrain = () => {
1507
+ if (lineDrainTimer) return;
1508
+ lineDrainTimer = setTimeout(() => {
1509
+ lineDrainTimer = void 0;
1510
+ flushQueuedLines();
1511
+ }, CLICKCLACK_PROGRESS_UPDATE_INTERVAL_MS);
1512
+ };
1513
+ const enqueueLine = (lineId, payload) => {
1514
+ const queued = queuedLines.get(lineId);
1515
+ if (queued) {
1516
+ const op = payload.op === "finalize" ? "finalize" : queued.payload.op === "append" ? "append" : payload.op;
1517
+ queued.payload = {
1518
+ ...payload,
1519
+ ...op ? { op } : {}
1520
+ };
1521
+ return;
1522
+ }
1523
+ const frame = {
1524
+ lineId,
1525
+ payload
1526
+ };
1527
+ queuedLines.set(lineId, frame);
1528
+ scheduleLineDrain();
1529
+ };
1530
+ return {
1531
+ start() {
1532
+ if (started) return;
1533
+ started = true;
1534
+ enqueue({
1535
+ op: "append",
1536
+ line: {
1537
+ id: "turn",
1538
+ kind: "commentary",
1539
+ text: params.agentLabel ? `${params.agentLabel} is responding` : "Agent is responding",
1540
+ status: "running"
1541
+ }
1542
+ });
1543
+ },
1544
+ onItemEvent(payload) {
1545
+ if (!started || cleared) return false;
1546
+ const id = resolveLineId(payload);
1547
+ const final = isFinal(payload);
1548
+ const kind = normalizedKind(payload);
1549
+ const retractsExistingCommentary = kind === "commentary" && seenLines.has(id) && payload.progressText !== void 0 && payload.progressText.trim() === "";
1550
+ if (retractsExistingCommentary && queuedLines.get(id)?.payload.op === "append") {
1551
+ queuedLines.delete(id);
1552
+ seenLines.delete(id);
1553
+ return false;
1554
+ }
1555
+ const line = {
1556
+ id,
1557
+ kind,
1558
+ text: retractsExistingCommentary ? "" : progressText(payload),
1559
+ status: payload.status?.trim() || (final ? "completed" : "running")
1560
+ };
1561
+ if (payload.name?.trim()) line.tool_name = payload.name.trim();
1562
+ enqueueLine(id, {
1563
+ op: final ? "finalize" : seenLines.has(id) ? "update" : "append",
1564
+ line
1565
+ });
1566
+ seenLines.add(id);
1567
+ return false;
1568
+ },
1569
+ async finalize() {
1570
+ if (!started || cleared) return;
1571
+ cleared = true;
1572
+ if (lineDrainTimer) {
1573
+ clearTimeout(lineDrainTimer);
1574
+ lineDrainTimer = void 0;
1575
+ }
1576
+ flushQueuedLines();
1577
+ enqueue({ op: "clear" });
1578
+ if (!await waitForDrainWithinFinalizeGrace(drain())) discardQueuedLines();
1579
+ }
1580
+ };
1581
+ }
1582
+ //#endregion
1218
1583
  //#region extensions/clickclack/src/inbound.ts
1219
1584
  const CHANNEL_ID$1 = "clickclack";
1220
1585
  const CLICKCLACK_MESSAGE_ID_PATTERN = /^msg_[0-9a-hjkmnp-tv-z]{26}$/u;
@@ -1265,19 +1630,56 @@ async function handleClickClackInbound(params) {
1265
1630
  config: params.config,
1266
1631
  message
1267
1632
  });
1268
- if (!access.shouldDispatch) return;
1633
+ if (!access.shouldDispatch || !access.channelIngress) return;
1269
1634
  const conversationId = message.channel_id || message.direct_conversation_id;
1270
1635
  if (!conversationId) return;
1271
1636
  const { discussionRoute, isDirect, route, target } = access.preparedRoute;
1272
- if (params.account.replyMode === "model" && !discussionRoute) {
1273
- await dispatchModelReply({
1274
- account: params.account,
1275
- cfg: params.config,
1276
- message,
1277
- route,
1278
- target,
1637
+ const progress = params.account.nativeProgress ? createClickClackAgentProgressPublisher({
1638
+ client: createClickClackClient({
1639
+ baseUrl: params.account.apiEndpoint,
1640
+ token: params.account.token,
1279
1641
  correlationId: params.correlationId
1280
- });
1642
+ }),
1643
+ target: message.channel_id ? {
1644
+ workspaceId: message.workspace_id,
1645
+ channelId: message.channel_id
1646
+ } : {
1647
+ workspaceId: message.workspace_id,
1648
+ conversationId
1649
+ },
1650
+ turnId: message.id,
1651
+ agentLabel: params.account.name?.trim() || params.account.botHandle?.trim() || params.account.agentId?.trim() || params.account.accountId,
1652
+ onError: (error) => {
1653
+ runtime.logging.getChildLogger({
1654
+ plugin: "clickclack",
1655
+ feature: "agent-progress"
1656
+ }).warn(`clickclack progress publish failed: ${String(error)}`);
1657
+ }
1658
+ }) : void 0;
1659
+ if (params.account.replyMode === "model" && !discussionRoute) {
1660
+ if (access.botLoopProtection) {
1661
+ const loopResult = recordChannelBotPairLoopAndCheckSuppression(access.botLoopProtection);
1662
+ if (loopResult.suppressed) {
1663
+ runtime.logging.getChildLogger({
1664
+ plugin: "clickclack",
1665
+ feature: "bot-loop-protection"
1666
+ }).warn(`[${params.account.accountId}] ClickClack bot-pair loop suppressed for ${Math.max(0, Math.ceil((loopResult.cooldownUntilMs - Date.now()) / 1e3))}s`);
1667
+ return;
1668
+ }
1669
+ }
1670
+ progress?.start();
1671
+ try {
1672
+ await dispatchModelReply({
1673
+ account: params.account,
1674
+ cfg: params.config,
1675
+ message,
1676
+ route,
1677
+ target,
1678
+ correlationId: params.correlationId
1679
+ });
1680
+ } finally {
1681
+ await progress?.finalize();
1682
+ }
1281
1683
  return;
1282
1684
  }
1283
1685
  let turnProvenance;
@@ -1307,7 +1709,8 @@ async function handleClickClackInbound(params) {
1307
1709
  timestamp: new Date(message.created_at),
1308
1710
  body: message.body
1309
1711
  });
1310
- const ctxPayload = runtime.channel.inbound.buildContext({
1712
+ const ctxPayload = (params.buildContext ?? buildChannelInboundEventContext)({
1713
+ channelIngress: access.channelIngress,
1311
1714
  channel: CHANNEL_ID$1,
1312
1715
  accountId: route.accountId ?? params.account.accountId,
1313
1716
  messageId: message.id,
@@ -1354,20 +1757,27 @@ async function handleClickClackInbound(params) {
1354
1757
  }
1355
1758
  });
1356
1759
  const runId = resolveClickClackAgentRunId(message.id);
1357
- const activityReplyOptions = activity ? {
1358
- onModelSelected: (ctx) => {
1760
+ const activityReplyOptions = {
1761
+ ...activity ? { onModelSelected: (ctx) => {
1359
1762
  turnProvenance = {
1360
1763
  model: ctx.provider && ctx.model ? `${ctx.provider}/${ctx.model}` : ctx.model,
1361
1764
  thinking: ctx.thinkLevel
1362
1765
  };
1363
- activity?.setProvenance(turnProvenance);
1364
- },
1365
- onItemEvent: activity.onItemEvent,
1366
- commentaryProgressEnabled: true,
1367
- suppressDefaultToolProgressMessages: true,
1368
- allowProgressCallbacksWhenSourceDeliverySuppressed: true
1369
- } : void 0;
1370
- const dispatchPromise = runtime.channel.inbound.dispatch({
1766
+ activity.setProvenance(turnProvenance);
1767
+ } } : {},
1768
+ ...progress || activity ? {
1769
+ onItemEvent: (payload) => {
1770
+ progress?.onItemEvent(payload);
1771
+ activity?.onItemEvent(payload);
1772
+ return false;
1773
+ },
1774
+ commentaryProgressEnabled: true,
1775
+ suppressDefaultToolProgressMessages: true,
1776
+ allowProgressCallbacksWhenSourceDeliverySuppressed: true
1777
+ } : {}
1778
+ };
1779
+ progress?.start();
1780
+ const dispatch = () => runtime.channel.inbound.dispatch({
1371
1781
  cfg: params.config,
1372
1782
  channel: CHANNEL_ID$1,
1373
1783
  accountId: params.account.accountId,
@@ -1377,11 +1787,12 @@ async function handleClickClackInbound(params) {
1377
1787
  sessionKey: route.sessionKey
1378
1788
  },
1379
1789
  ctxPayload,
1790
+ botLoopProtection: access.botLoopProtection,
1380
1791
  toolsAllow: params.account.toolsAllow,
1381
- replyOptions: runId || activityReplyOptions ? {
1792
+ replyOptions: {
1382
1793
  ...runId ? { runId } : {},
1383
1794
  ...activityReplyOptions
1384
- } : void 0,
1795
+ },
1385
1796
  delivery: {
1386
1797
  deliver: async (payload) => {
1387
1798
  if (hasClickClackReplyMedia(payload)) throw new Error("ClickClack media reply requires durable delivery");
@@ -1423,8 +1834,9 @@ async function handleClickClackInbound(params) {
1423
1834
  } }
1424
1835
  });
1425
1836
  try {
1426
- await dispatchPromise;
1837
+ await dispatch();
1427
1838
  } finally {
1839
+ await progress?.finalize();
1428
1840
  await activity?.finalize();
1429
1841
  }
1430
1842
  }
@@ -1432,8 +1844,7 @@ async function handleClickClackInbound(params) {
1432
1844
  //#region extensions/clickclack/src/gateway.ts
1433
1845
  const CLICKCLACK_EVENT_PAGE_LIMIT = 500;
1434
1846
  function payloadString(event, key) {
1435
- const value = event.payload?.[key];
1436
- return typeof value === "string" ? value : "";
1847
+ return readStringField(event.payload, key) ?? "";
1437
1848
  }
1438
1849
  function eventCorrelationId(event) {
1439
1850
  return normalizeClickClackCorrelationId(event.payload?.correlation_id);
@@ -1441,25 +1852,16 @@ function eventCorrelationId(event) {
1441
1852
  async function resolveEventMessage(params) {
1442
1853
  const messageId = payloadString(params.event, "message_id");
1443
1854
  if (!messageId) return null;
1444
- const directConversationId = payloadString(params.event, "direct_conversation_id");
1445
- if (directConversationId && typeof params.event.seq === "number") return (await params.client.directMessages(directConversationId, params.event.seq - 1, 10)).find((message) => message.id === messageId) ?? null;
1446
- if (params.event.type === "thread.reply_created") {
1447
- const rootId = payloadString(params.event, "root_message_id");
1448
- if (!rootId) return null;
1449
- return (await params.client.thread(rootId)).replies.find((message) => message.id === messageId) ?? null;
1855
+ try {
1856
+ return await params.client.message(messageId);
1857
+ } catch (error) {
1858
+ if (error instanceof ClickClackHttpError && error.status === 404) return null;
1859
+ throw error;
1450
1860
  }
1451
- if (params.event.channel_id && typeof params.event.seq === "number") return (await params.client.channelMessages(params.event.channel_id, params.event.seq - 1, 10)).find((message) => message.id === messageId) ?? null;
1452
- return null;
1453
- }
1454
- function decodeSocketMessage(data) {
1455
- if (typeof data === "string") return data;
1456
- if (Buffer.isBuffer(data)) return data.toString("utf8");
1457
- if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
1458
- return Buffer.concat(data).toString("utf8");
1459
1861
  }
1460
1862
  function parseSocketEvent(data) {
1461
1863
  try {
1462
- return JSON.parse(decodeSocketMessage(data));
1864
+ return JSON.parse(rawDataToString(data));
1463
1865
  } catch {
1464
1866
  return null;
1465
1867
  }
@@ -1476,8 +1878,11 @@ async function processEvent(params) {
1476
1878
  }) : params.client,
1477
1879
  event: params.event
1478
1880
  });
1479
- if (!message || message.author_id === params.botUserId) return;
1480
- if (message.author?.kind === "bot") return;
1881
+ if (!message) {
1882
+ params.log?.warn?.(`[${params.account.accountId}] skipped unreadable ClickClack message before agent dispatch: type=${params.event.type} messageId=${payloadString(params.event, "message_id") || "unknown"}`);
1883
+ return;
1884
+ }
1885
+ if (message.author_id === params.botUserId) return;
1481
1886
  const access = await resolveClickClackInboundAccess({
1482
1887
  account: params.account,
1483
1888
  config: params.config,
@@ -1492,6 +1897,7 @@ async function processEvent(params) {
1492
1897
  config: params.config,
1493
1898
  message,
1494
1899
  access,
1900
+ buildContext: params.buildContext,
1495
1901
  ...correlationId ? { correlationId } : {}
1496
1902
  });
1497
1903
  }
@@ -1536,6 +1942,7 @@ async function startClickClackGatewayAccount(ctx) {
1536
1942
  client,
1537
1943
  event,
1538
1944
  botUserId: account.botUserId,
1945
+ buildContext: ctx.channelRuntime?.inbound.buildContext,
1539
1946
  log: ctx.log
1540
1947
  });
1541
1948
  if (account.commandMenu) await syncClickClackCommandMenu({
@@ -1546,6 +1953,7 @@ async function startClickClackGatewayAccount(ctx) {
1546
1953
  ctx.setStatus({
1547
1954
  accountId: account.accountId,
1548
1955
  running: true,
1956
+ lifecycle: "starting",
1549
1957
  configured: true,
1550
1958
  enabled: account.enabled,
1551
1959
  baseUrl: account.baseUrl
@@ -1601,6 +2009,9 @@ async function startClickClackGatewayAccount(ctx) {
1601
2009
  };
1602
2010
  ctx.abortSignal.addEventListener("abort", abort, { once: true });
1603
2011
  removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
2012
+ socket.on("open", () => {
2013
+ ctx.setStatus(channelReadyPatch({ accountId: account.accountId }));
2014
+ });
1604
2015
  socket.on("message", (data) => {
1605
2016
  if (closing || settled) return;
1606
2017
  messageQueue = messageQueue.then(async () => {
@@ -1616,6 +2027,11 @@ async function startClickClackGatewayAccount(ctx) {
1616
2027
  });
1617
2028
  socket.on("close", () => {
1618
2029
  closing = true;
2030
+ if (!ctx.abortSignal.aborted) ctx.setStatus({
2031
+ accountId: account.accountId,
2032
+ connected: false,
2033
+ lifecycle: "recovering"
2034
+ });
1619
2035
  finishAfterQueuedMessages();
1620
2036
  });
1621
2037
  socket.on("error", (error) => {
@@ -1625,6 +2041,12 @@ async function startClickClackGatewayAccount(ctx) {
1625
2041
  }
1626
2042
  if (closing) return;
1627
2043
  ctx.log?.warn?.(`[${account.accountId}] ClickClack websocket error; reconnecting: ${error instanceof Error ? error.message : String(error)}`);
2044
+ ctx.setStatus({
2045
+ accountId: account.accountId,
2046
+ connected: false,
2047
+ lifecycle: "recovering",
2048
+ lastError: error instanceof Error ? error.message : String(error)
2049
+ });
1628
2050
  closing = true;
1629
2051
  socket.close();
1630
2052
  });
@@ -1636,10 +2058,7 @@ async function startClickClackGatewayAccount(ctx) {
1636
2058
  }
1637
2059
  }
1638
2060
  } finally {
1639
- ctx.setStatus({
1640
- accountId: account.accountId,
1641
- running: false
1642
- });
2061
+ ctx.setStatus(channelStoppedPatch({ accountId: account.accountId }));
1643
2062
  }
1644
2063
  }
1645
2064
  //#endregion
@@ -1742,7 +2161,6 @@ const clickClackPlugin = createChatChannelPlugin({
1742
2161
  reload: { configPrefixes: ["channels.clickclack"] },
1743
2162
  configSchema: clickClackConfigSchema,
1744
2163
  config: clickClackConfigAdapter,
1745
- setup: clickClackSetupAdapter,
1746
2164
  setupContract: clickClackSetupContract,
1747
2165
  setupWizard: clickClackSetupWizard,
1748
2166
  secrets: {
@@ -1849,4 +2267,4 @@ const clickClackPlugin = createChatChannelPlugin({
1849
2267
  }
1850
2268
  });
1851
2269
  //#endregion
1852
- export { truncateDiscussionDisplayTitle as C, reserveDiscussionBindingGeneration as D, recordPendingDiscussionOpen as E, slugifyDiscussionLabel as S, listPendingDiscussionOpens as T, discussionCredentialFingerprint as _, setClickClackRuntime as a, isDiscussionSessionKey as b, markClickClackDiscussionChannelIdentityRevoked as c, discussionInfoForBinding as d, normalizedServerBaseUrl as f, getClickClackDiscussionBindingStore as g, bindingMatchesSessionIncarnation as h, getClickClackRuntime as i, markClickClackDiscussionChannelRevoked as l, bindingMatchesActiveSessionIncarnation as m, buildClickClackTarget as n, clearClickClackDiscussionChannelRevoked as o, resolveDiscussionBindingAccount as p, parseClickClackTarget as r, isClickClackDiscussionChannelRevoked as s, clickClackPlugin as t, discussionAccounts as u, discussionExternalRef as v, clearDiscussionBindingGeneration as w, resolveDiscussionLabel as x, fallbackDiscussionLabel as y };
2270
+ export { slugifyDiscussionLabel as C, listPendingDiscussionOpens as D, clearPendingDiscussionOpen as E, recordPendingDiscussionOpen as O, resolveDiscussionLabel as S, clearDiscussionBindingGeneration as T, getClickClackDiscussionBindingStore as _, setClickClackRuntime as a, fallbackDiscussionLabel as b, markClickClackDiscussionChannelIdentityRevoked as c, discussionInfoForBinding as d, normalizedServerBaseUrl as f, bindingMatchesActiveSessionIncarnation as g, attachBindingToCurrentActiveSession as h, getClickClackRuntime as i, reserveDiscussionBindingGeneration as k, markClickClackDiscussionChannelRevoked as l, MAX_RETAINED_DETACHED_DISCUSSION_BINDINGS as m, buildClickClackTarget as n, clearClickClackDiscussionChannelRevoked as o, resolveDiscussionBindingAccount as p, parseClickClackTarget as r, isClickClackDiscussionChannelRevoked as s, clickClackPlugin as t, discussionAccounts as u, discussionCredentialFingerprint as v, truncateDiscussionDisplayTitle as w, isDiscussionSessionKey as x, discussionExternalRef as y };