@openclaw/clickclack 2026.7.2-beta.7 → 2026.7.33
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.
- package/README.md +0 -152
- package/dist/.setup/channel-DGqOQ-o2.mjs +1172 -0
- package/dist/api.js +1 -2
- package/dist/channel-plugin-api.js +1 -1
- package/dist/index.js +1 -3
- package/dist/runtime-api.js +3 -4
- package/node_modules/ws/lib/permessage-deflate.js +3 -1
- package/node_modules/ws/package.json +1 -1
- package/openclaw.plugin.json +21 -129
- package/package.json +6 -94
- package/dist/channel-CECIpaS0.js +0 -1852
- package/dist/doctor-contract-api.js +0 -48
- package/dist/runtime-api-CZ1rmHjv.js +0 -1029
- package/dist/secret-contract-BKkzzjBA.js +0 -35
- package/dist/secret-contract-api.js +0 -2
- package/dist/setup-claim-CP-QZ5bt.js +0 -136
- package/dist/setup-entry.js +0 -15
- package/dist/setup-plugin-api.js +0 -19
- package/dist/setup-surface-DAy8vDJK.js +0 -1131
|
@@ -1,1029 +0,0 @@
|
|
|
1
|
-
import { d as createClickClackClient, f as isClickClackChannelNameConflict, u as ClickClackHttpError, x as resolveClickClackAccount, y as listClickClackAccountIds } from "./setup-surface-DAy8vDJK.js";
|
|
2
|
-
import { C as truncateDiscussionDisplayTitle, D as reserveDiscussionBindingGeneration, E as recordPendingDiscussionOpen, S as slugifyDiscussionLabel, T as listPendingDiscussionOpens, _ as discussionCredentialFingerprint, b as isDiscussionSessionKey, c as markClickClackDiscussionChannelIdentityRevoked, d as discussionInfoForBinding, f as normalizedServerBaseUrl, g as getClickClackDiscussionBindingStore, h as bindingMatchesSessionIncarnation, l as markClickClackDiscussionChannelRevoked, m as bindingMatchesActiveSessionIncarnation, o as clearClickClackDiscussionChannelRevoked, p as resolveDiscussionBindingAccount, s as isClickClackDiscussionChannelRevoked, u as discussionAccounts, v as discussionExternalRef, w as clearDiscussionBindingGeneration, x as resolveDiscussionLabel, y as fallbackDiscussionLabel } from "./channel-CECIpaS0.js";
|
|
3
|
-
import "./api.js";
|
|
4
|
-
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { registerSessionDiscussionProvider } from "openclaw/plugin-sdk/session-discussion";
|
|
7
|
-
import { createSessionVisibilityChecker } from "openclaw/plugin-sdk/session-visibility";
|
|
8
|
-
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
|
9
|
-
import { textResult } from "openclaw/plugin-sdk/tool-results";
|
|
10
|
-
//#region extensions/clickclack/src/discussions/control-session-url.ts
|
|
11
|
-
const SESSION_UUID_SUFFIX_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/iu;
|
|
12
|
-
const SHORT_SESSION_REF_RE = /^(?:.*-)?([0-9a-f]{8,32})$/iu;
|
|
13
|
-
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/iu;
|
|
14
|
-
const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/giu;
|
|
15
|
-
const FIXED_RESERVED_SESSION_RESTS = /* @__PURE__ */ new Set([
|
|
16
|
-
"main",
|
|
17
|
-
"global",
|
|
18
|
-
"boot",
|
|
19
|
-
"sessions"
|
|
20
|
-
]);
|
|
21
|
-
const SESSION_SLUG_MAX_LENGTH = 48;
|
|
22
|
-
const DEFAULT_AGENT_ID = "main";
|
|
23
|
-
const DEFAULT_MAIN_KEY = "main";
|
|
24
|
-
function optionalString(value) {
|
|
25
|
-
return (value?.trim() ?? "") || null;
|
|
26
|
-
}
|
|
27
|
-
function normalizeAgentId$1(value) {
|
|
28
|
-
const trimmed = value.trim();
|
|
29
|
-
if (!trimmed) return DEFAULT_AGENT_ID;
|
|
30
|
-
if (VALID_AGENT_ID_RE.test(trimmed)) return trimmed.toLowerCase();
|
|
31
|
-
return trimmed.toLowerCase().replace(INVALID_AGENT_ID_CHARS_RE, "-").replace(/^-+|-+$/gu, "").slice(0, 64) || DEFAULT_AGENT_ID;
|
|
32
|
-
}
|
|
33
|
-
function normalizeBasePath(basePath) {
|
|
34
|
-
const trimmed = basePath.trim().replace(/^\/+|\/+$/gu, "");
|
|
35
|
-
return trimmed ? `/${trimmed}` : "";
|
|
36
|
-
}
|
|
37
|
-
function agentSessionKeyParts(sessionKey) {
|
|
38
|
-
const parts = sessionKey.split(":");
|
|
39
|
-
if (parts.length < 3 || parts[0]?.toLowerCase() !== "agent") return null;
|
|
40
|
-
const agentId = optionalString(parts[1]);
|
|
41
|
-
const restSegments = parts.slice(2);
|
|
42
|
-
if (!agentId || restSegments.some((segment) => !segment)) return null;
|
|
43
|
-
return {
|
|
44
|
-
agentId: normalizeAgentId$1(agentId),
|
|
45
|
-
rest: restSegments.join(":")
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
function encodePathSegment(segment) {
|
|
49
|
-
if (segment === ".") return "~dot";
|
|
50
|
-
if (segment === "..") return "~dotdot";
|
|
51
|
-
const encoded = encodeURIComponent(segment).replaceAll(".", "%2E");
|
|
52
|
-
return encoded.startsWith("~") ? `~${encoded}` : encoded;
|
|
53
|
-
}
|
|
54
|
-
function isReservedSessionRest(rest, mainKey) {
|
|
55
|
-
const normalized = rest.toLowerCase();
|
|
56
|
-
return FIXED_RESERVED_SESSION_RESTS.has(normalized) || normalized === (optionalString(mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY);
|
|
57
|
-
}
|
|
58
|
-
function sessionSlug(displayName) {
|
|
59
|
-
const tokens = (displayName ?? "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").split("-").filter(Boolean);
|
|
60
|
-
while (tokens.length > 0 && /^[0-9a-f]+$/u.test(tokens.at(-1) ?? "")) tokens.pop();
|
|
61
|
-
return tokens.join("-").slice(0, SESSION_SLUG_MAX_LENGTH).replace(/-+$/gu, "");
|
|
62
|
-
}
|
|
63
|
-
function sessionKeyUuid(sessionKey) {
|
|
64
|
-
const uuid = (agentSessionKeyParts(sessionKey)?.rest)?.match(SESSION_UUID_SUFFIX_RE)?.[1];
|
|
65
|
-
return uuid ? uuid.toLowerCase().replaceAll("-", "") : null;
|
|
66
|
-
}
|
|
67
|
-
function sessionPath(params) {
|
|
68
|
-
const rawKey = optionalString(params.sessionKey);
|
|
69
|
-
const parsed = rawKey ? agentSessionKeyParts(rawKey) : null;
|
|
70
|
-
const fallbackAgentId = optionalString(params.fallbackAgentId);
|
|
71
|
-
const agentId = parsed?.agentId ?? (fallbackAgentId ? normalizeAgentId$1(fallbackAgentId) : null);
|
|
72
|
-
if (!rawKey || !agentId || !parsed && rawKey.toLowerCase().startsWith("agent:")) return null;
|
|
73
|
-
const namespace = `${normalizeBasePath(params.basePath)}/chat`;
|
|
74
|
-
const encodedAgentId = encodePathSegment(agentId);
|
|
75
|
-
const rest = parsed?.rest ?? rawKey;
|
|
76
|
-
const normalizedRest = rest.toLowerCase();
|
|
77
|
-
const mainKey = optionalString(params.mainKey)?.toLowerCase() ?? DEFAULT_MAIN_KEY;
|
|
78
|
-
if (!parsed && normalizedRest === DEFAULT_MAIN_KEY || normalizedRest === mainKey || normalizedRest === "global") return `${namespace}/${encodedAgentId}`;
|
|
79
|
-
const uuid = parsed ? sessionKeyUuid(rawKey) : null;
|
|
80
|
-
if (uuid) {
|
|
81
|
-
let length = 8;
|
|
82
|
-
const slug = sessionSlug(params.displayName);
|
|
83
|
-
let sessionRef = `${slug ? `${slug}-` : ""}${uuid.slice(0, length)}`;
|
|
84
|
-
while (length < uuid.length && isReservedSessionRest(sessionRef, params.mainKey)) {
|
|
85
|
-
length += 1;
|
|
86
|
-
sessionRef = `${slug ? `${slug}-` : ""}${uuid.slice(0, length)}`;
|
|
87
|
-
}
|
|
88
|
-
return isReservedSessionRest(sessionRef, params.mainKey) ? null : `${namespace}/${encodedAgentId}/${sessionRef}`;
|
|
89
|
-
}
|
|
90
|
-
const segments = rest.split(":");
|
|
91
|
-
if (segments.some((segment) => !segment)) return null;
|
|
92
|
-
if (segments.length === 1) {
|
|
93
|
-
const segment = segments[0] ?? "";
|
|
94
|
-
if (!isReservedSessionRest(segment, params.mainKey) && SHORT_SESSION_REF_RE.test(segment)) return `${namespace}/${encodedAgentId}/~key/${encodePathSegment(segment)}`;
|
|
95
|
-
}
|
|
96
|
-
return `${namespace}/${encodedAgentId}/${segments.map(encodePathSegment).join("/")}`;
|
|
97
|
-
}
|
|
98
|
-
function controlSessionUrl(baseUrl, sessionKey, fallbackAgentId, mainKey, displayName) {
|
|
99
|
-
if (!baseUrl) return;
|
|
100
|
-
const url = new URL(baseUrl);
|
|
101
|
-
const path = sessionPath({
|
|
102
|
-
sessionKey,
|
|
103
|
-
fallbackAgentId,
|
|
104
|
-
basePath: url.pathname,
|
|
105
|
-
displayName,
|
|
106
|
-
mainKey
|
|
107
|
-
});
|
|
108
|
-
if (!path) return;
|
|
109
|
-
url.pathname = path;
|
|
110
|
-
url.hash = "";
|
|
111
|
-
return url.toString();
|
|
112
|
-
}
|
|
113
|
-
//#endregion
|
|
114
|
-
//#region extensions/clickclack/src/discussions/history-format.ts
|
|
115
|
-
function discussionRecordJson(value) {
|
|
116
|
-
return JSON.stringify(value).replace(/[\u0085\u2028\u2029]/gu, (separator) => `\\u${separator.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
117
|
-
}
|
|
118
|
-
/** Renders channel history as line-per-message records for the discussion tool. */
|
|
119
|
-
function formatDiscussionHistory(history) {
|
|
120
|
-
const text = history.messages.map((message) => {
|
|
121
|
-
const author = message.author?.display_name || message.author?.handle || message.author_id || "Unknown";
|
|
122
|
-
return `timestamp=${discussionRecordJson(message.created_at)} [Author ${discussionRecordJson(author)} id=${discussionRecordJson(message.author_id)}] text=${discussionRecordJson(message.body)}`;
|
|
123
|
-
}).join("\n");
|
|
124
|
-
const truncationNote = history.truncated ? "\n[History scan reached its safety bound; older active threads may be omitted.]" : "";
|
|
125
|
-
return text ? `${text}${truncationNote}` : "";
|
|
126
|
-
}
|
|
127
|
-
//#endregion
|
|
128
|
-
//#region extensions/clickclack/src/discussions/installation.ts
|
|
129
|
-
const INSTALLATION_NAMESPACE = "discussion-installation";
|
|
130
|
-
const INSTALLATION_KEY = "current";
|
|
131
|
-
/** Returns the durable installation namespace used in server-visible ownership refs. */
|
|
132
|
-
function getClickClackDiscussionInstallationId(runtime) {
|
|
133
|
-
const store = runtime.state.openSyncKeyedStore({
|
|
134
|
-
namespace: INSTALLATION_NAMESPACE,
|
|
135
|
-
maxEntries: 1,
|
|
136
|
-
overflowPolicy: "reject-new"
|
|
137
|
-
});
|
|
138
|
-
const existing = store.lookup(INSTALLATION_KEY)?.id;
|
|
139
|
-
if (existing) return existing;
|
|
140
|
-
const id = randomUUID();
|
|
141
|
-
store.registerIfAbsent(INSTALLATION_KEY, { id });
|
|
142
|
-
return store.lookup(INSTALLATION_KEY)?.id ?? id;
|
|
143
|
-
}
|
|
144
|
-
//#endregion
|
|
145
|
-
//#region extensions/clickclack/src/discussions/reconcile-scheduler.ts
|
|
146
|
-
const RECONCILE_DEBOUNCE_MS = 250;
|
|
147
|
-
const RECONCILE_RETRY_MS = 1e3;
|
|
148
|
-
const RECONCILE_RETRY_MAX_MS = 6e4;
|
|
149
|
-
/**
|
|
150
|
-
* Per-session debounced reconcile scheduling with failure backoff.
|
|
151
|
-
* Contracts: bursts coalesce onto the earliest scheduled run (steady event
|
|
152
|
-
* traffic can never postpone one); a failed run sets a not-before floor with
|
|
153
|
-
* growing backoff that later events cannot undercut; events during an
|
|
154
|
-
* in-flight run collapse into one follow-up scheduled after settlement; and
|
|
155
|
-
* `supersede()` invalidates callbacks from earlier activations so stop/start
|
|
156
|
-
* cycles cannot corrupt the bookkeeping of the next one.
|
|
157
|
-
*/
|
|
158
|
-
var DiscussionReconcileScheduler = class {
|
|
159
|
-
#hooks;
|
|
160
|
-
#timers = /* @__PURE__ */ new Map();
|
|
161
|
-
#retryState = /* @__PURE__ */ new Map();
|
|
162
|
-
#inFlight = /* @__PURE__ */ new Set();
|
|
163
|
-
#followUps = /* @__PURE__ */ new Set();
|
|
164
|
-
#generation = 0;
|
|
165
|
-
constructor(hooks) {
|
|
166
|
-
this.#hooks = hooks;
|
|
167
|
-
}
|
|
168
|
-
/** Invalidates callbacks scheduled by prior activations. */
|
|
169
|
-
supersede() {
|
|
170
|
-
this.#generation += 1;
|
|
171
|
-
}
|
|
172
|
-
clear() {
|
|
173
|
-
for (const scheduled of this.#timers.values()) clearTimeout(scheduled.timer);
|
|
174
|
-
this.#timers.clear();
|
|
175
|
-
this.#retryState.clear();
|
|
176
|
-
this.#inFlight.clear();
|
|
177
|
-
this.#followUps.clear();
|
|
178
|
-
}
|
|
179
|
-
schedule(sessionKey, delayMs = RECONCILE_DEBOUNCE_MS) {
|
|
180
|
-
if (!this.#hooks.shouldSchedule(sessionKey)) return;
|
|
181
|
-
if (this.#inFlight.has(sessionKey)) {
|
|
182
|
-
this.#followUps.add(sessionKey);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const retry = this.#retryState.get(sessionKey);
|
|
186
|
-
const fireAt = Math.max(Date.now() + delayMs, retry?.notBefore ?? 0);
|
|
187
|
-
const existing = this.#timers.get(sessionKey);
|
|
188
|
-
if (existing) {
|
|
189
|
-
if (existing.fireAt <= fireAt) return;
|
|
190
|
-
clearTimeout(existing.timer);
|
|
191
|
-
}
|
|
192
|
-
const generation = this.#generation;
|
|
193
|
-
const timer = setTimeout(() => {
|
|
194
|
-
if (generation !== this.#generation) return;
|
|
195
|
-
this.#timers.delete(sessionKey);
|
|
196
|
-
this.#inFlight.add(sessionKey);
|
|
197
|
-
this.#hooks.run(sessionKey).then(() => {
|
|
198
|
-
if (generation !== this.#generation) return;
|
|
199
|
-
this.#retryState.delete(sessionKey);
|
|
200
|
-
}, (error) => {
|
|
201
|
-
if (generation !== this.#generation || !this.#hooks.shouldSchedule(sessionKey)) return;
|
|
202
|
-
const previous = this.#retryState.get(sessionKey);
|
|
203
|
-
const nextRetryMs = Math.min(previous === void 0 ? RECONCILE_RETRY_MS : previous.delayMs * 2, RECONCILE_RETRY_MAX_MS);
|
|
204
|
-
this.#retryState.set(sessionKey, {
|
|
205
|
-
delayMs: nextRetryMs,
|
|
206
|
-
notBefore: Date.now() + nextRetryMs
|
|
207
|
-
});
|
|
208
|
-
this.#hooks.warn(`discussion event reconcile failed for ${sessionKey}; retrying in ${nextRetryMs}ms: ${String(error)}`);
|
|
209
|
-
this.#followUps.add(sessionKey);
|
|
210
|
-
}).finally(() => {
|
|
211
|
-
if (generation !== this.#generation) return;
|
|
212
|
-
this.#inFlight.delete(sessionKey);
|
|
213
|
-
if (this.#followUps.delete(sessionKey)) this.schedule(sessionKey);
|
|
214
|
-
});
|
|
215
|
-
}, Math.max(fireAt - Date.now(), 0));
|
|
216
|
-
timer.unref?.();
|
|
217
|
-
this.#timers.set(sessionKey, {
|
|
218
|
-
fireAt,
|
|
219
|
-
timer
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
//#endregion
|
|
224
|
-
//#region extensions/clickclack/src/discussions/service-open.ts
|
|
225
|
-
const CHANNEL_NAME_MUTATION_ATTEMPTS$1 = 4;
|
|
226
|
-
function isDefinitiveNoCreateHttpError(error) {
|
|
227
|
-
if (!(error instanceof ClickClackHttpError) || error.status < 400 || error.status >= 500) return false;
|
|
228
|
-
return ![
|
|
229
|
-
408,
|
|
230
|
-
409,
|
|
231
|
-
425,
|
|
232
|
-
429
|
|
233
|
-
].includes(error.status);
|
|
234
|
-
}
|
|
235
|
-
async function resolveAvailableChannelName(params) {
|
|
236
|
-
const desired = slugifyDiscussionLabel(params.label, params.sessionKey);
|
|
237
|
-
const channels = params.channels ?? await params.client.channels(params.workspaceId);
|
|
238
|
-
const occupied = new Set(channels.filter((channel) => channel.id !== params.ownChannelId).map((channel) => channel.name));
|
|
239
|
-
if (!occupied.has(desired)) return desired;
|
|
240
|
-
for (let suffix = 2; suffix <= 20; suffix += 1) {
|
|
241
|
-
const candidate = `${desired}-${suffix}`;
|
|
242
|
-
if (!occupied.has(candidate)) return candidate;
|
|
243
|
-
}
|
|
244
|
-
const fallback = fallbackDiscussionLabel(params.sessionKey, params.agentId);
|
|
245
|
-
if (!occupied.has(fallback)) return fallback;
|
|
246
|
-
for (let suffix = 2;; suffix += 1) {
|
|
247
|
-
const candidate = `${fallback}-${suffix}`;
|
|
248
|
-
if (!occupied.has(candidate)) return candidate;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
function assertChannelPatch(channel, patch) {
|
|
252
|
-
for (const key of [
|
|
253
|
-
"archived",
|
|
254
|
-
"external_managed",
|
|
255
|
-
"external_ref",
|
|
256
|
-
"external_url",
|
|
257
|
-
"display_title",
|
|
258
|
-
"name",
|
|
259
|
-
"sidebar_section"
|
|
260
|
-
]) {
|
|
261
|
-
const expected = patch[key];
|
|
262
|
-
if (expected === void 0) continue;
|
|
263
|
-
if (key === "display_title" && !(key in channel)) continue;
|
|
264
|
-
if ((key === "external_ref" || key === "external_url" || key === "sidebar_section" ? channel[key] ?? "" : channel[key]) !== expected) throw new Error(`ClickClack channel update did not apply ${key}`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
function assertManagedChannelContract(channel, expected) {
|
|
268
|
-
const displayTitleMatches = !("display_title" in channel) || channel.display_title === expected.displayTitle;
|
|
269
|
-
if (channel.external_managed !== true || (channel.external_ref ?? "") !== (expected.externalRef ?? "") || (channel.sidebar_section ?? "") !== (expected.section ?? "") || (channel.external_url ?? "") !== (expected.externalUrl ?? "") || !displayTitleMatches) throw new Error(`ClickClack server does not support the managed discussion channel contract for ${expected.sessionKey}`);
|
|
270
|
-
}
|
|
271
|
-
function assertManagedChannelListContract(channels) {
|
|
272
|
-
if (channels.some((channel) => typeof channel.external_managed !== "boolean" || channel.external_ref !== void 0 && typeof channel.external_ref !== "string" || channel.external_url !== void 0 && typeof channel.external_url !== "string" || channel.sidebar_section !== void 0 && typeof channel.sidebar_section !== "string")) throw new Error("ClickClack server does not advertise the managed discussion contract");
|
|
273
|
-
}
|
|
274
|
-
async function openClickClackDiscussionBinding(params) {
|
|
275
|
-
const { account, runtime, sessionKey, store } = params;
|
|
276
|
-
const entry = runtime.agent.session.getSessionEntry({
|
|
277
|
-
sessionKey,
|
|
278
|
-
readConsistency: "latest"
|
|
279
|
-
});
|
|
280
|
-
if (!entry) return;
|
|
281
|
-
if (!entry.sessionId?.trim()) throw new Error("OpenClaw session does not yet have a concrete session id");
|
|
282
|
-
const client = params.clientFactory(account);
|
|
283
|
-
const workspace = (await client.workspaces()).find((candidate) => candidate.id === account.discussions.workspace || candidate.slug === account.discussions.workspace || candidate.name === account.discussions.workspace);
|
|
284
|
-
if (!workspace) throw new Error(`ClickClack discussions workspace not found: ${account.discussions.workspace}`);
|
|
285
|
-
if (!workspace.route_id) throw new Error("ClickClack discussions workspace is missing its route id");
|
|
286
|
-
const serverBaseUrl = normalizedServerBaseUrl(account);
|
|
287
|
-
const credentialFingerprint = discussionCredentialFingerprint(account.token);
|
|
288
|
-
const unresolved = listPendingDiscussionOpens(runtime).find((pending) => pending.sessionKey === sessionKey);
|
|
289
|
-
if (unresolved && (unresolved.accountId !== account.accountId || unresolved.credentialFingerprint !== credentialFingerprint || unresolved.sessionId !== entry.sessionId || unresolved.serverBaseUrl !== serverBaseUrl || unresolved.workspaceId !== workspace.id)) {
|
|
290
|
-
await params.reconcilePendingOpen(unresolved);
|
|
291
|
-
if (listPendingDiscussionOpens(runtime).some((pending) => pending.sessionKey === sessionKey)) throw new Error("A previous ClickClack discussion open is still unresolved; restore its credential and retry");
|
|
292
|
-
}
|
|
293
|
-
const config = runtime.config.current();
|
|
294
|
-
const agentId = resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config));
|
|
295
|
-
const fallback = fallbackDiscussionLabel(sessionKey, agentId);
|
|
296
|
-
const label = resolveDiscussionLabel(entry, sessionKey, agentId);
|
|
297
|
-
const displayTitle = label === fallback ? "" : truncateDiscussionDisplayTitle(label);
|
|
298
|
-
const section = entry.category?.trim() || account.discussions.section;
|
|
299
|
-
const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? "main", config.session?.mainKey, label);
|
|
300
|
-
const archived = entry.archivedAt !== void 0;
|
|
301
|
-
return await params.withChannelMutationLock(async () => {
|
|
302
|
-
if (!store.hasCapacity(sessionKey)) throw new Error("ClickClack discussion binding capacity is exhausted");
|
|
303
|
-
let channels = await client.channels(workspace.id);
|
|
304
|
-
assertManagedChannelListContract(channels);
|
|
305
|
-
const destinationIdentity = [serverBaseUrl, workspace.id].join("\0");
|
|
306
|
-
const bindingGeneration = reserveDiscussionBindingGeneration({
|
|
307
|
-
runtime,
|
|
308
|
-
sessionKey,
|
|
309
|
-
destinationIdentity,
|
|
310
|
-
createGeneration: params.bindingGenerationFactory
|
|
311
|
-
});
|
|
312
|
-
const externalRef = discussionExternalRef(params.installationId, sessionKey, entry.sessionId, destinationIdentity, bindingGeneration);
|
|
313
|
-
let adopted;
|
|
314
|
-
let managedFields;
|
|
315
|
-
let resolved;
|
|
316
|
-
for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1; attempt += 1) {
|
|
317
|
-
adopted = channels.find((candidate) => candidate.external_managed === true && candidate.external_ref === externalRef);
|
|
318
|
-
managedFields = {
|
|
319
|
-
name: await resolveAvailableChannelName({
|
|
320
|
-
client,
|
|
321
|
-
workspaceId: workspace.id,
|
|
322
|
-
label,
|
|
323
|
-
sessionKey,
|
|
324
|
-
agentId,
|
|
325
|
-
channels,
|
|
326
|
-
ownChannelId: adopted?.id
|
|
327
|
-
}),
|
|
328
|
-
external_managed: true,
|
|
329
|
-
external_ref: externalRef,
|
|
330
|
-
external_url: externalUrl ?? "",
|
|
331
|
-
sidebar_section: section,
|
|
332
|
-
display_title: displayTitle
|
|
333
|
-
};
|
|
334
|
-
recordPendingDiscussionOpen({
|
|
335
|
-
runtime,
|
|
336
|
-
sessionKey,
|
|
337
|
-
generation: bindingGeneration,
|
|
338
|
-
pending: {
|
|
339
|
-
accountId: account.accountId,
|
|
340
|
-
serverBaseUrl,
|
|
341
|
-
workspaceId: workspace.id,
|
|
342
|
-
sessionId: entry.sessionId,
|
|
343
|
-
externalRef,
|
|
344
|
-
credentialFingerprint
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
params.ensureTimer();
|
|
348
|
-
try {
|
|
349
|
-
if (adopted) {
|
|
350
|
-
markClickClackDiscussionChannelIdentityRevoked({
|
|
351
|
-
runtime,
|
|
352
|
-
accountId: account.accountId,
|
|
353
|
-
serverBaseUrl,
|
|
354
|
-
channelId: adopted.id
|
|
355
|
-
});
|
|
356
|
-
resolved = await client.updateChannel(adopted.id, {
|
|
357
|
-
...managedFields,
|
|
358
|
-
archived
|
|
359
|
-
});
|
|
360
|
-
} else {
|
|
361
|
-
resolved = await client.createChannel(workspace.id, {
|
|
362
|
-
...managedFields,
|
|
363
|
-
kind: "public"
|
|
364
|
-
});
|
|
365
|
-
markClickClackDiscussionChannelIdentityRevoked({
|
|
366
|
-
runtime,
|
|
367
|
-
accountId: account.accountId,
|
|
368
|
-
serverBaseUrl,
|
|
369
|
-
channelId: resolved.id
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
break;
|
|
373
|
-
} catch (error) {
|
|
374
|
-
if (isClickClackChannelNameConflict(error) && attempt < CHANNEL_NAME_MUTATION_ATTEMPTS$1 - 1) {
|
|
375
|
-
channels = await client.channels(workspace.id);
|
|
376
|
-
assertManagedChannelListContract(channels);
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
const definitiveNoCreate = isDefinitiveNoCreateHttpError(error);
|
|
380
|
-
try {
|
|
381
|
-
const relisted = await client.channels(workspace.id);
|
|
382
|
-
assertManagedChannelListContract(relisted);
|
|
383
|
-
const recovered = relisted.find((candidate) => candidate.external_managed === true && candidate.external_ref === externalRef);
|
|
384
|
-
if (recovered) {
|
|
385
|
-
adopted = recovered;
|
|
386
|
-
markClickClackDiscussionChannelIdentityRevoked({
|
|
387
|
-
runtime,
|
|
388
|
-
accountId: account.accountId,
|
|
389
|
-
serverBaseUrl,
|
|
390
|
-
channelId: recovered.id
|
|
391
|
-
});
|
|
392
|
-
resolved = await client.updateChannel(recovered.id, {
|
|
393
|
-
...managedFields,
|
|
394
|
-
archived
|
|
395
|
-
});
|
|
396
|
-
break;
|
|
397
|
-
}
|
|
398
|
-
if (definitiveNoCreate) clearDiscussionBindingGeneration({
|
|
399
|
-
runtime,
|
|
400
|
-
sessionKey,
|
|
401
|
-
expectedGeneration: bindingGeneration
|
|
402
|
-
});
|
|
403
|
-
} catch {
|
|
404
|
-
if (definitiveNoCreate && !adopted) clearDiscussionBindingGeneration({
|
|
405
|
-
runtime,
|
|
406
|
-
sessionKey,
|
|
407
|
-
expectedGeneration: bindingGeneration
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
throw error;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
if (!resolved || !managedFields) throw new Error("ClickClack discussion channel name retries were exhausted");
|
|
414
|
-
try {
|
|
415
|
-
assertManagedChannelContract(resolved, {
|
|
416
|
-
sessionKey,
|
|
417
|
-
externalRef,
|
|
418
|
-
section,
|
|
419
|
-
externalUrl,
|
|
420
|
-
displayTitle
|
|
421
|
-
});
|
|
422
|
-
if (adopted) assertChannelPatch(resolved, {
|
|
423
|
-
...managedFields,
|
|
424
|
-
archived
|
|
425
|
-
});
|
|
426
|
-
} catch (error) {
|
|
427
|
-
try {
|
|
428
|
-
assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
|
|
429
|
-
clearDiscussionBindingGeneration({
|
|
430
|
-
runtime,
|
|
431
|
-
sessionKey,
|
|
432
|
-
expectedGeneration: bindingGeneration
|
|
433
|
-
});
|
|
434
|
-
} catch (archiveError) {
|
|
435
|
-
params.warn(`failed to archive incompatible discussion channel ${resolved.id}: ${String(archiveError)}`);
|
|
436
|
-
}
|
|
437
|
-
throw error;
|
|
438
|
-
}
|
|
439
|
-
if (!resolved.route_id) {
|
|
440
|
-
try {
|
|
441
|
-
assertChannelPatch(await client.updateChannel(resolved.id, { archived: true }), { archived: true });
|
|
442
|
-
clearDiscussionBindingGeneration({
|
|
443
|
-
runtime,
|
|
444
|
-
sessionKey,
|
|
445
|
-
expectedGeneration: bindingGeneration
|
|
446
|
-
});
|
|
447
|
-
} catch (archiveError) {
|
|
448
|
-
params.warn(`failed to archive route-less discussion channel ${resolved.id}: ${String(archiveError)}`);
|
|
449
|
-
}
|
|
450
|
-
throw new Error("ClickClack discussion channel is missing its route id");
|
|
451
|
-
}
|
|
452
|
-
let channel = resolved;
|
|
453
|
-
if (!adopted && archived) {
|
|
454
|
-
channel = await client.updateChannel(resolved.id, { archived: true });
|
|
455
|
-
assertChannelPatch(channel, { archived: true });
|
|
456
|
-
}
|
|
457
|
-
const nextBinding = {
|
|
458
|
-
accountId: account.accountId,
|
|
459
|
-
agentId,
|
|
460
|
-
sessionId: entry.sessionId,
|
|
461
|
-
serverBaseUrl,
|
|
462
|
-
credentialFingerprint,
|
|
463
|
-
externalRef,
|
|
464
|
-
externalUrl: externalUrl ?? "",
|
|
465
|
-
workspaceRef: account.discussions.workspace,
|
|
466
|
-
workspaceId: workspace.id,
|
|
467
|
-
channelId: channel.id,
|
|
468
|
-
channelRouteId: channel.route_id,
|
|
469
|
-
workspaceRouteId: workspace.route_id,
|
|
470
|
-
section,
|
|
471
|
-
archived,
|
|
472
|
-
label,
|
|
473
|
-
displayTitle: "display_title" in channel ? channel.display_title : void 0
|
|
474
|
-
};
|
|
475
|
-
const currentEntry = runtime.agent.session.getSessionEntry({
|
|
476
|
-
sessionKey,
|
|
477
|
-
readConsistency: "latest"
|
|
478
|
-
});
|
|
479
|
-
if (!currentEntry || currentEntry.sessionId !== entry.sessionId) {
|
|
480
|
-
try {
|
|
481
|
-
assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
|
|
482
|
-
clearDiscussionBindingGeneration({
|
|
483
|
-
runtime,
|
|
484
|
-
sessionKey,
|
|
485
|
-
expectedGeneration: bindingGeneration
|
|
486
|
-
});
|
|
487
|
-
} catch (archiveError) {
|
|
488
|
-
params.warn(`failed to archive superseded discussion channel ${channel.id}: ${String(archiveError)}`);
|
|
489
|
-
}
|
|
490
|
-
throw new Error("OpenClaw session changed while opening its ClickClack discussion");
|
|
491
|
-
}
|
|
492
|
-
try {
|
|
493
|
-
store.set(sessionKey, nextBinding);
|
|
494
|
-
} catch (error) {
|
|
495
|
-
try {
|
|
496
|
-
assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
|
|
497
|
-
clearDiscussionBindingGeneration({
|
|
498
|
-
runtime,
|
|
499
|
-
sessionKey,
|
|
500
|
-
expectedGeneration: bindingGeneration
|
|
501
|
-
});
|
|
502
|
-
} catch (archiveError) {
|
|
503
|
-
params.warn(`failed to archive unbound discussion channel ${channel.id}: ${String(archiveError)}`);
|
|
504
|
-
}
|
|
505
|
-
throw error;
|
|
506
|
-
}
|
|
507
|
-
params.finalizePendingBinding(sessionKey, nextBinding);
|
|
508
|
-
return nextBinding;
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
//#endregion
|
|
512
|
-
//#region extensions/clickclack/src/discussions/service.ts
|
|
513
|
-
const RECONCILE_INTERVAL_MS = 6e4;
|
|
514
|
-
const CHANNEL_NAME_MUTATION_ATTEMPTS = 4;
|
|
515
|
-
var ClickClackDiscussionService = class {
|
|
516
|
-
#runtime;
|
|
517
|
-
#store;
|
|
518
|
-
#clientFactory;
|
|
519
|
-
#installationId;
|
|
520
|
-
#bindingGenerationFactory;
|
|
521
|
-
#timersEnabled;
|
|
522
|
-
#sessionLocks = /* @__PURE__ */ new Map();
|
|
523
|
-
#reconcileScheduler = new DiscussionReconcileScheduler({
|
|
524
|
-
shouldSchedule: (sessionKey) => !this.#closed && (this.#store.get(sessionKey) !== void 0 || listPendingDiscussionOpens(this.#runtime).some((pending) => pending.sessionKey === sessionKey)),
|
|
525
|
-
run: async (sessionKey) => await this.reconcile(sessionKey),
|
|
526
|
-
warn: (message) => this.#logger().warn(message)
|
|
527
|
-
});
|
|
528
|
-
#channelMutationLock = Promise.resolve();
|
|
529
|
-
#timer;
|
|
530
|
-
#reconcileAllPromise;
|
|
531
|
-
#unsubscribeSessionsChanged;
|
|
532
|
-
#closed = false;
|
|
533
|
-
constructor(runtime, options = {}) {
|
|
534
|
-
this.#runtime = runtime;
|
|
535
|
-
this.#store = getClickClackDiscussionBindingStore(runtime);
|
|
536
|
-
this.#clientFactory = options.clientFactory ?? ((account) => createClickClackClient({
|
|
537
|
-
baseUrl: account.apiEndpoint,
|
|
538
|
-
token: account.token
|
|
539
|
-
}));
|
|
540
|
-
this.#installationId = options.installationId ?? getClickClackDiscussionInstallationId(runtime);
|
|
541
|
-
this.#bindingGenerationFactory = options.bindingGenerationFactory ?? randomUUID;
|
|
542
|
-
this.#timersEnabled = options.startTimer !== false;
|
|
543
|
-
this.provider = {
|
|
544
|
-
id: "clickclack",
|
|
545
|
-
info: async ({ sessionKey }) => await this.info(sessionKey),
|
|
546
|
-
open: async ({ sessionKey }) => await this.open(sessionKey)
|
|
547
|
-
};
|
|
548
|
-
if (options.gatewayEvents) this.bindGatewayEvents(options.gatewayEvents);
|
|
549
|
-
if (this.#timersEnabled) this.#ensureTimer();
|
|
550
|
-
}
|
|
551
|
-
bindGatewayEvents(gatewayEvents) {
|
|
552
|
-
this.#unsubscribeSessionsChanged?.();
|
|
553
|
-
this.#closed = false;
|
|
554
|
-
this.#reconcileScheduler.supersede();
|
|
555
|
-
this.#unsubscribeSessionsChanged = gatewayEvents?.onSessionsChanged((event) => {
|
|
556
|
-
this.#reconcileScheduler.schedule(event.sessionKey);
|
|
557
|
-
});
|
|
558
|
-
for (const sessionKey of this.#listReconcileSessionKeys()) this.#reconcileScheduler.schedule(sessionKey, 0);
|
|
559
|
-
if (this.#timersEnabled) this.#ensureTimer();
|
|
560
|
-
}
|
|
561
|
-
hasEnabledAccount() {
|
|
562
|
-
return discussionAccounts(this.#currentConfig()).length === 1;
|
|
563
|
-
}
|
|
564
|
-
async info(sessionKey) {
|
|
565
|
-
return await this.#withSessionLock(sessionKey, async () => {
|
|
566
|
-
if (discussionAccounts(this.#currentConfig()).length !== 1) return { state: "none" };
|
|
567
|
-
const existing = this.#store.get(sessionKey);
|
|
568
|
-
if (existing) {
|
|
569
|
-
const resolved = await this.#resolveBindingForUse(existing);
|
|
570
|
-
if (resolved.state === "retargeted") {
|
|
571
|
-
this.#revokeAndDeleteBinding(sessionKey, existing);
|
|
572
|
-
return { state: "available" };
|
|
573
|
-
}
|
|
574
|
-
if (resolved.state === "stale") {
|
|
575
|
-
await this.#releaseStaleBinding(sessionKey, existing);
|
|
576
|
-
return { state: "available" };
|
|
577
|
-
}
|
|
578
|
-
if (resolved.state !== "active") return { state: "none" };
|
|
579
|
-
this.#finalizePendingBinding(sessionKey, existing);
|
|
580
|
-
await this.#reconcileBinding(sessionKey, existing, resolved.account);
|
|
581
|
-
const current = this.#store.get(sessionKey);
|
|
582
|
-
if (!current) return { state: this.hasEnabledAccount() ? "available" : "none" };
|
|
583
|
-
return discussionInfoForBinding(current, resolved.account);
|
|
584
|
-
}
|
|
585
|
-
return { state: "available" };
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
async open(sessionKey) {
|
|
589
|
-
return await this.#withSessionLock(sessionKey, async () => {
|
|
590
|
-
const accounts = discussionAccounts(this.#currentConfig());
|
|
591
|
-
if (accounts.length > 1) throw new Error("ClickClack discussions require exactly one enabled discussion account");
|
|
592
|
-
const account = accounts[0];
|
|
593
|
-
if (!account) return { state: "none" };
|
|
594
|
-
const existing = this.#store.get(sessionKey);
|
|
595
|
-
if (existing) {
|
|
596
|
-
const resolved = await this.#resolveBindingForUse(existing);
|
|
597
|
-
if (resolved.state === "retargeted") this.#revokeAndDeleteBinding(sessionKey, existing);
|
|
598
|
-
else if (resolved.state === "stale") await this.#releaseStaleBinding(sessionKey, existing);
|
|
599
|
-
else if (resolved.state === "active") {
|
|
600
|
-
this.#finalizePendingBinding(sessionKey, existing);
|
|
601
|
-
await this.#reconcileBinding(sessionKey, existing, resolved.account);
|
|
602
|
-
const current = this.#store.get(sessionKey);
|
|
603
|
-
if (current) return discussionInfoForBinding(current, resolved.account);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
let binding;
|
|
607
|
-
try {
|
|
608
|
-
binding = await openClickClackDiscussionBinding({
|
|
609
|
-
runtime: this.#runtime,
|
|
610
|
-
store: this.#store,
|
|
611
|
-
account,
|
|
612
|
-
clientFactory: this.#clientFactory,
|
|
613
|
-
installationId: this.#installationId,
|
|
614
|
-
bindingGenerationFactory: this.#bindingGenerationFactory,
|
|
615
|
-
sessionKey,
|
|
616
|
-
ensureTimer: () => this.#ensureTimer(),
|
|
617
|
-
reconcilePendingOpen: async (pending) => await this.#reconcilePendingOpen(pending, { allowRetry: false }),
|
|
618
|
-
withChannelMutationLock: async (run) => await this.#withChannelMutationLock(run),
|
|
619
|
-
finalizePendingBinding: (key, nextBinding) => this.#finalizePendingBinding(key, nextBinding),
|
|
620
|
-
warn: (message) => this.#logger().warn(message)
|
|
621
|
-
});
|
|
622
|
-
} finally {
|
|
623
|
-
this.#ensureTimer();
|
|
624
|
-
}
|
|
625
|
-
if (!binding) return { state: "available" };
|
|
626
|
-
return discussionInfoForBinding(binding, account);
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
async reconcile(sessionKey) {
|
|
630
|
-
try {
|
|
631
|
-
await this.#withSessionLock(sessionKey, async () => {
|
|
632
|
-
const binding = this.#store.get(sessionKey);
|
|
633
|
-
if (binding) await this.#reconcileBinding(sessionKey, binding);
|
|
634
|
-
});
|
|
635
|
-
const pending = listPendingDiscussionOpens(this.#runtime).find((candidate) => candidate.sessionKey === sessionKey);
|
|
636
|
-
if (pending) await this.#reconcilePendingOpen(pending);
|
|
637
|
-
} finally {
|
|
638
|
-
this.#ensureTimer();
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
async reconcileAll() {
|
|
642
|
-
if (this.#reconcileAllPromise) return await this.#reconcileAllPromise;
|
|
643
|
-
this.#reconcileAllPromise = (async () => {
|
|
644
|
-
for (const sessionKey of this.#listReconcileSessionKeys()) try {
|
|
645
|
-
await this.reconcile(sessionKey);
|
|
646
|
-
} catch (error) {
|
|
647
|
-
this.#logger().warn(`discussion reconcile failed for ${sessionKey}: ${String(error)}`);
|
|
648
|
-
}
|
|
649
|
-
})().finally(() => {
|
|
650
|
-
this.#reconcileAllPromise = void 0;
|
|
651
|
-
});
|
|
652
|
-
return await this.#reconcileAllPromise;
|
|
653
|
-
}
|
|
654
|
-
async readLatestMessages(sessionKey, limit) {
|
|
655
|
-
const binding = this.#store.get(sessionKey);
|
|
656
|
-
if (!binding) return { text: "No discussion is bound to this session." };
|
|
657
|
-
const resolved = await this.#resolveBindingForUse(binding);
|
|
658
|
-
if (resolved.state === "retargeted") return { text: "No discussion is bound to this session." };
|
|
659
|
-
if (resolved.state === "stale") return { text: "No discussion is bound to this session." };
|
|
660
|
-
if (resolved.state !== "active") return { text: "No discussion is bound to this session." };
|
|
661
|
-
if (!bindingMatchesSessionIncarnation(this.#runtime, sessionKey, binding)) return { text: "No discussion is bound to this session." };
|
|
662
|
-
if (isClickClackDiscussionChannelRevoked({
|
|
663
|
-
runtime: this.#runtime,
|
|
664
|
-
serverBaseUrl: binding.serverBaseUrl,
|
|
665
|
-
channelId: binding.channelId
|
|
666
|
-
})) return { text: "No discussion is bound to this session." };
|
|
667
|
-
return {
|
|
668
|
-
binding,
|
|
669
|
-
text: formatDiscussionHistory(await this.#clientFactory(resolved.account).latestChannelMessages(binding.channelId, limit)) || "The bound discussion has no messages yet."
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
cleanup() {
|
|
673
|
-
this.#closed = true;
|
|
674
|
-
this.#unsubscribeSessionsChanged?.();
|
|
675
|
-
this.#unsubscribeSessionsChanged = void 0;
|
|
676
|
-
this.#reconcileScheduler.supersede();
|
|
677
|
-
this.#reconcileScheduler.clear();
|
|
678
|
-
if (this.#timer) {
|
|
679
|
-
clearInterval(this.#timer);
|
|
680
|
-
this.#timer = void 0;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
async #reconcileBinding(sessionKey, binding, resolvedAccount) {
|
|
684
|
-
this.#finalizePendingBinding(sessionKey, binding);
|
|
685
|
-
if (isClickClackDiscussionChannelRevoked({
|
|
686
|
-
runtime: this.#runtime,
|
|
687
|
-
serverBaseUrl: binding.serverBaseUrl,
|
|
688
|
-
channelId: binding.channelId
|
|
689
|
-
})) {
|
|
690
|
-
this.#store.delete(sessionKey);
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
const resolved = resolvedAccount ? {
|
|
694
|
-
state: "active",
|
|
695
|
-
account: resolvedAccount
|
|
696
|
-
} : await this.#resolveBindingForUse(binding);
|
|
697
|
-
if (resolved.state === "retargeted") {
|
|
698
|
-
this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
if (resolved.state === "stale") {
|
|
702
|
-
await this.#releaseStaleBinding(sessionKey, binding);
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
if (resolved.state !== "active") return;
|
|
706
|
-
const account = resolved.account;
|
|
707
|
-
if (!account.baseUrl || !account.token) throw new Error(`ClickClack discussion account is no longer configured: ${binding.accountId}`);
|
|
708
|
-
const entry = this.#runtime.agent.session.getSessionEntry({
|
|
709
|
-
sessionKey,
|
|
710
|
-
readConsistency: "latest"
|
|
711
|
-
});
|
|
712
|
-
if (entry && (!binding.sessionId || entry.sessionId !== binding.sessionId)) {
|
|
713
|
-
await this.#archiveAndDeleteBinding(sessionKey, binding, account);
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
const archived = entry ? entry.archivedAt !== void 0 : true;
|
|
717
|
-
const deleted = entry === void 0;
|
|
718
|
-
const fallback = fallbackDiscussionLabel(sessionKey, binding.agentId);
|
|
719
|
-
const label = entry ? resolveDiscussionLabel(entry, sessionKey, binding.agentId) : binding.label;
|
|
720
|
-
const section = entry?.category?.trim() || account.discussions.section;
|
|
721
|
-
const externalUrl = controlSessionUrl(account.discussions.controlUrlBase, sessionKey, account.agentId ?? "main", this.#currentConfig().session?.mainKey, label) ?? "";
|
|
722
|
-
const patch = {};
|
|
723
|
-
if (archived !== binding.archived) patch.archived = archived;
|
|
724
|
-
const labelChanged = label !== binding.label;
|
|
725
|
-
const desiredDisplayTitle = label === fallback ? "" : truncateDiscussionDisplayTitle(label);
|
|
726
|
-
const serverSupportsDisplayTitle = this.#store.entries().some(({ binding: candidate }) => candidate.displayTitle !== void 0 && candidate.serverBaseUrl === binding.serverBaseUrl && candidate.accountId === binding.accountId);
|
|
727
|
-
const shouldBackfillDisplayTitle = desiredDisplayTitle !== "" && binding.displayTitle !== desiredDisplayTitle && serverSupportsDisplayTitle;
|
|
728
|
-
if (labelChanged || shouldBackfillDisplayTitle) patch.display_title = desiredDisplayTitle;
|
|
729
|
-
if (section !== binding.section) patch.sidebar_section = section;
|
|
730
|
-
if (externalUrl !== binding.externalUrl) patch.external_url = externalUrl;
|
|
731
|
-
if (Object.keys(patch).length === 0 && !labelChanged) {
|
|
732
|
-
if (deleted) this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
const client = this.#clientFactory(account);
|
|
736
|
-
let updated;
|
|
737
|
-
if (labelChanged) updated = await this.#withChannelMutationLock(async () => {
|
|
738
|
-
for (let attempt = 0; attempt < CHANNEL_NAME_MUTATION_ATTEMPTS; attempt += 1) {
|
|
739
|
-
patch.name = await resolveAvailableChannelName({
|
|
740
|
-
client,
|
|
741
|
-
workspaceId: binding.workspaceId,
|
|
742
|
-
label,
|
|
743
|
-
sessionKey,
|
|
744
|
-
agentId: binding.agentId,
|
|
745
|
-
ownChannelId: binding.channelId
|
|
746
|
-
});
|
|
747
|
-
try {
|
|
748
|
-
const renamed = await client.updateChannel(binding.channelId, patch);
|
|
749
|
-
assertChannelPatch(renamed, patch);
|
|
750
|
-
return renamed;
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if (!isClickClackChannelNameConflict(error) || attempt === CHANNEL_NAME_MUTATION_ATTEMPTS - 1) throw error;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
throw new Error("ClickClack discussion channel name retries were exhausted");
|
|
756
|
-
});
|
|
757
|
-
else {
|
|
758
|
-
updated = await client.updateChannel(binding.channelId, patch);
|
|
759
|
-
assertChannelPatch(updated, patch);
|
|
760
|
-
}
|
|
761
|
-
if (deleted) {
|
|
762
|
-
this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
this.#store.set(sessionKey, {
|
|
766
|
-
...binding,
|
|
767
|
-
archived,
|
|
768
|
-
externalUrl,
|
|
769
|
-
label,
|
|
770
|
-
section,
|
|
771
|
-
displayTitle: "display_title" in updated ? updated.display_title : void 0
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
async #reconcilePendingOpen(pending, options = {}) {
|
|
775
|
-
const currentBinding = this.#store.get(pending.sessionKey);
|
|
776
|
-
if (currentBinding?.externalRef === pending.externalRef) {
|
|
777
|
-
this.#finalizePendingBinding(pending.sessionKey, currentBinding);
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
const cfg = this.#currentConfig();
|
|
781
|
-
const account = listClickClackAccountIds(cfg).map((accountId) => resolveClickClackAccount({
|
|
782
|
-
cfg,
|
|
783
|
-
accountId
|
|
784
|
-
})).find((candidate) => candidate.configured && normalizedServerBaseUrl(candidate) === pending.serverBaseUrl && discussionCredentialFingerprint(candidate.token) === pending.credentialFingerprint);
|
|
785
|
-
if (!account) return;
|
|
786
|
-
const client = this.#clientFactory(account);
|
|
787
|
-
const entry = this.#runtime.agent.session.getSessionEntry({
|
|
788
|
-
sessionKey: pending.sessionKey,
|
|
789
|
-
readConsistency: "latest"
|
|
790
|
-
});
|
|
791
|
-
const activeAccounts = discussionAccounts(cfg);
|
|
792
|
-
const retryAccount = activeAccounts.length === 1 ? activeAccounts[0] : void 0;
|
|
793
|
-
if (options.allowRetry !== false && entry?.sessionId === pending.sessionId && retryAccount && normalizedServerBaseUrl(retryAccount) === pending.serverBaseUrl && discussionCredentialFingerprint(retryAccount.token) === pending.credentialFingerprint) {
|
|
794
|
-
if ((await this.#clientFactory(retryAccount).workspaces()).find((candidate) => candidate.id === retryAccount.discussions.workspace || candidate.slug === retryAccount.discussions.workspace || candidate.name === retryAccount.discussions.workspace)?.id === pending.workspaceId) {
|
|
795
|
-
await this.open(pending.sessionKey);
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
const channels = await client.channels(pending.workspaceId);
|
|
800
|
-
assertManagedChannelListContract(channels);
|
|
801
|
-
const channel = channels.find((candidate) => candidate.external_managed === true && candidate.external_ref === pending.externalRef);
|
|
802
|
-
if (channel) {
|
|
803
|
-
markClickClackDiscussionChannelIdentityRevoked({
|
|
804
|
-
runtime: this.#runtime,
|
|
805
|
-
accountId: pending.accountId,
|
|
806
|
-
serverBaseUrl: pending.serverBaseUrl,
|
|
807
|
-
channelId: channel.id
|
|
808
|
-
});
|
|
809
|
-
assertChannelPatch(await client.updateChannel(channel.id, { archived: true }), { archived: true });
|
|
810
|
-
}
|
|
811
|
-
clearDiscussionBindingGeneration({
|
|
812
|
-
runtime: this.#runtime,
|
|
813
|
-
sessionKey: pending.sessionKey,
|
|
814
|
-
expectedGeneration: pending.generation
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
async #releaseStaleBinding(sessionKey, binding) {
|
|
818
|
-
clearDiscussionBindingGeneration({
|
|
819
|
-
runtime: this.#runtime,
|
|
820
|
-
sessionKey
|
|
821
|
-
});
|
|
822
|
-
const boundAccount = resolveClickClackAccount({
|
|
823
|
-
cfg: this.#currentConfig(),
|
|
824
|
-
accountId: binding.accountId
|
|
825
|
-
});
|
|
826
|
-
if (!boundAccount.configured || binding.serverBaseUrl !== normalizedServerBaseUrl(boundAccount) || !binding.credentialFingerprint || binding.credentialFingerprint !== discussionCredentialFingerprint(boundAccount.token)) {
|
|
827
|
-
this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
assertChannelPatch(await this.#clientFactory(boundAccount).updateChannel(binding.channelId, { archived: true }), { archived: true });
|
|
831
|
-
this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
832
|
-
}
|
|
833
|
-
async #archiveAndDeleteBinding(sessionKey, binding, account) {
|
|
834
|
-
clearDiscussionBindingGeneration({
|
|
835
|
-
runtime: this.#runtime,
|
|
836
|
-
sessionKey
|
|
837
|
-
});
|
|
838
|
-
assertChannelPatch(await this.#clientFactory(account).updateChannel(binding.channelId, { archived: true }), { archived: true });
|
|
839
|
-
this.#revokeAndDeleteBinding(sessionKey, binding);
|
|
840
|
-
}
|
|
841
|
-
#revokeAndDeleteBinding(sessionKey, binding) {
|
|
842
|
-
markClickClackDiscussionChannelRevoked(this.#runtime, binding);
|
|
843
|
-
this.#store.delete(sessionKey);
|
|
844
|
-
}
|
|
845
|
-
#finalizePendingBinding(sessionKey, binding) {
|
|
846
|
-
const pending = listPendingDiscussionOpens(this.#runtime).find((candidate) => candidate.sessionKey === sessionKey && candidate.externalRef === binding.externalRef);
|
|
847
|
-
if (pending) {
|
|
848
|
-
clearClickClackDiscussionChannelRevoked({
|
|
849
|
-
runtime: this.#runtime,
|
|
850
|
-
serverBaseUrl: binding.serverBaseUrl,
|
|
851
|
-
channelId: binding.channelId
|
|
852
|
-
});
|
|
853
|
-
clearDiscussionBindingGeneration({
|
|
854
|
-
runtime: this.#runtime,
|
|
855
|
-
sessionKey,
|
|
856
|
-
expectedGeneration: pending.generation
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
async #resolveBindingForUse(binding) {
|
|
861
|
-
const resolved = resolveDiscussionBindingAccount(this.#currentConfig(), binding);
|
|
862
|
-
if (resolved.state !== "active") return resolved;
|
|
863
|
-
return (await this.#clientFactory(resolved.account).workspaces()).find((candidate) => candidate.id === resolved.account.discussions.workspace || candidate.slug === resolved.account.discussions.workspace || candidate.name === resolved.account.discussions.workspace)?.id === binding.workspaceId ? resolved : { state: "retargeted" };
|
|
864
|
-
}
|
|
865
|
-
#currentConfig() {
|
|
866
|
-
return this.#runtime.config.current();
|
|
867
|
-
}
|
|
868
|
-
#ensureTimer() {
|
|
869
|
-
const hasPendingOpens = listPendingDiscussionOpens(this.#runtime).length > 0;
|
|
870
|
-
const needsBindingPoll = this.#unsubscribeSessionsChanged === void 0 && this.#store.entries().length > 0;
|
|
871
|
-
if (this.#closed || !hasPendingOpens && !needsBindingPoll) {
|
|
872
|
-
if (this.#timer) {
|
|
873
|
-
clearInterval(this.#timer);
|
|
874
|
-
this.#timer = void 0;
|
|
875
|
-
}
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
if (!this.#timersEnabled || this.#timer) return;
|
|
879
|
-
this.#timer = setInterval(() => {
|
|
880
|
-
this.reconcileAll().catch((error) => {
|
|
881
|
-
this.#logger().warn(`discussion reconcile pass failed: ${String(error)}`);
|
|
882
|
-
}).finally(() => {
|
|
883
|
-
this.#ensureTimer();
|
|
884
|
-
});
|
|
885
|
-
}, RECONCILE_INTERVAL_MS);
|
|
886
|
-
this.#timer.unref?.();
|
|
887
|
-
}
|
|
888
|
-
#listReconcileSessionKeys() {
|
|
889
|
-
return /* @__PURE__ */ new Set([...this.#store.entries().map(({ sessionKey }) => sessionKey), ...listPendingDiscussionOpens(this.#runtime).map(({ sessionKey }) => sessionKey)]);
|
|
890
|
-
}
|
|
891
|
-
#logger() {
|
|
892
|
-
return this.#runtime.logging.getChildLogger({
|
|
893
|
-
plugin: "clickclack",
|
|
894
|
-
feature: "discussions"
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
async #withSessionLock(sessionKey, run) {
|
|
898
|
-
const current = (this.#sessionLocks.get(sessionKey) ?? Promise.resolve()).catch(() => void 0).then(run);
|
|
899
|
-
this.#sessionLocks.set(sessionKey, current);
|
|
900
|
-
try {
|
|
901
|
-
return await current;
|
|
902
|
-
} finally {
|
|
903
|
-
if (this.#sessionLocks.get(sessionKey) === current) this.#sessionLocks.delete(sessionKey);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
async #withChannelMutationLock(run) {
|
|
907
|
-
const current = this.#channelMutationLock.catch(() => void 0).then(run);
|
|
908
|
-
this.#channelMutationLock = current;
|
|
909
|
-
return await current;
|
|
910
|
-
}
|
|
911
|
-
};
|
|
912
|
-
//#endregion
|
|
913
|
-
//#region extensions/clickclack/src/discussions/tool-policy.ts
|
|
914
|
-
const TARGETED_SESSION_TOOLS = /* @__PURE__ */ new Set([
|
|
915
|
-
"sessions_history",
|
|
916
|
-
"sessions_send",
|
|
917
|
-
"session_status"
|
|
918
|
-
]);
|
|
919
|
-
function blockedResult() {
|
|
920
|
-
return {
|
|
921
|
-
block: true,
|
|
922
|
-
blockReason: `ClickClack discussion sessions may use ${[...TARGETED_SESSION_TOOLS].join(", ")} only with their attached main session.`
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
function isClickClackDiscussionSessionTarget(params) {
|
|
926
|
-
const matched = getClickClackDiscussionBindingStore(params.runtime).getByDiscussionSession(params.requesterSessionKey);
|
|
927
|
-
if (matched && matched.sessionKey === params.targetSessionKey && !isClickClackDiscussionChannelRevoked({
|
|
928
|
-
runtime: params.runtime,
|
|
929
|
-
serverBaseUrl: matched.binding.serverBaseUrl,
|
|
930
|
-
channelId: matched.binding.channelId
|
|
931
|
-
}) && !matched.binding.archived && bindingMatchesActiveSessionIncarnation(params.runtime, matched.sessionKey, matched.binding) && resolveDiscussionBindingAccount(params.runtime.config.current(), matched.binding).state === "active") return matched;
|
|
932
|
-
}
|
|
933
|
-
/** Restricts a discussion side session's session tools to its attached main session. */
|
|
934
|
-
function enforceClickClackDiscussionToolTarget(params) {
|
|
935
|
-
const callerSessionKey = params.context.sessionKey;
|
|
936
|
-
if (!callerSessionKey) return;
|
|
937
|
-
const { toolName } = params.event;
|
|
938
|
-
if (toolName !== "session_status" && !toolName.startsWith("sessions_")) return;
|
|
939
|
-
const matched = getClickClackDiscussionBindingStore(params.runtime).getByDiscussionSession(callerSessionKey);
|
|
940
|
-
if (!matched) return isDiscussionSessionKey(callerSessionKey) ? blockedResult() : void 0;
|
|
941
|
-
const targetsMain = Boolean(isClickClackDiscussionSessionTarget({
|
|
942
|
-
runtime: params.runtime,
|
|
943
|
-
requesterSessionKey: callerSessionKey,
|
|
944
|
-
targetSessionKey: matched.sessionKey
|
|
945
|
-
})) && TARGETED_SESSION_TOOLS.has(toolName) && params.event.params.sessionKey === matched.sessionKey;
|
|
946
|
-
const usesAlternateSendTarget = toolName === "sessions_send" && (params.event.params.label !== void 0 || params.event.params.agentId !== void 0);
|
|
947
|
-
const mutatesStatus = toolName === "session_status" && params.event.params.model !== void 0;
|
|
948
|
-
const selectsHistoryIncarnation = toolName === "sessions_history" && params.event.params.sessionId !== void 0;
|
|
949
|
-
if (targetsMain && !usesAlternateSendTarget && !mutatesStatus && !selectsHistoryIncarnation) return;
|
|
950
|
-
return blockedResult();
|
|
951
|
-
}
|
|
952
|
-
//#endregion
|
|
953
|
-
//#region extensions/clickclack/src/discussions/tool.ts
|
|
954
|
-
const DEFAULT_MESSAGE_LIMIT = 30;
|
|
955
|
-
const MAX_MESSAGE_LIMIT = 200;
|
|
956
|
-
function createClickClackDiscussionTool(params) {
|
|
957
|
-
return {
|
|
958
|
-
name: "discussion",
|
|
959
|
-
label: "Discussion",
|
|
960
|
-
description: "Read the latest messages from the ClickClack discussion bound to this session.",
|
|
961
|
-
parameters: {
|
|
962
|
-
type: "object",
|
|
963
|
-
properties: { limit: {
|
|
964
|
-
type: "integer",
|
|
965
|
-
minimum: 1,
|
|
966
|
-
maximum: MAX_MESSAGE_LIMIT,
|
|
967
|
-
description: `Maximum messages to return (default ${DEFAULT_MESSAGE_LIMIT}).`
|
|
968
|
-
} },
|
|
969
|
-
additionalProperties: false
|
|
970
|
-
},
|
|
971
|
-
async execute(_toolCallId, input) {
|
|
972
|
-
if (!params.sessionKey) return textResult("No discussion is bound to this session.", { bound: false });
|
|
973
|
-
const requested = typeof input === "object" && input !== null && "limit" in input ? Number(input.limit) : DEFAULT_MESSAGE_LIMIT;
|
|
974
|
-
const limit = Number.isInteger(requested) ? Math.max(1, Math.min(MAX_MESSAGE_LIMIT, requested)) : DEFAULT_MESSAGE_LIMIT;
|
|
975
|
-
const result = await params.service.readLatestMessages(params.sessionKey, limit);
|
|
976
|
-
return textResult(result.text, {
|
|
977
|
-
bound: Boolean(result.binding),
|
|
978
|
-
limit,
|
|
979
|
-
...result.binding ? { channelId: result.binding.channelId } : {}
|
|
980
|
-
});
|
|
981
|
-
}
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
//#endregion
|
|
985
|
-
//#region extensions/clickclack/src/discussions/register.ts
|
|
986
|
-
function registerClickClackDiscussions(api) {
|
|
987
|
-
if (api.registrationMode === "tool-discovery") {
|
|
988
|
-
api.registerTool(() => null, { name: "discussion" });
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
const service = new ClickClackDiscussionService(api.runtime);
|
|
992
|
-
api.registerService({
|
|
993
|
-
id: "clickclack-discussion-session-events",
|
|
994
|
-
start: ({ gatewayEvents }) => {
|
|
995
|
-
service.bindGatewayEvents(gatewayEvents);
|
|
996
|
-
},
|
|
997
|
-
stop: () => {
|
|
998
|
-
service.cleanup();
|
|
999
|
-
}
|
|
1000
|
-
});
|
|
1001
|
-
api.registerTool((context) => createClickClackDiscussionTool({
|
|
1002
|
-
service,
|
|
1003
|
-
sessionKey: context.sessionKey
|
|
1004
|
-
}));
|
|
1005
|
-
api.on("before_tool_call", (event, context) => enforceClickClackDiscussionToolTarget({
|
|
1006
|
-
runtime: api.runtime,
|
|
1007
|
-
event,
|
|
1008
|
-
context
|
|
1009
|
-
}));
|
|
1010
|
-
const unregisterSessionAccess = createSessionVisibilityChecker.registerScopedAccessProvider(({ requesterSessionKey, targetSessionKey }) => {
|
|
1011
|
-
const target = isClickClackDiscussionSessionTarget({
|
|
1012
|
-
runtime: api.runtime,
|
|
1013
|
-
requesterSessionKey,
|
|
1014
|
-
targetSessionKey
|
|
1015
|
-
});
|
|
1016
|
-
return target ? { expectedSessionId: target.binding.sessionId } : void 0;
|
|
1017
|
-
});
|
|
1018
|
-
registerSessionDiscussionProvider(service.provider);
|
|
1019
|
-
api.lifecycle.registerRuntimeLifecycle({
|
|
1020
|
-
id: "clickclack-discussions",
|
|
1021
|
-
description: "Stops the lifecycle reconciler for managed ClickClack discussions.",
|
|
1022
|
-
cleanup: () => {
|
|
1023
|
-
unregisterSessionAccess();
|
|
1024
|
-
service.cleanup();
|
|
1025
|
-
}
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
//#endregion
|
|
1029
|
-
export { registerClickClackDiscussions as t };
|