@openclaw/clickclack 2026.7.2-beta.5 → 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,722 +1,1078 @@
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-CD6MUC1g.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
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
10
- import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/native-command-registry";
11
- import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
12
14
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
13
15
  import { createHash, randomUUID } from "node:crypto";
16
+ import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-mention-gating";
17
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
18
+ import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/native-command-registry";
19
+ import { buildChannelInboundEventContext, createChannelInboundEnvelopeBuilder, recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
20
+ import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
14
21
  import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
15
22
  import { FormatCapabilityProfile, renderMarkdownWithMarkers, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
16
- //#region extensions/clickclack/src/runtime.ts
17
- /**
18
- * Runtime store for host-provided OpenClaw services used by the ClickClack
19
- * bundled plugin.
20
- */
21
- const { setRuntime: setClickClackRuntime, getRuntime: getClickClackRuntime } = createPluginRuntimeStore({
22
- pluginId: "clickclack",
23
- errorMessage: "ClickClack runtime not initialized"
24
- });
25
- //#endregion
26
- //#region extensions/clickclack/src/access.ts
27
- /**
28
- * Maps ClickClack senders and conversations onto the shared channel ingress
29
- * allowlist/command authorization contract.
30
- */
31
- const CHANNEL_ID$2 = "clickclack";
32
- function normalizeClickClackUserId(value) {
33
- const trimmed = value.trim();
34
- if (!trimmed) return null;
35
- const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
36
- return withoutProvider.match(/^dm:(.+)$/i)?.[1]?.trim() || withoutProvider || null;
23
+ //#region extensions/clickclack/src/discussions/binding-generation.ts
24
+ const DISCUSSION_GENERATIONS_NAMESPACE = "discussion-binding-generations";
25
+ const MAX_PENDING_DISCUSSION_GENERATIONS = 1e4;
26
+ const storesByRuntime$2 = /* @__PURE__ */ new WeakMap();
27
+ function getGenerationStore(runtime) {
28
+ const existing = storesByRuntime$2.get(runtime);
29
+ if (existing) return existing;
30
+ const created = runtime.state.openSyncKeyedStore({
31
+ namespace: DISCUSSION_GENERATIONS_NAMESPACE,
32
+ maxEntries: MAX_PENDING_DISCUSSION_GENERATIONS,
33
+ overflowPolicy: "reject-new"
34
+ });
35
+ storesByRuntime$2.set(runtime, created);
36
+ return created;
37
37
  }
38
- const clickClackIngressIdentity = {
39
- key: "user-id",
40
- normalizeEntry: normalizeClickClackUserId,
41
- normalizeSubject: normalizeClickClackUserId,
42
- isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
43
- entryIdPrefix: "clickclack-user"
44
- };
45
- /**
46
- * Resolves whether a ClickClack message should enter the agent pipeline and
47
- * whether its command-style body may run tools.
48
- */
49
- async function resolveClickClackInboundAccess(params) {
50
- const runtime = getClickClackRuntime();
51
- const isDirect = Boolean(params.message.direct_conversation_id);
52
- const cfg = params.config;
53
- const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(params.message.body, cfg);
54
- const resolved = await resolveStableChannelMessageIngress({
55
- channelId: CHANNEL_ID$2,
56
- accountId: params.account.accountId,
57
- identity: clickClackIngressIdentity,
58
- cfg,
59
- subject: { stableId: params.message.author_id },
60
- conversation: {
61
- kind: isDirect ? "direct" : "group",
62
- id: isDirect ? params.message.direct_conversation_id ?? params.message.author_id : params.message.channel_id ?? params.message.thread_root_id
63
- },
64
- allowFrom: params.account.allowFrom,
65
- dmPolicy: "allowlist",
66
- groupPolicy: "allowlist",
67
- command: shouldCheckCommand ? {
68
- cfg,
69
- modeWhenAccessGroupsOff: "configured"
70
- } : false
38
+ /** Reserves a generation so an interrupted channel create can be adopted on retry. */
39
+ function reserveDiscussionBindingGeneration(params) {
40
+ const store = getGenerationStore(params.runtime);
41
+ const existing = store.lookup(params.sessionKey);
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
+ }
52
+ const generation = (params.createGeneration ?? randomUUID)();
53
+ store.register(params.sessionKey, {
54
+ accountId: params.accountId,
55
+ credentialFingerprint: params.credentialFingerprint,
56
+ destinationIdentity: params.destinationIdentity,
57
+ generation
71
58
  });
72
- return {
73
- shouldDispatch: resolved.ingress.admission === "dispatch",
74
- commandAuthorized: resolved.commandAccess.requested ? resolved.commandAccess.authorized : resolved.senderAccess.allowed
75
- };
59
+ return generation;
60
+ }
61
+ /** Clears only the completed reservation; future opens must mint a new ownership ref. */
62
+ function clearDiscussionBindingGeneration(params) {
63
+ const store = getGenerationStore(params.runtime);
64
+ const existing = store.lookup(params.sessionKey);
65
+ if (!existing) return;
66
+ if (params.expectedGeneration && existing.generation !== params.expectedGeneration) return;
67
+ store.delete(params.sessionKey);
68
+ }
69
+ /** Quarantines a destination before the first fallible channel create. */
70
+ function recordPendingDiscussionOpen(params) {
71
+ const store = getGenerationStore(params.runtime);
72
+ const existing = store.lookup(params.sessionKey);
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");
75
+ store.register(params.sessionKey, {
76
+ ...existing,
77
+ pending: params.pending
78
+ });
79
+ }
80
+ function listPendingDiscussionOpens(runtime) {
81
+ return getGenerationStore(runtime).entries().flatMap((entry) => entry.value.pending ? [{
82
+ sessionKey: entry.key,
83
+ generation: entry.value.generation,
84
+ ...entry.value.pending
85
+ }] : []);
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
+ }
99
+ function hasPendingDiscussionOpenForDestination(params) {
100
+ const serverBaseUrl = params.serverBaseUrl.replace(/\/+$/u, "");
101
+ return listPendingDiscussionOpens(params.runtime).some((pending) => pending.serverBaseUrl === serverBaseUrl && pending.workspaceId === params.workspaceId);
76
102
  }
77
103
  //#endregion
78
- //#region extensions/clickclack/src/command-menu.ts
79
- const CLICKCLACK_COMMAND_PATTERN = /^[a-z0-9_-]{1,32}$/u;
80
- const CLICKCLACK_MAX_COMMANDS = 100;
81
- const CLICKCLACK_MAX_DESCRIPTION_LENGTH = 100;
82
- const CLICKCLACK_MAX_ARGS_HINT_LENGTH = 100;
83
- function truncateCodePoints(value, maxLength) {
84
- return Array.from(value).slice(0, maxLength).join("");
104
+ //#region extensions/clickclack/src/discussions/naming.ts
105
+ function shortSessionHash(sessionKey) {
106
+ return createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
85
107
  }
86
- function commandArgsHint(spec) {
87
- if (spec.args?.length) return truncateCodePoints(spec.args.map((arg) => arg.required ? `<${arg.name}>` : `[${arg.name}]`).join(" "), CLICKCLACK_MAX_ARGS_HINT_LENGTH);
88
- return spec.acceptsArgs ? "[args]" : "";
108
+ function slugifyLabel(label) {
109
+ return label.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80).replace(/-+$/gu, "");
89
110
  }
90
- function errorStatus(error) {
91
- if (!error || typeof error !== "object" || !("status" in error)) return;
92
- const status = error.status;
93
- return typeof status === "number" ? status : void 0;
111
+ function fallbackDiscussionLabel(sessionKey, agentId) {
112
+ const agentSegment = agentId?.trim() ? slugifyLabel(agentId) : "";
113
+ const hash = shortSessionHash(sessionKey).slice(0, 8);
114
+ return `s-${agentSegment ? `${agentSegment}-` : ""}${hash}`;
94
115
  }
95
- function mapNativeCommandSpecsToClickClackMenu(specs, log) {
96
- const commands = [];
97
- const seen = /* @__PURE__ */ new Set();
98
- const invalidNames = /* @__PURE__ */ new Set();
99
- for (const spec of specs) {
100
- if (spec.isAlias) continue;
101
- const command = spec.name.trim().toLowerCase();
102
- if (!CLICKCLACK_COMMAND_PATTERN.test(command)) {
103
- invalidNames.add(spec.name.trim() || "<empty>");
104
- continue;
105
- }
106
- if (seen.has(command)) continue;
107
- seen.add(command);
108
- const description = spec.description.trim() || command;
109
- commands.push({
110
- command,
111
- description: truncateCodePoints(description, CLICKCLACK_MAX_DESCRIPTION_LENGTH),
112
- args_hint: commandArgsHint(spec)
113
- });
114
- }
115
- if (invalidNames.size > 0) log?.warn?.(`ClickClack command menu skipped invalid native command names: ${[...invalidNames].map((name) => JSON.stringify(name)).join(", ")}`);
116
- return commands.slice(0, CLICKCLACK_MAX_COMMANDS);
116
+ function resolveDiscussionLabel(entry, sessionKey, agentId) {
117
+ return entry.label?.trim() || entry.displayName?.trim() || entry.subject?.trim() || fallbackDiscussionLabel(sessionKey, agentId);
117
118
  }
118
- async function syncClickClackCommandMenu(params) {
119
- try {
120
- const commands = mapNativeCommandSpecsToClickClackMenu(listNativeCommandSpecsForConfig(params.cfg, { provider: "clickclack" }), params.log);
121
- await params.client.setBotCommands(commands);
122
- } catch (error) {
123
- const status = errorStatus(error);
124
- if (status === 403) {
125
- params.log?.warn?.("ClickClack command menu sync skipped: bot token lacks commands:write");
126
- return;
127
- }
128
- if (status === 404) {
129
- params.log?.debug?.("ClickClack command menu sync skipped: server does not support /api/bots/self/commands");
130
- return;
119
+ function truncateDiscussionDisplayTitle(label) {
120
+ return Array.from(label).slice(0, 200).join("").trim();
121
+ }
122
+ function slugifyDiscussionLabel(label, sessionKey) {
123
+ return slugifyLabel(label) || fallbackDiscussionLabel(sessionKey);
124
+ }
125
+ function discussionSessionPeerId(identity) {
126
+ return `disc-${createHash("sha256").update([
127
+ identity.mainSessionKey,
128
+ identity.sessionId,
129
+ identity.serverBaseUrl,
130
+ identity.channelId,
131
+ identity.externalRef
132
+ ].join("\0")).digest("hex").slice(0, 32)}`;
133
+ }
134
+ function isDiscussionSessionKey(sessionKey) {
135
+ return sessionKey.includes(":clickclack:") && /:channel:disc-[0-9a-f]{32}$/u.test(sessionKey);
136
+ }
137
+ function discussionExternalRef(installationId, mainSessionKey, destinationIdentity, bindingGeneration) {
138
+ return `openclaw:${installationId}:${shortSessionHash([
139
+ mainSessionKey,
140
+ destinationIdentity,
141
+ bindingGeneration
142
+ ].join("\0"))}`;
143
+ }
144
+ function discussionCredentialFingerprint(token) {
145
+ return createHash("sha256").update(token).digest("hex");
146
+ }
147
+ function discussionSessionKey(params) {
148
+ return params.runtime.channel.routing.buildAgentSessionKey({
149
+ agentId: params.agentId,
150
+ channel: "clickclack",
151
+ accountId: params.accountId,
152
+ peer: {
153
+ kind: "channel",
154
+ id: discussionSessionPeerId(params)
131
155
  }
132
- params.log?.warn?.(`ClickClack command menu sync failed: ${formatErrorMessage(error)}`);
133
- }
156
+ });
134
157
  }
135
158
  //#endregion
136
- //#region extensions/clickclack/src/activity.ts
159
+ //#region extensions/clickclack/src/discussions/binding-store.ts
160
+ function bindingMatchesActiveSessionIncarnation(runtime, sessionKey, binding) {
161
+ const entry = runtime.agent.session.getSessionEntry({
162
+ sessionKey,
163
+ readConsistency: "latest"
164
+ });
165
+ return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId && entry.archivedAt === void 0);
166
+ }
137
167
  /**
138
- * Publishes agent activity (streamed commentary + tool progress) into
139
- * ClickClack as durable `agent_commentary` / `agent_tool` message rows,
140
- * coalesced so one logical step becomes one row instead of a row per frame.
141
- *
142
- * Ported from the clickglass agent-bridge sidecar, adapted from gateway
143
- * websocket frames to the in-process `replyOptions.onItemEvent` seam:
144
- *
145
- * - Commentary/reasoning arrives as cumulative text snapshots per item id.
146
- * Each segment becomes one durable row: POSTed when the segment starts
147
- * streaming and PATCHed (debounced) as the snapshot grows, so prose
148
- * interleaves chronologically with tool rows.
149
- * - Tool/step items can emit several frames (start/update/complete) for the
150
- * same call, sometimes with lane-prefixed ids (`tool:X`, `command:X`).
151
- * Normalize the prefix away to one key per call, POST one row on the first
152
- * frame, and PATCH it when a later frame carries a strictly longer body.
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.
153
171
  */
154
- /** Debounce window for PATCHing streaming commentary snapshots. */
155
- const CLICKCLACK_COMMENTARY_FLUSH_MS = 700;
156
- /** Item kinds rendered as agent_tool rows; everything else is commentary. */
157
- const TOOL_ITEM_KINDS = /* @__PURE__ */ new Set([
158
- "tool",
159
- "command",
160
- "command_output",
161
- "patch",
162
- "search",
163
- "api"
164
- ]);
165
- /** Item kinds that never become durable rows (ephemeral or plumbing lanes). */
166
- const SKIPPED_ITEM_KINDS = /* @__PURE__ */ new Set(["lifecycle"]);
167
- /** Item kinds emitted as cumulative commentary snapshots. */
168
- const STREAMING_COMMENTARY_ITEM_KINDS = /* @__PURE__ */ new Set([
169
- "preamble",
170
- "commentary",
171
- "analysis",
172
- "thinking",
173
- "reasoning"
174
- ]);
175
- /** Provider-specific reasoning lanes normalized into one ClickClack label. */
176
- const THINKING_ITEM_KINDS = /* @__PURE__ */ new Set([
177
- "analysis",
178
- "thinking",
179
- "reasoning"
180
- ]);
181
- function normalizedItemKind(payload) {
182
- return payload.kind?.trim().toLowerCase() ?? "";
183
- }
184
- function commentaryBody(payload) {
185
- const text = payload.progressText?.trim() || payload.summary?.trim() || payload.meta?.trim() || "";
186
- if (!text) return "";
187
- const kind = normalizedItemKind(payload);
188
- if (THINKING_ITEM_KINDS.has(kind)) return `**Thinking**\n\n${text}`;
189
- return text;
172
+ function attachBindingToCurrentActiveSession(params) {
173
+ const entry = params.runtime.agent.session.getSessionEntry({
174
+ sessionKey: params.sessionKey,
175
+ readConsistency: "latest"
176
+ });
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;
190
191
  }
191
- function activityBody(payload) {
192
- const line = buildChannelProgressDraftLine({
193
- event: "item",
194
- itemId: payload.itemId,
195
- toolCallId: payload.toolCallId,
196
- itemKind: payload.kind,
197
- title: payload.title,
198
- name: payload.name,
199
- phase: payload.phase,
200
- status: payload.status,
201
- summary: payload.summary,
202
- progressText: payload.progressText,
203
- meta: payload.meta
204
- })?.text?.trim();
205
- if (line) return line;
206
- const head = payload.name?.trim() || payload.title?.trim();
207
- const text = payload.progressText?.trim() || payload.summary?.trim();
208
- if (head && text) return `**${head}**\n\n${text}`;
209
- if (text) return text;
210
- if (head) return head;
211
- return payload.status?.trim() || payload.kind?.trim() || "";
192
+ const DISCUSSION_BINDINGS_NAMESPACE = "discussion-bindings";
193
+ const MAX_DISCUSSION_BINDINGS = 1e4;
194
+ const MAX_RETAINED_DETACHED_DISCUSSION_BINDINGS = 1e3;
195
+ const storesByRuntime$1 = /* @__PURE__ */ new WeakMap();
196
+ function channelKey(serverBaseUrl, channelId) {
197
+ return `${serverBaseUrl.replace(/\/+$/u, "")}\0${channelId}`;
212
198
  }
213
- /**
214
- * Creates a per-turn activity publisher. Publishing is best-effort: transport
215
- * failures are reported through `onError` and never interrupt the reply turn.
216
- */
217
- function createClickClackActivityPublisher(params) {
218
- const flushMs = params.flushMs ?? CLICKCLACK_COMMENTARY_FLUSH_MS;
219
- const commentaryByItem = /* @__PURE__ */ new Map();
220
- const toolRows = /* @__PURE__ */ new Map();
221
- let provenance;
222
- let chain = Promise.resolve();
223
- const enqueue = (work) => {
224
- chain = chain.then(work).catch((error) => {
225
- params.onError?.(error);
199
+ /** SQLite-backed session/channel bindings with a process-local inbound lookup index. */
200
+ var ClickClackDiscussionBindingStore = class {
201
+ #store;
202
+ #sessionByChannel = /* @__PURE__ */ new Map();
203
+ #mainByDiscussionSession = /* @__PURE__ */ new Map();
204
+ #detachedAtBySession = /* @__PURE__ */ new Map();
205
+ #runtime;
206
+ constructor(runtime) {
207
+ this.#runtime = runtime;
208
+ this.#store = runtime.state.openSyncKeyedStore({
209
+ namespace: DISCUSSION_BINDINGS_NAMESPACE,
210
+ maxEntries: MAX_DISCUSSION_BINDINGS,
211
+ overflowPolicy: "reject-new"
226
212
  });
227
- return chain;
228
- };
229
- const postRow = (kind, body) => params.client.createActivityMessage({
230
- channelId: params.target.channelId,
231
- conversationId: params.target.conversationId,
232
- body,
233
- kind,
234
- turnId: params.turnId,
235
- provenance
236
- });
237
- const flushCommentary = (segmentKey) => {
238
- const segment = commentaryByItem.get(segmentKey);
239
- if (!segment) return Promise.resolve();
240
- if (segment.timer) {
241
- clearTimeout(segment.timer);
242
- segment.timer = void 0;
213
+ for (const entry of this.#store.entries()) this.#index(entry.key, entry.value);
214
+ }
215
+ get(sessionKey) {
216
+ return this.#store.lookup(sessionKey);
217
+ }
218
+ hasCapacity(sessionKey) {
219
+ return this.get(sessionKey) !== void 0 || this.#store.entries().length < MAX_DISCUSSION_BINDINGS;
220
+ }
221
+ getByChannel(serverBaseUrl, channelId) {
222
+ const key = channelKey(serverBaseUrl, channelId);
223
+ const sessionKey = this.#sessionByChannel.get(key);
224
+ if (!sessionKey) return;
225
+ const binding = this.get(sessionKey);
226
+ if (!binding) {
227
+ this.#sessionByChannel.delete(key);
228
+ return;
243
229
  }
244
- if (!segment.dirty || !segment.body.trim()) return Promise.resolve();
245
- segment.dirty = false;
246
- const body = segment.body;
247
- return enqueue(async () => {
248
- if (segment.messageId) {
249
- await params.client.updateMessageBody(segment.messageId, body);
250
- return;
251
- }
252
- const posted = await postRow("agent_commentary", body);
253
- segment.messageId = posted.id;
254
- });
255
- };
256
- const flushAllCommentary = () => {
257
- const flushes = [...commentaryByItem.keys()].map((key) => flushCommentary(key));
258
- return Promise.all(flushes).then(() => void 0);
259
- };
260
- const handleCommentary = (payload) => {
261
- const body = commentaryBody(payload);
262
- if (!body.trim()) return;
263
- const key = payload.itemId?.trim() || "turn";
264
- let segment = commentaryByItem.get(key);
265
- if (!segment) {
266
- segment = {
267
- body: "",
268
- dirty: false
269
- };
270
- commentaryByItem.set(key, segment);
230
+ return {
231
+ sessionKey,
232
+ binding
233
+ };
234
+ }
235
+ set(sessionKey, binding) {
236
+ const previous = this.get(sessionKey);
237
+ this.#store.register(sessionKey, binding);
238
+ if (previous) this.#unindex(sessionKey, previous);
239
+ this.#index(sessionKey, binding);
240
+ }
241
+ delete(sessionKey) {
242
+ const previous = this.get(sessionKey);
243
+ const deleted = this.#store.delete(sessionKey);
244
+ if (deleted && previous) this.#unindex(sessionKey, previous);
245
+ return deleted;
246
+ }
247
+ getByDiscussionSession(sideSessionKey) {
248
+ const sessionKey = this.#mainByDiscussionSession.get(sideSessionKey);
249
+ if (!sessionKey) return;
250
+ const binding = this.get(sessionKey);
251
+ return binding ? {
252
+ sessionKey,
253
+ binding
254
+ } : void 0;
255
+ }
256
+ entries() {
257
+ return this.#store.entries().map((entry) => ({
258
+ sessionKey: entry.key,
259
+ binding: entry.value
260
+ }));
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
271
  }
272
- if (body.length < segment.body.length || body === segment.body) return;
273
- segment.body = body;
274
- segment.dirty = true;
275
- if (!segment.timer) segment.timer = setTimeout(() => {
276
- segment.timer = void 0;
277
- flushCommentary(key);
278
- }, flushMs);
279
- };
280
- const toolRowKey = (payload) => {
281
- const toolCallId = payload.toolCallId?.trim();
282
- if (toolCallId) return toolCallId;
283
- return (payload.itemId?.trim() ?? "").replace(/^(tool|command):/, "");
284
- };
285
- const handleDiscreteItem = (payload) => {
286
- const body = activityBody(payload);
287
- if (!body) return;
288
- const kind = TOOL_ITEM_KINDS.has(payload.kind?.trim().toLowerCase() ?? "") ? "agent_tool" : "agent_commentary";
289
- flushAllCommentary();
290
- const key = `${kind}:${toolRowKey(payload)}`;
291
- const existing = toolRows.get(key);
292
- if (!existing || !toolRowKey(payload)) {
293
- const row = { body };
294
- if (toolRowKey(payload)) toolRows.set(key, row);
295
- enqueue(async () => {
296
- const posted = await postRow(kind, row.body);
297
- row.messageId = posted.id;
298
- row.sentBody = row.body;
299
- });
300
- return;
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();
301
277
  }
302
- if (body.length <= existing.body.length) return;
303
- existing.body = body;
304
- enqueue(async () => {
305
- if (existing.messageId && existing.body !== existing.sentBody) {
306
- await params.client.updateMessageBody(existing.messageId, existing.body);
307
- existing.sentBody = existing.body;
308
- }
278
+ return {
279
+ sessionKey: oldestSessionKey,
280
+ binding
281
+ };
282
+ }
283
+ #index(sessionKey, binding) {
284
+ this.#sessionByChannel.set(channelKey(binding.serverBaseUrl, binding.channelId), sessionKey);
285
+ const sideSessionKey = discussionSessionKey({
286
+ runtime: this.#runtime,
287
+ agentId: binding.agentId,
288
+ mainSessionKey: sessionKey,
289
+ sessionId: binding.sessionId,
290
+ accountId: binding.accountId,
291
+ serverBaseUrl: binding.serverBaseUrl,
292
+ channelId: binding.channelId,
293
+ externalRef: binding.externalRef
294
+ });
295
+ if (sideSessionKey) this.#mainByDiscussionSession.set(sideSessionKey, sessionKey);
296
+ if (binding.detachedAt !== void 0) this.#detachedAtBySession.set(sessionKey, binding.detachedAt);
297
+ }
298
+ #unindex(sessionKey, binding) {
299
+ this.#sessionByChannel.delete(channelKey(binding.serverBaseUrl, binding.channelId));
300
+ const sideSessionKey = discussionSessionKey({
301
+ runtime: this.#runtime,
302
+ agentId: binding.agentId,
303
+ mainSessionKey: sessionKey,
304
+ sessionId: binding.sessionId,
305
+ accountId: binding.accountId,
306
+ serverBaseUrl: binding.serverBaseUrl,
307
+ channelId: binding.channelId,
308
+ externalRef: binding.externalRef
309
309
  });
310
+ if (sideSessionKey) this.#mainByDiscussionSession.delete(sideSessionKey);
311
+ this.#detachedAtBySession.delete(sessionKey);
312
+ }
313
+ };
314
+ function getClickClackDiscussionBindingStore(runtime) {
315
+ const existing = storesByRuntime$1.get(runtime);
316
+ if (existing) return existing;
317
+ const created = new ClickClackDiscussionBindingStore(runtime);
318
+ storesByRuntime$1.set(runtime, created);
319
+ return created;
320
+ }
321
+ //#endregion
322
+ //#region extensions/clickclack/src/discussions/eligibility.ts
323
+ function discussionAccounts(cfg) {
324
+ return listEnabledClickClackAccounts(cfg).filter((account) => account.configured && account.discussions.enabled);
325
+ }
326
+ function normalizedServerBaseUrl(account) {
327
+ return account.baseUrl.replace(/\/+$/u, "");
328
+ }
329
+ /** Resolves the sole live account and rejects bindings pinned to an older destination. */
330
+ function resolveDiscussionBindingAccount(cfg, binding) {
331
+ const accounts = discussionAccounts(cfg);
332
+ if (accounts.length !== 1) return { state: "unavailable" };
333
+ const account = accounts[0];
334
+ if (!account) return { state: "unavailable" };
335
+ if (account.accountId !== binding.accountId || normalizedServerBaseUrl(account) !== binding.serverBaseUrl || account.discussions.workspace !== binding.workspaceRef || binding.credentialFingerprint !== void 0 && discussionCredentialFingerprint(account.token) !== binding.credentialFingerprint) return {
336
+ state: "stale",
337
+ account
310
338
  };
311
339
  return {
312
- onItemEvent: (payload) => {
313
- const kind = normalizedItemKind(payload);
314
- if (STREAMING_COMMENTARY_ITEM_KINDS.has(kind)) {
315
- handleCommentary(payload);
316
- return;
317
- }
318
- if (SKIPPED_ITEM_KINDS.has(kind)) return;
319
- handleDiscreteItem(payload);
320
- },
321
- setProvenance: (next) => {
322
- provenance = next;
323
- },
324
- finalize: async () => {
325
- await flushAllCommentary();
326
- await chain;
327
- }
340
+ state: "active",
341
+ account
342
+ };
343
+ }
344
+ /** Public embed/open URLs for one bound discussion channel. */
345
+ function discussionInfoForBinding(binding, account) {
346
+ const baseUrl = normalizedServerBaseUrl(account);
347
+ return {
348
+ state: "open",
349
+ embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}?openclawHostTheme=1`,
350
+ openUrl: `${baseUrl}/app/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`
328
351
  };
329
352
  }
330
353
  //#endregion
331
- //#region extensions/clickclack/src/discussions/binding-generation.ts
332
- const DISCUSSION_GENERATIONS_NAMESPACE = "discussion-binding-generations";
333
- const MAX_PENDING_DISCUSSION_GENERATIONS = 1e4;
334
- const storesByRuntime$2 = /* @__PURE__ */ new WeakMap();
335
- function getGenerationStore(runtime) {
336
- const existing = storesByRuntime$2.get(runtime);
354
+ //#region extensions/clickclack/src/discussions/revoked-channel-store.ts
355
+ const REVOKED_CHANNELS_NAMESPACE = "discussion-revoked-channels";
356
+ const MAX_REVOKED_CHANNELS = 1e5;
357
+ const storesByRuntime = /* @__PURE__ */ new WeakMap();
358
+ function revokedChannelKey(params) {
359
+ return [params.serverBaseUrl.replace(/\/+$/u, ""), params.channelId].join("\0");
360
+ }
361
+ function getStore(runtime) {
362
+ const existing = storesByRuntime.get(runtime);
337
363
  if (existing) return existing;
338
364
  const created = runtime.state.openSyncKeyedStore({
339
- namespace: DISCUSSION_GENERATIONS_NAMESPACE,
340
- maxEntries: MAX_PENDING_DISCUSSION_GENERATIONS,
365
+ namespace: REVOKED_CHANNELS_NAMESPACE,
366
+ maxEntries: MAX_REVOKED_CHANNELS,
341
367
  overflowPolicy: "reject-new"
342
368
  });
343
- storesByRuntime$2.set(runtime, created);
369
+ storesByRuntime.set(runtime, created);
344
370
  return created;
345
371
  }
346
- /** Reserves a generation so an interrupted channel create can be adopted on retry. */
347
- function reserveDiscussionBindingGeneration(params) {
348
- const store = getGenerationStore(params.runtime);
349
- const existing = store.lookup(params.sessionKey);
350
- if (existing?.destinationIdentity === params.destinationIdentity) return existing.generation;
351
- const generation = (params.createGeneration ?? randomUUID)();
352
- store.register(params.sessionKey, {
353
- destinationIdentity: params.destinationIdentity,
354
- generation
355
- });
356
- return generation;
357
- }
358
- /** Clears only the completed reservation; future opens must mint a new ownership ref. */
359
- function clearDiscussionBindingGeneration(params) {
360
- const store = getGenerationStore(params.runtime);
361
- const existing = store.lookup(params.sessionKey);
362
- if (!existing) return;
363
- if (params.expectedGeneration && existing.generation !== params.expectedGeneration) return;
364
- store.delete(params.sessionKey);
372
+ /** Records managed ownership before its live binding is released. */
373
+ function markClickClackDiscussionChannelRevoked(runtime, binding) {
374
+ const value = {
375
+ accountId: binding.accountId,
376
+ serverBaseUrl: binding.serverBaseUrl,
377
+ channelId: binding.channelId,
378
+ revokedAt: Date.now()
379
+ };
380
+ getStore(runtime).register(revokedChannelKey(value), value);
365
381
  }
366
- /** Quarantines a destination before the first fallible channel create. */
367
- function recordPendingDiscussionOpen(params) {
368
- const store = getGenerationStore(params.runtime);
369
- const existing = store.lookup(params.sessionKey);
370
- if (!existing || existing.generation !== params.generation) throw new Error("ClickClack discussion generation changed before channel creation");
371
- store.register(params.sessionKey, {
372
- ...existing,
373
- pending: params.pending
374
- });
382
+ function markClickClackDiscussionChannelIdentityRevoked(params) {
383
+ const value = {
384
+ accountId: params.accountId,
385
+ serverBaseUrl: params.serverBaseUrl.replace(/\/+$/u, ""),
386
+ channelId: params.channelId,
387
+ revokedAt: Date.now()
388
+ };
389
+ getStore(params.runtime).register(revokedChannelKey(value), value);
375
390
  }
376
- function listPendingDiscussionOpens(runtime) {
377
- return getGenerationStore(runtime).entries().flatMap((entry) => entry.value.pending ? [{
378
- sessionKey: entry.key,
379
- generation: entry.value.generation,
380
- ...entry.value.pending
381
- }] : []);
391
+ function clearClickClackDiscussionChannelRevoked(params) {
392
+ getStore(params.runtime).delete(revokedChannelKey(params));
382
393
  }
383
- function hasPendingDiscussionOpenForDestination(params) {
384
- const serverBaseUrl = params.serverBaseUrl.replace(/\/+$/u, "");
385
- return listPendingDiscussionOpens(params.runtime).some((pending) => pending.serverBaseUrl === serverBaseUrl && pending.workspaceId === params.workspaceId);
394
+ /** Distinguishes a released managed channel from a genuinely ordinary channel. */
395
+ function isClickClackDiscussionChannelRevoked(params) {
396
+ return Boolean(getStore(params.runtime).lookup(revokedChannelKey(params)));
386
397
  }
387
398
  //#endregion
388
- //#region extensions/clickclack/src/discussions/naming.ts
389
- function shortSessionHash(sessionKey) {
390
- return createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
391
- }
392
- function slugifyLabel(label) {
393
- return label.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80).replace(/-+$/gu, "");
399
+ //#region extensions/clickclack/src/discussions/routing.ts
400
+ function resolveClickClackDiscussionRoute(params) {
401
+ if (isClickClackDiscussionChannelRevoked(params)) return { state: "revoked" };
402
+ const store = getClickClackDiscussionBindingStore(params.runtime);
403
+ const matched = store.getByChannel(params.serverBaseUrl, params.channelId);
404
+ if (!matched) return { state: hasPendingDiscussionOpenForDestination(params) ? "revoked" : "unbound" };
405
+ if (matched.binding.accountId !== params.accountId) return { state: "revoked" };
406
+ if (matched.binding.serverBaseUrl !== params.serverBaseUrl.replace(/\/+$/u, "")) return { state: "revoked" };
407
+ if (resolveDiscussionBindingAccount(params.config, matched.binding).state !== "active") 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" };
424
+ const sessionKey = discussionSessionKey({
425
+ runtime: params.runtime,
426
+ agentId: binding.agentId,
427
+ mainSessionKey: matched.sessionKey,
428
+ sessionId: binding.sessionId,
429
+ accountId: params.accountId,
430
+ serverBaseUrl: binding.serverBaseUrl,
431
+ channelId: binding.channelId,
432
+ externalRef: binding.externalRef
433
+ });
434
+ if (!sessionKey) return { state: "revoked" };
435
+ return {
436
+ state: "active",
437
+ route: {
438
+ agentId: binding.agentId,
439
+ sessionKey,
440
+ systemPrompt: [
441
+ "You are the side agent for a ClickClack discussion attached to an OpenClaw session.",
442
+ `The main session key is ${matched.sessionKey}.`,
443
+ "Observe it with sessions_history and session_status (using changesSince for incremental checks).",
444
+ "Use sessions_send to relay or steer the main session only when the humans in this discussion ask you to.",
445
+ "These session tools are host-scoped to the attached main session; do not attempt session discovery or alternate targets."
446
+ ].join(" ")
447
+ }
448
+ };
394
449
  }
395
- function fallbackDiscussionLabel(sessionKey, agentId) {
396
- const agentSegment = agentId?.trim() ? slugifyLabel(agentId) : "";
397
- const hash = shortSessionHash(sessionKey).slice(0, 8);
398
- return `s-${agentSegment ? `${agentSegment}-` : ""}${hash}`;
450
+ //#endregion
451
+ //#region extensions/clickclack/src/group-policy.ts
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
+ };
399
466
  }
400
- function resolveDiscussionLabel(entry, sessionKey, agentId) {
401
- return entry.label?.trim() || entry.displayName?.trim() || entry.subject?.trim() || fallbackDiscussionLabel(sessionKey, agentId);
467
+ /**
468
+ * Resolves the effective group policy for a ClickClack channel.
469
+ *
470
+ * Lookup order:
471
+ * 1. Exact channel ID in `groups`
472
+ * 2. Wildcard `'*'` entry in `groups`
473
+ * 3. Account-level `requireMention` / `mentionPatterns`
474
+ * 4. Backward-compatible default: { requireMention: false, mentionPatterns: [] }
475
+ */
476
+ function resolveClickClackGroupPolicy(params) {
477
+ const { account, channelId } = params;
478
+ const accountPolicy = {
479
+ requireMention: account.requireMention === true,
480
+ mentionPatterns: account.mentionPatterns ?? []
481
+ };
482
+ const channelKey = channelId?.trim();
483
+ const groups = channelKey ? account.groups : void 0;
484
+ const wildcard = groups?.["*"];
485
+ const exact = channelKey ? groups?.[channelKey] : void 0;
486
+ return {
487
+ requireMention: exact?.requireMention ?? wildcard?.requireMention ?? accountPolicy.requireMention,
488
+ mentionPatterns: exact?.mentionPatterns ?? wildcard?.mentionPatterns ?? accountPolicy.mentionPatterns
489
+ };
402
490
  }
403
- function truncateDiscussionDisplayTitle(label) {
404
- return Array.from(label).slice(0, 200).join("").trim();
491
+ //#endregion
492
+ //#region extensions/clickclack/src/mention-facts.ts
493
+ /**
494
+ * Detects whether a ClickClack group message contains a direct mention of the
495
+ * current account.
496
+ *
497
+ * Pure helper – no side effects, no runtime imports.
498
+ */
499
+ const CLICKCLACK_MENTION_PATTERN = /(?:^|[^a-z0-9_@-])@([a-z0-9][a-z0-9_-]{1,31})(?![a-z0-9_-])/giu;
500
+ function buildLocalMentionRegexes(params) {
501
+ if (params.mentionPatterns.length === 0) return [];
502
+ const cfg = params.cfg;
503
+ return buildMentionRegexes({
504
+ ...cfg,
505
+ messages: {
506
+ ...cfg?.messages,
507
+ groupChat: {
508
+ ...cfg?.messages?.groupChat,
509
+ mentionPatterns: params.mentionPatterns
510
+ }
511
+ }
512
+ }, void 0, {
513
+ provider: "clickclack",
514
+ conversationId: params.channelId
515
+ });
405
516
  }
406
- function slugifyDiscussionLabel(label, sessionKey) {
407
- return slugifyLabel(label) || fallbackDiscussionLabel(sessionKey);
517
+ function resolveMentionHandles(body) {
518
+ return [...body.matchAll(CLICKCLACK_MENTION_PATTERN)].map((match) => match[1]?.toLowerCase()).filter((handle) => Boolean(handle));
408
519
  }
409
- function discussionSessionPeerId(identity) {
410
- return `disc-${createHash("sha256").update([
411
- identity.mainSessionKey,
412
- identity.sessionId,
413
- identity.serverBaseUrl,
414
- identity.channelId,
415
- identity.externalRef
416
- ].join("\0")).digest("hex").slice(0, 32)}`;
520
+ /**
521
+ * Builds mention facts for a ClickClack message.
522
+ *
523
+ * Rules:
524
+ * - DMs always have canDetectMention: false, wasMentioned: false
525
+ * (DMs bypass mention gating).
526
+ * - Group messages: canDetectMention: true when body text is available.
527
+ * - Checks the message body against shared and account-local mention patterns.
528
+ * - If botHandle is provided and the message body contains its ClickClack
529
+ * `@handle`, treat it as a mention.
530
+ * - Plain display names do not count unless explicitly configured as a pattern.
531
+ */
532
+ function resolveClickClackMentionFacts(params) {
533
+ const { isDirect, body, mentionPatterns, botHandle, cfg, agentId, channelId } = params;
534
+ if (isDirect) return {
535
+ canDetectMention: false,
536
+ wasMentioned: false
537
+ };
538
+ if (!body) return {
539
+ canDetectMention: true,
540
+ wasMentioned: false,
541
+ hasAnyMention: false
542
+ };
543
+ const sharedMentionRegexes = buildMentionRegexes(cfg, agentId, {
544
+ provider: "clickclack",
545
+ conversationId: channelId
546
+ });
547
+ const localMentionRegexes = buildLocalMentionRegexes({
548
+ cfg,
549
+ mentionPatterns,
550
+ channelId
551
+ });
552
+ const mentionRegexes = [...sharedMentionRegexes, ...localMentionRegexes];
553
+ const bodyForRegex = normalizeMentionText(body);
554
+ const hasConfiguredMention = mentionRegexes.some((regex) => regex.test(bodyForRegex));
555
+ const mentionHandles = resolveMentionHandles(body);
556
+ const normalizedBotHandle = botHandle?.replace(/^@/u, "").trim().toLowerCase();
557
+ return {
558
+ canDetectMention: true,
559
+ wasMentioned: (normalizedBotHandle ? mentionHandles.includes(normalizedBotHandle) : false) || hasConfiguredMention,
560
+ hasAnyMention: mentionHandles.length > 0 || hasConfiguredMention
561
+ };
417
562
  }
418
- function isDiscussionSessionKey(sessionKey) {
419
- return sessionKey.includes(":clickclack:") && /:channel:disc-[0-9a-f]{32}$/u.test(sessionKey);
563
+ //#endregion
564
+ //#region extensions/clickclack/src/runtime.ts
565
+ /**
566
+ * Runtime store for host-provided OpenClaw services used by the ClickClack
567
+ * bundled plugin.
568
+ */
569
+ const { setRuntime: setClickClackRuntime, getRuntime: getClickClackRuntime } = createPluginRuntimeStore({
570
+ pluginId: "clickclack",
571
+ errorMessage: "ClickClack runtime not initialized"
572
+ });
573
+ //#endregion
574
+ //#region extensions/clickclack/src/target.ts
575
+ /**
576
+ * Parses `channel:name`, `thread:msg_id`, `dm:usr_id`, or a bare channel name.
577
+ */
578
+ function parseClickClackTarget(raw) {
579
+ const value = raw.trim();
580
+ if (!value) throw new Error("ClickClack target is required");
581
+ const [prefix, ...rest] = value.split(":");
582
+ const body = rest.join(":").trim();
583
+ if (prefix === "channel" && body) return {
584
+ chatType: "group",
585
+ kind: "channel",
586
+ id: body
587
+ };
588
+ if (prefix === "thread" && body) return {
589
+ chatType: "group",
590
+ kind: "thread",
591
+ id: body
592
+ };
593
+ if (prefix === "dm" && body) return {
594
+ chatType: "direct",
595
+ kind: "dm",
596
+ id: body
597
+ };
598
+ if (!body) return {
599
+ chatType: "group",
600
+ kind: "channel",
601
+ id: value
602
+ };
603
+ throw new Error(`Unsupported ClickClack target: ${raw}`);
420
604
  }
421
- function discussionExternalRef(installationId, mainSessionKey, sessionId, destinationIdentity, bindingGeneration) {
422
- return `openclaw:${installationId}:${shortSessionHash([
423
- mainSessionKey,
424
- sessionId,
425
- destinationIdentity,
426
- bindingGeneration
427
- ].join("\0"))}`;
605
+ /** Formats a parsed ClickClack target back into canonical target syntax. */
606
+ function buildClickClackTarget(target) {
607
+ return `${target.kind}:${target.id}`;
428
608
  }
429
- function discussionCredentialFingerprint(token) {
430
- return createHash("sha256").update(token).digest("hex");
609
+ /** Normalizes user-entered ClickClack target text for channel routing. */
610
+ function normalizeClickClackTarget(raw) {
611
+ return buildClickClackTarget(parseClickClackTarget(raw));
431
612
  }
432
- function discussionSessionKey(params) {
433
- return params.runtime.channel.routing.buildAgentSessionKey({
434
- agentId: params.agentId,
435
- channel: "clickclack",
436
- accountId: params.accountId,
437
- peer: {
438
- kind: "channel",
439
- id: discussionSessionPeerId(params)
440
- }
441
- });
613
+ /** Reports whether a target string can be offered to the ClickClack parser. */
614
+ function looksLikeClickClackTarget(raw) {
615
+ return /^(channel|thread|dm):/i.test(raw.trim()) || raw.trim().length > 0;
442
616
  }
443
617
  //#endregion
444
- //#region extensions/clickclack/src/discussions/binding-store.ts
445
- function bindingMatchesSessionIncarnation(runtime, sessionKey, binding) {
446
- const entry = runtime.agent.session.getSessionEntry({
447
- sessionKey,
448
- readConsistency: "latest"
449
- });
450
- return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId);
451
- }
452
- function bindingMatchesActiveSessionIncarnation(runtime, sessionKey, binding) {
453
- const entry = runtime.agent.session.getSessionEntry({
454
- sessionKey,
455
- readConsistency: "latest"
456
- });
457
- return Boolean(entry && binding.sessionId && entry.sessionId === binding.sessionId && entry.archivedAt === void 0);
458
- }
459
- const DISCUSSION_BINDINGS_NAMESPACE = "discussion-bindings";
460
- const MAX_DISCUSSION_BINDINGS = 1e4;
461
- const storesByRuntime$1 = /* @__PURE__ */ new WeakMap();
462
- function channelKey(serverBaseUrl, channelId) {
463
- return `${serverBaseUrl.replace(/\/+$/u, "")}\0${channelId}`;
618
+ //#region extensions/clickclack/src/access.ts
619
+ /**
620
+ * Maps ClickClack senders and conversations onto the shared channel ingress
621
+ * allowlist/command authorization contract.
622
+ */
623
+ const CHANNEL_ID$2 = "clickclack";
624
+ function normalizeClickClackUserId(value) {
625
+ const trimmed = value.trim();
626
+ if (!trimmed) return null;
627
+ const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
628
+ return withoutProvider.match(/^dm:(.+)$/i)?.[1]?.trim() || withoutProvider || null;
464
629
  }
465
- /** SQLite-backed session/channel bindings with a process-local inbound lookup index. */
466
- var ClickClackDiscussionBindingStore = class {
467
- #store;
468
- #sessionByChannel = /* @__PURE__ */ new Map();
469
- #mainByDiscussionSession = /* @__PURE__ */ new Map();
470
- #runtime;
471
- constructor(runtime) {
472
- this.#runtime = runtime;
473
- this.#store = runtime.state.openSyncKeyedStore({
474
- namespace: DISCUSSION_BINDINGS_NAMESPACE,
475
- maxEntries: MAX_DISCUSSION_BINDINGS,
476
- overflowPolicy: "reject-new"
477
- });
478
- for (const entry of this.#store.entries()) this.#index(entry.key, entry.value);
479
- }
480
- get(sessionKey) {
481
- return this.#store.lookup(sessionKey);
482
- }
483
- hasCapacity(sessionKey) {
484
- return this.get(sessionKey) !== void 0 || this.#store.entries().length < MAX_DISCUSSION_BINDINGS;
485
- }
486
- getByChannel(serverBaseUrl, channelId) {
487
- const key = channelKey(serverBaseUrl, channelId);
488
- const sessionKey = this.#sessionByChannel.get(key);
489
- if (!sessionKey) return;
490
- const binding = this.get(sessionKey);
491
- if (!binding) {
492
- this.#sessionByChannel.delete(key);
493
- return;
494
- }
495
- return {
496
- sessionKey,
497
- binding
498
- };
499
- }
500
- set(sessionKey, binding) {
501
- const previous = this.get(sessionKey);
502
- this.#store.register(sessionKey, binding);
503
- if (previous) this.#unindex(sessionKey, previous);
504
- this.#index(sessionKey, binding);
505
- }
506
- delete(sessionKey) {
507
- const previous = this.get(sessionKey);
508
- const deleted = this.#store.delete(sessionKey);
509
- if (deleted && previous) this.#unindex(sessionKey, previous);
510
- return deleted;
511
- }
512
- getByDiscussionSession(sideSessionKey) {
513
- const sessionKey = this.#mainByDiscussionSession.get(sideSessionKey);
514
- if (!sessionKey) return;
515
- const binding = this.get(sessionKey);
516
- return binding ? {
517
- sessionKey,
518
- binding
519
- } : void 0;
520
- }
521
- entries() {
522
- return this.#store.entries().map((entry) => ({
523
- sessionKey: entry.key,
524
- binding: entry.value
525
- }));
526
- }
527
- #index(sessionKey, binding) {
528
- this.#sessionByChannel.set(channelKey(binding.serverBaseUrl, binding.channelId), sessionKey);
529
- const sideSessionKey = discussionSessionKey({
530
- runtime: this.#runtime,
531
- agentId: binding.agentId,
532
- mainSessionKey: sessionKey,
533
- sessionId: binding.sessionId,
534
- accountId: binding.accountId,
535
- serverBaseUrl: binding.serverBaseUrl,
536
- channelId: binding.channelId,
537
- externalRef: binding.externalRef
538
- });
539
- if (sideSessionKey) this.#mainByDiscussionSession.set(sideSessionKey, sessionKey);
540
- }
541
- #unindex(sessionKey, binding) {
542
- this.#sessionByChannel.delete(channelKey(binding.serverBaseUrl, binding.channelId));
543
- const sideSessionKey = discussionSessionKey({
544
- runtime: this.#runtime,
545
- agentId: binding.agentId,
546
- mainSessionKey: sessionKey,
547
- sessionId: binding.sessionId,
548
- accountId: binding.accountId,
549
- serverBaseUrl: binding.serverBaseUrl,
550
- channelId: binding.channelId,
551
- externalRef: binding.externalRef
552
- });
553
- if (sideSessionKey) this.#mainByDiscussionSession.delete(sideSessionKey);
554
- }
630
+ const clickClackIngressIdentity = {
631
+ key: "user-id",
632
+ normalizeEntry: normalizeClickClackUserId,
633
+ normalizeSubject: normalizeClickClackUserId,
634
+ isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
635
+ entryIdPrefix: "clickclack-user"
555
636
  };
556
- function getClickClackDiscussionBindingStore(runtime) {
557
- const existing = storesByRuntime$1.get(runtime);
558
- if (existing) return existing;
559
- const created = new ClickClackDiscussionBindingStore(runtime);
560
- storesByRuntime$1.set(runtime, created);
561
- return created;
562
- }
563
- //#endregion
564
- //#region extensions/clickclack/src/discussions/eligibility.ts
565
- function discussionAccounts(cfg) {
566
- return listEnabledClickClackAccounts(cfg).filter((account) => account.configured && account.discussions.enabled);
567
- }
568
- function normalizedServerBaseUrl(account) {
569
- return account.baseUrl.replace(/\/+$/u, "");
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;
570
640
  }
571
- /** Resolves the sole live account and rejects bindings pinned to an older destination. */
572
- function resolveDiscussionBindingAccount(cfg, binding) {
573
- const accounts = discussionAccounts(cfg);
574
- if (accounts.length !== 1) return { state: "unavailable" };
575
- const account = accounts[0];
576
- if (!account) return { state: "unavailable" };
577
- if (account.accountId !== binding.accountId || normalizedServerBaseUrl(account) !== binding.serverBaseUrl || account.discussions.workspace !== binding.workspaceRef || binding.credentialFingerprint !== void 0 && discussionCredentialFingerprint(account.token) !== binding.credentialFingerprint) return {
578
- state: "stale",
579
- account
641
+ function resolveAccountAgentRoute(params) {
642
+ const runtime = getClickClackRuntime();
643
+ const peer = {
644
+ kind: params.isDirect ? "direct" : "channel",
645
+ id: params.target
646
+ };
647
+ const route = runtime.channel.routing.resolveAgentRoute({
648
+ cfg: params.cfg,
649
+ channel: CHANNEL_ID$2,
650
+ accountId: params.account.accountId,
651
+ peer
652
+ });
653
+ const agentId = normalizeAgentId(params.account.agentId ?? route.agentId);
654
+ if (agentId === route.agentId) return route;
655
+ const dmScope = params.cfg.session?.dmScope ?? "main";
656
+ const sessionKey = runtime.channel.routing.buildAgentSessionKey({
657
+ agentId,
658
+ mainKey: params.cfg.session?.mainKey,
659
+ channel: CHANNEL_ID$2,
660
+ accountId: params.account.accountId,
661
+ peer,
662
+ dmScope,
663
+ identityLinks: params.cfg.session?.identityLinks
664
+ });
665
+ const mainSessionKey = runtime.channel.routing.buildAgentSessionKey({
666
+ agentId,
667
+ mainKey: params.cfg.session?.mainKey,
668
+ channel: CHANNEL_ID$2,
669
+ accountId: params.account.accountId,
670
+ dmScope: "main"
671
+ });
672
+ return {
673
+ ...route,
674
+ agentId,
675
+ dmScope,
676
+ sessionKey,
677
+ mainSessionKey,
678
+ lastRoutePolicy: sessionKey === mainSessionKey ? "main" : "session"
580
679
  };
680
+ }
681
+ function resolvePreparedInboundRoute(params) {
682
+ const runtime = getClickClackRuntime();
683
+ const isDirect = Boolean(params.message.direct_conversation_id);
684
+ const target = buildClickClackTarget(isDirect ? {
685
+ chatType: "direct",
686
+ kind: "dm",
687
+ id: params.message.author_id
688
+ } : {
689
+ chatType: "group",
690
+ kind: "channel",
691
+ id: params.message.channel_id ?? ""
692
+ });
693
+ const accountRoute = resolveAccountAgentRoute({
694
+ cfg: params.config,
695
+ account: params.account,
696
+ target,
697
+ isDirect
698
+ });
699
+ const discussionResolution = !isDirect && params.message.channel_id ? resolveClickClackDiscussionRoute({
700
+ runtime,
701
+ config: params.config,
702
+ accountId: params.account.accountId,
703
+ serverBaseUrl: params.account.baseUrl,
704
+ workspaceId: params.message.workspace_id,
705
+ channelId: params.message.channel_id
706
+ }) : { state: "unbound" };
707
+ const discussionRoute = discussionResolution.state === "active" ? discussionResolution.route : void 0;
581
708
  return {
582
- state: "active",
583
- account
709
+ isDirect,
710
+ target,
711
+ route: discussionRoute ? {
712
+ ...accountRoute,
713
+ agentId: discussionRoute.agentId,
714
+ sessionKey: discussionRoute.sessionKey,
715
+ lastRoutePolicy: "session"
716
+ } : accountRoute,
717
+ discussionRoute,
718
+ revoked: discussionResolution.state === "revoked"
584
719
  };
585
720
  }
586
- /** Public embed/open URLs for one bound discussion channel. */
587
- function discussionInfoForBinding(binding, account) {
588
- const baseUrl = normalizedServerBaseUrl(account);
721
+ /**
722
+ * Resolves whether a ClickClack message should enter the agent pipeline and
723
+ * whether its command-style body may run tools.
724
+ */
725
+ async function resolveClickClackInboundAccess(params) {
726
+ const runtime = getClickClackRuntime();
727
+ const cfg = params.config;
728
+ const preparedRoute = resolvePreparedInboundRoute(params);
729
+ const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(params.message.body, cfg);
730
+ const effectiveGroupPolicy = resolveClickClackGroupPolicy({
731
+ account: params.account,
732
+ channelId: params.message.channel_id
733
+ });
734
+ const mentionFacts = resolveClickClackMentionFacts({
735
+ isDirect: preparedRoute.isDirect,
736
+ body: params.message.body,
737
+ mentionPatterns: effectiveGroupPolicy.mentionPatterns,
738
+ botHandle: params.account.botHandle,
739
+ cfg,
740
+ agentId: preparedRoute.route.agentId,
741
+ channelId: params.message.channel_id
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;
778
+ const allowTextCommands = params.account.replyMode === "agent" && runtime.channel.commands.shouldHandleTextCommands({
779
+ cfg,
780
+ surface: CHANNEL_ID$2,
781
+ commandSource: "text"
782
+ });
783
+ const resolved = await resolveStableChannelMessageIngress({
784
+ channelId: CHANNEL_ID$2,
785
+ accountId: params.account.accountId,
786
+ identity: clickClackIngressIdentity,
787
+ cfg,
788
+ subject: { stableId: params.message.author_id },
789
+ conversation: {
790
+ kind: preparedRoute.isDirect ? "direct" : "group",
791
+ id: preparedRoute.isDirect ? params.message.direct_conversation_id ?? params.message.author_id : params.message.channel_id ?? params.message.thread_root_id
792
+ },
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,
800
+ dmPolicy: "allowlist",
801
+ groupPolicy: "allowlist",
802
+ mentionFacts,
803
+ policy: { activation: {
804
+ requireMention: effectiveGroupPolicy.requireMention,
805
+ allowTextCommands
806
+ } },
807
+ command: shouldCheckCommand ? {
808
+ cfg,
809
+ modeWhenAccessGroupsOff: "configured"
810
+ } : false
811
+ });
589
812
  return {
590
- state: "open",
591
- embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}?openclawHostTheme=1`,
592
- openUrl: `${baseUrl}/app/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`
813
+ shouldDispatch: !preparedRoute.revoked && resolved.ingress.admission === "dispatch",
814
+ commandAuthorized: resolved.commandAccess.requested ? resolved.commandAccess.authorized : resolved.senderAccess.allowed,
815
+ requireMention: effectiveGroupPolicy.requireMention,
816
+ mentionFacts,
817
+ botLoopProtection,
818
+ preparedRoute,
819
+ channelIngress: resolved
593
820
  };
594
821
  }
595
822
  //#endregion
596
- //#region extensions/clickclack/src/discussions/revoked-channel-store.ts
597
- const REVOKED_CHANNELS_NAMESPACE = "discussion-revoked-channels";
598
- const MAX_REVOKED_CHANNELS = 1e5;
599
- const storesByRuntime = /* @__PURE__ */ new WeakMap();
600
- function revokedChannelKey(params) {
601
- return [params.serverBaseUrl.replace(/\/+$/u, ""), params.channelId].join("\0");
602
- }
603
- function getStore(runtime) {
604
- const existing = storesByRuntime.get(runtime);
605
- if (existing) return existing;
606
- const created = runtime.state.openSyncKeyedStore({
607
- namespace: REVOKED_CHANNELS_NAMESPACE,
608
- maxEntries: MAX_REVOKED_CHANNELS,
609
- overflowPolicy: "reject-new"
610
- });
611
- storesByRuntime.set(runtime, created);
612
- return created;
823
+ //#region extensions/clickclack/src/command-menu.ts
824
+ const CLICKCLACK_COMMAND_PATTERN = /^[a-z0-9_-]{1,32}$/u;
825
+ const CLICKCLACK_MAX_COMMANDS = 100;
826
+ const CLICKCLACK_MAX_DESCRIPTION_LENGTH = 100;
827
+ const CLICKCLACK_MAX_ARGS_HINT_LENGTH = 100;
828
+ function truncateCodePoints(value, maxLength) {
829
+ return Array.from(value).slice(0, maxLength).join("");
613
830
  }
614
- /** Records managed ownership before its live binding is released. */
615
- function markClickClackDiscussionChannelRevoked(runtime, binding) {
616
- const value = {
617
- accountId: binding.accountId,
618
- serverBaseUrl: binding.serverBaseUrl,
619
- channelId: binding.channelId,
620
- revokedAt: Date.now()
621
- };
622
- getStore(runtime).register(revokedChannelKey(value), value);
831
+ function commandArgsHint(spec) {
832
+ if (spec.args?.length) return truncateCodePoints(spec.args.map((arg) => arg.required ? `<${arg.name}>` : `[${arg.name}]`).join(" "), CLICKCLACK_MAX_ARGS_HINT_LENGTH);
833
+ return spec.acceptsArgs ? "[args]" : "";
623
834
  }
624
- function markClickClackDiscussionChannelIdentityRevoked(params) {
625
- const value = {
626
- accountId: params.accountId,
627
- serverBaseUrl: params.serverBaseUrl.replace(/\/+$/u, ""),
628
- channelId: params.channelId,
629
- revokedAt: Date.now()
630
- };
631
- getStore(params.runtime).register(revokedChannelKey(value), value);
835
+ function errorStatus(error) {
836
+ if (!error || typeof error !== "object" || !("status" in error)) return;
837
+ const status = error.status;
838
+ return typeof status === "number" ? status : void 0;
632
839
  }
633
- function clearClickClackDiscussionChannelRevoked(params) {
634
- getStore(params.runtime).delete(revokedChannelKey(params));
840
+ function mapNativeCommandSpecsToClickClackMenu(specs, log) {
841
+ const commands = [];
842
+ const seen = /* @__PURE__ */ new Set();
843
+ const invalidNames = /* @__PURE__ */ new Set();
844
+ for (const spec of specs) {
845
+ if (spec.isAlias) continue;
846
+ const command = spec.name.trim().toLowerCase();
847
+ if (!CLICKCLACK_COMMAND_PATTERN.test(command)) {
848
+ invalidNames.add(spec.name.trim() || "<empty>");
849
+ continue;
850
+ }
851
+ if (seen.has(command)) continue;
852
+ seen.add(command);
853
+ const description = spec.description.trim() || command;
854
+ commands.push({
855
+ command,
856
+ description: truncateCodePoints(description, CLICKCLACK_MAX_DESCRIPTION_LENGTH),
857
+ args_hint: commandArgsHint(spec)
858
+ });
859
+ }
860
+ if (invalidNames.size > 0) log?.warn?.(`ClickClack command menu skipped invalid native command names: ${[...invalidNames].map((name) => JSON.stringify(name)).join(", ")}`);
861
+ return commands.slice(0, CLICKCLACK_MAX_COMMANDS);
635
862
  }
636
- /** Distinguishes a released managed channel from a genuinely ordinary channel. */
637
- function isClickClackDiscussionChannelRevoked(params) {
638
- return Boolean(getStore(params.runtime).lookup(revokedChannelKey(params)));
863
+ async function syncClickClackCommandMenu(params) {
864
+ try {
865
+ const commands = mapNativeCommandSpecsToClickClackMenu(listNativeCommandSpecsForConfig(params.cfg, { provider: "clickclack" }), params.log);
866
+ await params.client.setBotCommands(commands);
867
+ } catch (error) {
868
+ const status = errorStatus(error);
869
+ if (status === 403) {
870
+ params.log?.warn?.("ClickClack command menu sync skipped: bot token lacks commands:write");
871
+ return;
872
+ }
873
+ if (status === 404) {
874
+ params.log?.debug?.("ClickClack command menu sync skipped: server does not support /api/bots/self/commands");
875
+ return;
876
+ }
877
+ params.log?.warn?.(`ClickClack command menu sync failed: ${formatErrorMessage(error)}`);
878
+ }
639
879
  }
640
880
  //#endregion
641
- //#region extensions/clickclack/src/discussions/routing.ts
642
- function resolveClickClackDiscussionRoute(params) {
643
- if (isClickClackDiscussionChannelRevoked(params)) return { state: "revoked" };
644
- const matched = getClickClackDiscussionBindingStore(params.runtime).getByChannel(params.serverBaseUrl, params.channelId);
645
- if (!matched) return { state: hasPendingDiscussionOpenForDestination(params) ? "revoked" : "unbound" };
646
- if (matched.binding.accountId !== params.accountId) return { state: "revoked" };
647
- if (matched.binding.serverBaseUrl !== params.serverBaseUrl.replace(/\/+$/u, "")) return { state: "revoked" };
648
- if (matched.binding.archived) return { state: "revoked" };
649
- if (resolveDiscussionBindingAccount(params.config, matched.binding).state !== "active") return { state: "revoked" };
650
- if (!bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding)) return { state: "revoked" };
651
- const sessionKey = discussionSessionKey({
652
- runtime: params.runtime,
653
- agentId: matched.binding.agentId,
654
- mainSessionKey: matched.sessionKey,
655
- sessionId: matched.binding.sessionId,
656
- accountId: params.accountId,
657
- serverBaseUrl: matched.binding.serverBaseUrl,
658
- channelId: matched.binding.channelId,
659
- externalRef: matched.binding.externalRef
881
+ //#region extensions/clickclack/src/activity.ts
882
+ /**
883
+ * Publishes agent activity (streamed commentary + tool progress) into
884
+ * ClickClack as durable `agent_commentary` / `agent_tool` message rows,
885
+ * coalesced so one logical step becomes one row instead of a row per frame.
886
+ *
887
+ * Ported from the clickglass agent-bridge sidecar, adapted from gateway
888
+ * websocket frames to the in-process `replyOptions.onItemEvent` seam:
889
+ *
890
+ * - Commentary/reasoning arrives as cumulative text snapshots per item id.
891
+ * Each segment becomes one durable row: POSTed when the segment starts
892
+ * streaming and PATCHed (debounced) as the snapshot grows, so prose
893
+ * interleaves chronologically with tool rows.
894
+ * - Tool/step items can emit several frames (start/update/complete) for the
895
+ * same call, sometimes with lane-prefixed ids (`tool:X`, `command:X`).
896
+ * Normalize the prefix away to one key per call, POST one row on the first
897
+ * frame, and PATCH it when a later frame carries a strictly longer body.
898
+ */
899
+ /** Debounce window for PATCHing streaming commentary snapshots. */
900
+ const CLICKCLACK_COMMENTARY_FLUSH_MS = 700;
901
+ /** Item kinds rendered as agent_tool rows; everything else is commentary. */
902
+ const TOOL_ITEM_KINDS = /* @__PURE__ */ new Set([
903
+ "tool",
904
+ "command",
905
+ "command_output",
906
+ "patch",
907
+ "search",
908
+ "api"
909
+ ]);
910
+ /** Item kinds that never become durable rows (ephemeral or plumbing lanes). */
911
+ const SKIPPED_ITEM_KINDS = /* @__PURE__ */ new Set(["lifecycle"]);
912
+ /** Item kinds emitted as cumulative commentary snapshots. */
913
+ const STREAMING_COMMENTARY_ITEM_KINDS = /* @__PURE__ */ new Set([
914
+ "preamble",
915
+ "commentary",
916
+ "analysis",
917
+ "thinking",
918
+ "reasoning"
919
+ ]);
920
+ /** Provider-specific reasoning lanes normalized into one ClickClack label. */
921
+ const THINKING_ITEM_KINDS = /* @__PURE__ */ new Set([
922
+ "analysis",
923
+ "thinking",
924
+ "reasoning"
925
+ ]);
926
+ function normalizedItemKind(payload) {
927
+ return payload.kind?.trim().toLowerCase() ?? "";
928
+ }
929
+ function commentaryBody(payload) {
930
+ const text = payload.progressText?.trim() || payload.summary?.trim() || payload.meta?.trim() || "";
931
+ if (!text) return "";
932
+ const kind = normalizedItemKind(payload);
933
+ if (THINKING_ITEM_KINDS.has(kind)) return `**Thinking**\n\n${text}`;
934
+ return text;
935
+ }
936
+ function activityBody(payload) {
937
+ const line = buildChannelProgressDraftLine({
938
+ event: "item",
939
+ itemId: payload.itemId,
940
+ toolCallId: payload.toolCallId,
941
+ itemKind: payload.kind,
942
+ title: payload.title,
943
+ name: payload.name,
944
+ phase: payload.phase,
945
+ status: payload.status,
946
+ summary: payload.summary,
947
+ progressText: payload.progressText,
948
+ meta: payload.meta,
949
+ commandBearing: payload.commandBearing
950
+ })?.text?.trim();
951
+ if (line) return line;
952
+ const head = payload.name?.trim() || payload.title?.trim();
953
+ const text = payload.progressText?.trim() || payload.summary?.trim();
954
+ if (head && text) return `**${head}**\n\n${text}`;
955
+ if (text) return text;
956
+ if (head) return head;
957
+ return payload.status?.trim() || payload.kind?.trim() || "";
958
+ }
959
+ /**
960
+ * Creates a per-turn activity publisher. Publishing is best-effort: transport
961
+ * failures are reported through `onError` and never interrupt the reply turn.
962
+ */
963
+ function createClickClackActivityPublisher(params) {
964
+ const flushMs = params.flushMs ?? CLICKCLACK_COMMENTARY_FLUSH_MS;
965
+ const commentaryByItem = /* @__PURE__ */ new Map();
966
+ const toolRows = /* @__PURE__ */ new Map();
967
+ let provenance;
968
+ let chain = Promise.resolve();
969
+ const enqueue = (work) => {
970
+ chain = chain.then(work).catch((error) => {
971
+ params.onError?.(error);
972
+ });
973
+ return chain;
974
+ };
975
+ const postRow = (kind, body) => params.client.createActivityMessage({
976
+ channelId: params.target.channelId,
977
+ conversationId: params.target.conversationId,
978
+ body,
979
+ kind,
980
+ turnId: params.turnId,
981
+ provenance
660
982
  });
661
- if (!sessionKey) return { state: "revoked" };
662
- return {
663
- state: "active",
664
- route: {
665
- agentId: matched.binding.agentId,
666
- sessionKey,
667
- systemPrompt: [
668
- "You are the side agent for a ClickClack discussion attached to an OpenClaw session.",
669
- `The main session key is ${matched.sessionKey}.`,
670
- "Observe it with sessions_history and session_status (using changesSince for incremental checks).",
671
- "Use sessions_send to relay or steer the main session only when the humans in this discussion ask you to.",
672
- "These session tools are host-scoped to the attached main session; do not attempt session discovery or alternate targets."
673
- ].join(" ")
983
+ const flushCommentary = (segmentKey) => {
984
+ const segment = commentaryByItem.get(segmentKey);
985
+ if (!segment) return Promise.resolve();
986
+ if (segment.timer) {
987
+ clearTimeout(segment.timer);
988
+ segment.timer = void 0;
674
989
  }
990
+ if (!segment.dirty || !segment.body.trim()) return Promise.resolve();
991
+ segment.dirty = false;
992
+ const body = segment.body;
993
+ return enqueue(async () => {
994
+ if (segment.messageId) {
995
+ await params.client.updateMessageBody(segment.messageId, body);
996
+ return;
997
+ }
998
+ const posted = await postRow("agent_commentary", body);
999
+ segment.messageId = posted.id;
1000
+ });
1001
+ };
1002
+ const flushAllCommentary = () => {
1003
+ const flushes = [...commentaryByItem.keys()].map((key) => flushCommentary(key));
1004
+ return Promise.all(flushes).then(() => void 0);
675
1005
  };
676
- }
677
- //#endregion
678
- //#region extensions/clickclack/src/target.ts
679
- /**
680
- * Parses `channel:name`, `thread:msg_id`, `dm:usr_id`, or a bare channel name.
681
- */
682
- function parseClickClackTarget(raw) {
683
- const value = raw.trim();
684
- if (!value) throw new Error("ClickClack target is required");
685
- const [prefix, ...rest] = value.split(":");
686
- const body = rest.join(":").trim();
687
- if (prefix === "channel" && body) return {
688
- chatType: "group",
689
- kind: "channel",
690
- id: body
1006
+ const handleCommentary = (payload) => {
1007
+ const body = commentaryBody(payload);
1008
+ if (!body.trim()) return;
1009
+ const key = payload.itemId?.trim() || "turn";
1010
+ let segment = commentaryByItem.get(key);
1011
+ if (!segment) {
1012
+ segment = {
1013
+ body: "",
1014
+ dirty: false
1015
+ };
1016
+ commentaryByItem.set(key, segment);
1017
+ }
1018
+ if (body.length < segment.body.length || body === segment.body) return;
1019
+ segment.body = body;
1020
+ segment.dirty = true;
1021
+ if (!segment.timer) segment.timer = setTimeout(() => {
1022
+ segment.timer = void 0;
1023
+ flushCommentary(key);
1024
+ }, flushMs);
691
1025
  };
692
- if (prefix === "thread" && body) return {
693
- chatType: "group",
694
- kind: "thread",
695
- id: body
1026
+ const toolRowKey = (payload) => {
1027
+ const toolCallId = payload.toolCallId?.trim();
1028
+ if (toolCallId) return toolCallId;
1029
+ return (payload.itemId?.trim() ?? "").replace(/^(tool|command):/, "");
696
1030
  };
697
- if (prefix === "dm" && body) return {
698
- chatType: "direct",
699
- kind: "dm",
700
- id: body
1031
+ const handleDiscreteItem = (payload) => {
1032
+ const body = activityBody(payload);
1033
+ if (!body) return;
1034
+ const kind = TOOL_ITEM_KINDS.has(payload.kind?.trim().toLowerCase() ?? "") ? "agent_tool" : "agent_commentary";
1035
+ flushAllCommentary();
1036
+ const key = `${kind}:${toolRowKey(payload)}`;
1037
+ const existing = toolRows.get(key);
1038
+ if (!existing || !toolRowKey(payload)) {
1039
+ const row = { body };
1040
+ if (toolRowKey(payload)) toolRows.set(key, row);
1041
+ enqueue(async () => {
1042
+ const posted = await postRow(kind, row.body);
1043
+ row.messageId = posted.id;
1044
+ row.sentBody = row.body;
1045
+ });
1046
+ return;
1047
+ }
1048
+ if (body.length <= existing.body.length) return;
1049
+ existing.body = body;
1050
+ enqueue(async () => {
1051
+ if (existing.messageId && existing.body !== existing.sentBody) {
1052
+ await params.client.updateMessageBody(existing.messageId, existing.body);
1053
+ existing.sentBody = existing.body;
1054
+ }
1055
+ });
701
1056
  };
702
- if (!body) return {
703
- chatType: "group",
704
- kind: "channel",
705
- id: value
1057
+ return {
1058
+ onItemEvent: (payload) => {
1059
+ const kind = normalizedItemKind(payload);
1060
+ if (STREAMING_COMMENTARY_ITEM_KINDS.has(kind)) {
1061
+ handleCommentary(payload);
1062
+ return false;
1063
+ }
1064
+ if (SKIPPED_ITEM_KINDS.has(kind)) return false;
1065
+ handleDiscreteItem(payload);
1066
+ return false;
1067
+ },
1068
+ setProvenance: (next) => {
1069
+ provenance = next;
1070
+ },
1071
+ finalize: async () => {
1072
+ await flushAllCommentary();
1073
+ await chain;
1074
+ }
706
1075
  };
707
- throw new Error(`Unsupported ClickClack target: ${raw}`);
708
- }
709
- /** Formats a parsed ClickClack target back into canonical target syntax. */
710
- function buildClickClackTarget(target) {
711
- return `${target.kind}:${target.id}`;
712
- }
713
- /** Normalizes user-entered ClickClack target text for channel routing. */
714
- function normalizeClickClackTarget(raw) {
715
- return buildClickClackTarget(parseClickClackTarget(raw));
716
- }
717
- /** Reports whether a target string can be offered to the ClickClack parser. */
718
- function looksLikeClickClackTarget(raw) {
719
- return /^(channel|thread|dm):/i.test(raw.trim()) || raw.trim().length > 0;
720
1076
  }
721
1077
  //#endregion
722
1078
  //#region extensions/clickclack/src/outbound.ts
@@ -876,9 +1232,9 @@ async function sendClickClackMedia(params) {
876
1232
  mediaLocalRoots: params.mediaLocalRoots,
877
1233
  mediaReadFile: params.mediaReadFile
878
1234
  });
879
- const filename = media.fileName?.trim() || "attachment";
880
- mediaFilename = filename;
881
1235
  const contentType = media.contentType?.trim() || "application/octet-stream";
1236
+ const filename = media.fileName?.trim() || `attachment${extensionForMime(contentType) ?? ""}`;
1237
+ mediaFilename = filename;
882
1238
  await dispatch();
883
1239
  upload = await client.createUpload({
884
1240
  workspaceId,
@@ -1011,6 +1367,219 @@ async function reconcileClickClackUnknownSend(ctx) {
1011
1367
  };
1012
1368
  }
1013
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
1014
1583
  //#region extensions/clickclack/src/inbound.ts
1015
1584
  const CHANNEL_ID$1 = "clickclack";
1016
1585
  const CLICKCLACK_MESSAGE_ID_PATTERN = /^msg_[0-9a-hjkmnp-tv-z]{26}$/u;
@@ -1020,49 +1589,6 @@ function hasClickClackReplyMedia(payload) {
1020
1589
  function resolveClickClackAgentRunId(messageId) {
1021
1590
  return CLICKCLACK_MESSAGE_ID_PATTERN.test(messageId) ? `${CHANNEL_ID$1}:${messageId}` : void 0;
1022
1591
  }
1023
- function resolveAccountAgentRoute(params) {
1024
- const runtime = getClickClackRuntime();
1025
- const route = runtime.channel.routing.resolveAgentRoute({
1026
- cfg: params.cfg,
1027
- channel: CHANNEL_ID$1,
1028
- accountId: params.account.accountId,
1029
- peer: {
1030
- kind: params.isDirect ? "direct" : "channel",
1031
- id: params.target
1032
- }
1033
- });
1034
- const agentId = normalizeAgentId(params.account.agentId ?? route.agentId);
1035
- if (agentId === route.agentId) return route;
1036
- const peer = {
1037
- kind: params.isDirect ? "direct" : "channel",
1038
- id: params.target
1039
- };
1040
- const dmScope = params.cfg.session?.dmScope ?? "main";
1041
- const sessionKey = runtime.channel.routing.buildAgentSessionKey({
1042
- agentId,
1043
- mainKey: params.cfg.session?.mainKey,
1044
- channel: CHANNEL_ID$1,
1045
- accountId: params.account.accountId,
1046
- peer,
1047
- dmScope,
1048
- identityLinks: params.cfg.session?.identityLinks
1049
- });
1050
- const mainSessionKey = runtime.channel.routing.buildAgentSessionKey({
1051
- agentId,
1052
- mainKey: params.cfg.session?.mainKey,
1053
- channel: CHANNEL_ID$1,
1054
- accountId: params.account.accountId,
1055
- dmScope: "main"
1056
- });
1057
- return {
1058
- ...route,
1059
- agentId,
1060
- dmScope,
1061
- sessionKey,
1062
- mainSessionKey,
1063
- lastRoutePolicy: sessionKey === mainSessionKey ? "main" : "session"
1064
- };
1065
- }
1066
1592
  async function dispatchModelReply(params) {
1067
1593
  const runtime = getClickClackRuntime();
1068
1594
  const text = (await runtime.llm.complete({
@@ -1104,50 +1630,56 @@ async function handleClickClackInbound(params) {
1104
1630
  config: params.config,
1105
1631
  message
1106
1632
  });
1107
- if (!access.shouldDispatch) return;
1633
+ if (!access.shouldDispatch || !access.channelIngress) return;
1108
1634
  const conversationId = message.channel_id || message.direct_conversation_id;
1109
1635
  if (!conversationId) return;
1110
- const isDirect = Boolean(message.direct_conversation_id);
1111
- const target = buildClickClackTarget(isDirect ? {
1112
- chatType: "direct",
1113
- kind: "dm",
1114
- id: message.author_id
1115
- } : {
1116
- chatType: "group",
1117
- kind: "channel",
1118
- id: message.channel_id ?? ""
1119
- });
1120
- const accountRoute = resolveAccountAgentRoute({
1121
- cfg: params.config,
1122
- account: params.account,
1123
- target,
1124
- isDirect
1125
- });
1126
- const discussionResolution = !isDirect && message.channel_id ? resolveClickClackDiscussionRoute({
1127
- runtime,
1128
- config: params.config,
1129
- accountId: params.account.accountId,
1130
- serverBaseUrl: params.account.baseUrl,
1131
- workspaceId: message.workspace_id,
1132
- channelId: message.channel_id
1133
- }) : { state: "unbound" };
1134
- if (discussionResolution.state === "revoked") return;
1135
- const discussionRoute = discussionResolution.state === "active" ? discussionResolution.route : void 0;
1136
- const route = discussionRoute ? {
1137
- ...accountRoute,
1138
- agentId: discussionRoute.agentId,
1139
- sessionKey: discussionRoute.sessionKey,
1140
- lastRoutePolicy: "session"
1141
- } : accountRoute;
1142
- if (params.account.replyMode === "model" && !discussionRoute) {
1143
- await dispatchModelReply({
1144
- account: params.account,
1145
- cfg: params.config,
1146
- message,
1147
- route,
1148
- target,
1636
+ const { discussionRoute, isDirect, route, target } = access.preparedRoute;
1637
+ const progress = params.account.nativeProgress ? createClickClackAgentProgressPublisher({
1638
+ client: createClickClackClient({
1639
+ baseUrl: params.account.apiEndpoint,
1640
+ token: params.account.token,
1149
1641
  correlationId: params.correlationId
1150
- });
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
+ }
1151
1683
  return;
1152
1684
  }
1153
1685
  let turnProvenance;
@@ -1177,7 +1709,8 @@ async function handleClickClackInbound(params) {
1177
1709
  timestamp: new Date(message.created_at),
1178
1710
  body: message.body
1179
1711
  });
1180
- const ctxPayload = runtime.channel.inbound.buildContext({
1712
+ const ctxPayload = (params.buildContext ?? buildChannelInboundEventContext)({
1713
+ channelIngress: access.channelIngress,
1181
1714
  channel: CHANNEL_ID$1,
1182
1715
  accountId: route.accountId ?? params.account.accountId,
1183
1716
  messageId: message.id,
@@ -1216,10 +1749,7 @@ async function handleClickClackInbound(params) {
1216
1749
  },
1217
1750
  access: {
1218
1751
  commands: { authorized: access.commandAuthorized },
1219
- mentions: {
1220
- canDetectMention: !isDirect,
1221
- wasMentioned: !isDirect
1222
- }
1752
+ mentions: access.mentionFacts
1223
1753
  },
1224
1754
  extra: {
1225
1755
  GroupChannel: message.channel_id,
@@ -1227,20 +1757,27 @@ async function handleClickClackInbound(params) {
1227
1757
  }
1228
1758
  });
1229
1759
  const runId = resolveClickClackAgentRunId(message.id);
1230
- const activityReplyOptions = activity ? {
1231
- onModelSelected: (ctx) => {
1760
+ const activityReplyOptions = {
1761
+ ...activity ? { onModelSelected: (ctx) => {
1232
1762
  turnProvenance = {
1233
1763
  model: ctx.provider && ctx.model ? `${ctx.provider}/${ctx.model}` : ctx.model,
1234
1764
  thinking: ctx.thinkLevel
1235
1765
  };
1236
- activity?.setProvenance(turnProvenance);
1237
- },
1238
- onItemEvent: activity.onItemEvent,
1239
- commentaryProgressEnabled: true,
1240
- suppressDefaultToolProgressMessages: true,
1241
- allowProgressCallbacksWhenSourceDeliverySuppressed: true
1242
- } : void 0;
1243
- 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({
1244
1781
  cfg: params.config,
1245
1782
  channel: CHANNEL_ID$1,
1246
1783
  accountId: params.account.accountId,
@@ -1250,11 +1787,12 @@ async function handleClickClackInbound(params) {
1250
1787
  sessionKey: route.sessionKey
1251
1788
  },
1252
1789
  ctxPayload,
1790
+ botLoopProtection: access.botLoopProtection,
1253
1791
  toolsAllow: params.account.toolsAllow,
1254
- replyOptions: runId || activityReplyOptions ? {
1792
+ replyOptions: {
1255
1793
  ...runId ? { runId } : {},
1256
1794
  ...activityReplyOptions
1257
- } : void 0,
1795
+ },
1258
1796
  delivery: {
1259
1797
  deliver: async (payload) => {
1260
1798
  if (hasClickClackReplyMedia(payload)) throw new Error("ClickClack media reply requires durable delivery");
@@ -1296,8 +1834,9 @@ async function handleClickClackInbound(params) {
1296
1834
  } }
1297
1835
  });
1298
1836
  try {
1299
- await dispatchPromise;
1837
+ await dispatch();
1300
1838
  } finally {
1839
+ await progress?.finalize();
1301
1840
  await activity?.finalize();
1302
1841
  }
1303
1842
  }
@@ -1305,8 +1844,7 @@ async function handleClickClackInbound(params) {
1305
1844
  //#region extensions/clickclack/src/gateway.ts
1306
1845
  const CLICKCLACK_EVENT_PAGE_LIMIT = 500;
1307
1846
  function payloadString(event, key) {
1308
- const value = event.payload?.[key];
1309
- return typeof value === "string" ? value : "";
1847
+ return readStringField(event.payload, key) ?? "";
1310
1848
  }
1311
1849
  function eventCorrelationId(event) {
1312
1850
  return normalizeClickClackCorrelationId(event.payload?.correlation_id);
@@ -1314,25 +1852,16 @@ function eventCorrelationId(event) {
1314
1852
  async function resolveEventMessage(params) {
1315
1853
  const messageId = payloadString(params.event, "message_id");
1316
1854
  if (!messageId) return null;
1317
- const directConversationId = payloadString(params.event, "direct_conversation_id");
1318
- if (directConversationId && typeof params.event.seq === "number") return (await params.client.directMessages(directConversationId, params.event.seq - 1, 10)).find((message) => message.id === messageId) ?? null;
1319
- if (params.event.type === "thread.reply_created") {
1320
- const rootId = payloadString(params.event, "root_message_id");
1321
- if (!rootId) return null;
1322
- 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;
1323
1860
  }
1324
- 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;
1325
- return null;
1326
- }
1327
- function decodeSocketMessage(data) {
1328
- if (typeof data === "string") return data;
1329
- if (Buffer.isBuffer(data)) return data.toString("utf8");
1330
- if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
1331
- return Buffer.concat(data).toString("utf8");
1332
1861
  }
1333
1862
  function parseSocketEvent(data) {
1334
1863
  try {
1335
- return JSON.parse(decodeSocketMessage(data));
1864
+ return JSON.parse(rawDataToString(data));
1336
1865
  } catch {
1337
1866
  return null;
1338
1867
  }
@@ -1349,19 +1878,26 @@ async function processEvent(params) {
1349
1878
  }) : params.client,
1350
1879
  event: params.event
1351
1880
  });
1352
- if (!message || message.author_id === params.botUserId) return;
1353
- 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;
1354
1886
  const access = await resolveClickClackInboundAccess({
1355
1887
  account: params.account,
1356
1888
  config: params.config,
1357
1889
  message
1358
1890
  });
1359
- if (!access.shouldDispatch) return;
1891
+ if (!access.shouldDispatch) {
1892
+ params.log?.info(`[${params.account.accountId}] skipped ClickClack message before agent dispatch: kind=${message.direct_conversation_id ? "dm" : "group"} requireMention=${access.requireMention ?? "unknown"} wasMentioned=${access.mentionFacts.wasMentioned} hasAnyMention=${access.mentionFacts.hasAnyMention ?? "unknown"} commandAuthorized=${access.commandAuthorized}`);
1893
+ return;
1894
+ }
1360
1895
  await handleClickClackInbound({
1361
1896
  account: params.account,
1362
1897
  config: params.config,
1363
1898
  message,
1364
1899
  access,
1900
+ buildContext: params.buildContext,
1365
1901
  ...correlationId ? { correlationId } : {}
1366
1902
  });
1367
1903
  }
@@ -1397,14 +1933,17 @@ async function startClickClackGatewayAccount(ctx) {
1397
1933
  const account = {
1398
1934
  ...configuredAccount,
1399
1935
  workspace: workspaceId,
1400
- botUserId: configuredAccount.botUserId ?? me.id
1936
+ botUserId: configuredAccount.botUserId ?? me.id,
1937
+ botHandle: me.handle
1401
1938
  };
1402
1939
  const processIncomingEvent = (event) => processEvent({
1403
1940
  account,
1404
1941
  config: ctx.cfg,
1405
1942
  client,
1406
1943
  event,
1407
- botUserId: account.botUserId
1944
+ botUserId: account.botUserId,
1945
+ buildContext: ctx.channelRuntime?.inbound.buildContext,
1946
+ log: ctx.log
1408
1947
  });
1409
1948
  if (account.commandMenu) await syncClickClackCommandMenu({
1410
1949
  cfg: ctx.cfg,
@@ -1414,6 +1953,7 @@ async function startClickClackGatewayAccount(ctx) {
1414
1953
  ctx.setStatus({
1415
1954
  accountId: account.accountId,
1416
1955
  running: true,
1956
+ lifecycle: "starting",
1417
1957
  configured: true,
1418
1958
  enabled: account.enabled,
1419
1959
  baseUrl: account.baseUrl
@@ -1469,6 +2009,9 @@ async function startClickClackGatewayAccount(ctx) {
1469
2009
  };
1470
2010
  ctx.abortSignal.addEventListener("abort", abort, { once: true });
1471
2011
  removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
2012
+ socket.on("open", () => {
2013
+ ctx.setStatus(channelReadyPatch({ accountId: account.accountId }));
2014
+ });
1472
2015
  socket.on("message", (data) => {
1473
2016
  if (closing || settled) return;
1474
2017
  messageQueue = messageQueue.then(async () => {
@@ -1484,6 +2027,11 @@ async function startClickClackGatewayAccount(ctx) {
1484
2027
  });
1485
2028
  socket.on("close", () => {
1486
2029
  closing = true;
2030
+ if (!ctx.abortSignal.aborted) ctx.setStatus({
2031
+ accountId: account.accountId,
2032
+ connected: false,
2033
+ lifecycle: "recovering"
2034
+ });
1487
2035
  finishAfterQueuedMessages();
1488
2036
  });
1489
2037
  socket.on("error", (error) => {
@@ -1493,6 +2041,12 @@ async function startClickClackGatewayAccount(ctx) {
1493
2041
  }
1494
2042
  if (closing) return;
1495
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
+ });
1496
2050
  closing = true;
1497
2051
  socket.close();
1498
2052
  });
@@ -1504,10 +2058,7 @@ async function startClickClackGatewayAccount(ctx) {
1504
2058
  }
1505
2059
  }
1506
2060
  } finally {
1507
- ctx.setStatus({
1508
- accountId: account.accountId,
1509
- running: false
1510
- });
2061
+ ctx.setStatus(channelStoppedPatch({ accountId: account.accountId }));
1511
2062
  }
1512
2063
  }
1513
2064
  //#endregion
@@ -1610,7 +2161,6 @@ const clickClackPlugin = createChatChannelPlugin({
1610
2161
  reload: { configPrefixes: ["channels.clickclack"] },
1611
2162
  configSchema: clickClackConfigSchema,
1612
2163
  config: clickClackConfigAdapter,
1613
- setup: clickClackSetupAdapter,
1614
2164
  setupContract: clickClackSetupContract,
1615
2165
  setupWizard: clickClackSetupWizard,
1616
2166
  secrets: {
@@ -1717,4 +2267,4 @@ const clickClackPlugin = createChatChannelPlugin({
1717
2267
  }
1718
2268
  });
1719
2269
  //#endregion
1720
- export { listPendingDiscussionOpens as C, setClickClackRuntime as D, getClickClackRuntime as E, clearDiscussionBindingGeneration as S, reserveDiscussionBindingGeneration as T, fallbackDiscussionLabel as _, isClickClackDiscussionChannelRevoked as a, slugifyDiscussionLabel as b, discussionAccounts as c, resolveDiscussionBindingAccount as d, bindingMatchesActiveSessionIncarnation as f, discussionExternalRef as g, discussionCredentialFingerprint as h, clearClickClackDiscussionChannelRevoked as i, discussionInfoForBinding as l, getClickClackDiscussionBindingStore as m, buildClickClackTarget as n, markClickClackDiscussionChannelIdentityRevoked as o, bindingMatchesSessionIncarnation as p, parseClickClackTarget as r, markClickClackDiscussionChannelRevoked as s, clickClackPlugin as t, normalizedServerBaseUrl as u, isDiscussionSessionKey as v, recordPendingDiscussionOpen as w, truncateDiscussionDisplayTitle as x, resolveDiscussionLabel 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 };