@agentchatme/openclaw 0.2.0

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/dist/index.cjs ADDED
@@ -0,0 +1,2412 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var channelCore = require('openclaw/plugin-sdk/channel-core');
6
+ var zod = require('zod');
7
+ var pino = require('pino');
8
+ var ws = require('ws');
9
+
10
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
+
12
+ var pino__default = /*#__PURE__*/_interopDefault(pino);
13
+
14
+ // src/channel.ts
15
+ var reconnectConfigSchema = zod.z.object({
16
+ initialBackoffMs: zod.z.number().int().min(100).max(1e4).default(1e3),
17
+ maxBackoffMs: zod.z.number().int().min(1e3).max(3e5).default(3e4),
18
+ jitterRatio: zod.z.number().min(0).max(1).default(0.2)
19
+ }).strict();
20
+ var pingConfigSchema = zod.z.object({
21
+ intervalMs: zod.z.number().int().min(5e3).max(12e4).default(3e4),
22
+ timeoutMs: zod.z.number().int().min(1e3).max(3e4).default(1e4)
23
+ }).strict();
24
+ var outboundConfigSchema = zod.z.object({
25
+ maxInFlight: zod.z.number().int().min(1).max(1e4).default(256),
26
+ sendTimeoutMs: zod.z.number().int().min(1e3).max(6e4).default(15e3)
27
+ }).strict();
28
+ var observabilityConfigSchema = zod.z.object({
29
+ logLevel: zod.z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
30
+ redactKeys: zod.z.array(zod.z.string()).default(["apiKey", "authorization", "cookie", "set-cookie"])
31
+ }).strict();
32
+ var agentHandleSchema = zod.z.string().regex(/^[a-z0-9_.-]{3,32}$/, "handle must be 3-32 chars, lowercase alphanumeric + . _ -");
33
+ var agentchatChannelConfigSchema = zod.z.object({
34
+ apiBase: zod.z.string().url().default("https://api.agentchat.me"),
35
+ apiKey: zod.z.string().min(20, "apiKey looks too short for an AgentChat API key"),
36
+ agentHandle: agentHandleSchema.optional(),
37
+ // `.prefault({})` — Zod 4 idiom: when the key is missing from input, parse
38
+ // `{}` through the inner schema so its own per-field defaults kick in.
39
+ // Using `.default({})` here fails in Zod 4 because the output type's fields
40
+ // are non-optional once the inner schema has defaults.
41
+ reconnect: reconnectConfigSchema.prefault({}),
42
+ ping: pingConfigSchema.prefault({}),
43
+ outbound: outboundConfigSchema.prefault({}),
44
+ observability: observabilityConfigSchema.prefault({})
45
+ }).strict();
46
+ function parseChannelConfig(input) {
47
+ return agentchatChannelConfigSchema.parse(input);
48
+ }
49
+
50
+ // src/errors.ts
51
+ var AgentChatChannelError = class extends Error {
52
+ class_;
53
+ retryAfterMs;
54
+ statusCode;
55
+ constructor(class_, message, options = {}) {
56
+ super(message, { cause: options.cause });
57
+ this.name = "AgentChatChannelError";
58
+ this.class_ = class_;
59
+ this.retryAfterMs = options.retryAfterMs;
60
+ this.statusCode = options.statusCode;
61
+ }
62
+ };
63
+ function classifyHttpStatus(status, retryAfterHeader) {
64
+ if (status === 401 || status === 403) return "terminal-auth";
65
+ if (status === 409) return "idempotent-replay";
66
+ if (status === 429) return "retry-rate";
67
+ if (status >= 500 && status <= 599) return "retry-transient";
68
+ if (status >= 400 && status <= 499) return "terminal-user";
69
+ return "retry-transient";
70
+ }
71
+ function parseRetryAfter(header, nowMs) {
72
+ if (!header) return void 0;
73
+ const seconds = Number(header);
74
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.floor(seconds * 1e3);
75
+ const when = Date.parse(header);
76
+ if (Number.isFinite(when)) return Math.max(0, when - nowMs);
77
+ return void 0;
78
+ }
79
+ function classifyNetworkError(err) {
80
+ if (!err || typeof err !== "object") return "retry-transient";
81
+ const code = err.code;
82
+ if (typeof code === "string") {
83
+ if (code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ECONNREFUSED") {
84
+ return "retry-transient";
85
+ }
86
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "retry-transient";
87
+ }
88
+ return "retry-transient";
89
+ }
90
+
91
+ // src/setup-client.ts
92
+ var DEFAULT_API_BASE = "https://api.agentchat.me";
93
+ var DEFAULT_TIMEOUT_MS = 1e4;
94
+ async function validateApiKey(apiKey, opts = {}) {
95
+ if (!apiKey || typeof apiKey !== "string") {
96
+ return { ok: false, reason: "unauthorized", message: "API key is empty" };
97
+ }
98
+ const base = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
99
+ const url = `${base}/v1/agents/me`;
100
+ const controller = new AbortController();
101
+ const fetchImpl = opts.fetch ?? fetch;
102
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
103
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
104
+ let res;
105
+ try {
106
+ res = await fetchImpl(url, {
107
+ method: "GET",
108
+ headers: {
109
+ Authorization: `Bearer ${apiKey}`,
110
+ Accept: "application/json"
111
+ },
112
+ signal: controller.signal
113
+ });
114
+ } catch (err) {
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ const unreachable = err instanceof Error && (err.name === "AbortError" || /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i.test(message));
117
+ return {
118
+ ok: false,
119
+ reason: unreachable ? "unreachable" : "network-error",
120
+ message: `agentchat: GET /v1/agents/me failed: ${message}`
121
+ };
122
+ } finally {
123
+ clearTimeout(timer);
124
+ }
125
+ if (res.status === 401) {
126
+ return { ok: false, reason: "unauthorized", status: 401, message: "API key is invalid or revoked" };
127
+ }
128
+ if (res.status === 403) {
129
+ return { ok: false, reason: "forbidden", status: 403, message: "API key lacks permission to read /agents/me" };
130
+ }
131
+ if (res.status === 410) {
132
+ return { ok: false, reason: "deleted", status: 410, message: "Agent account has been deleted" };
133
+ }
134
+ if (res.status >= 500) {
135
+ return { ok: false, reason: "server-error", status: res.status, message: `AgentChat API returned ${res.status}` };
136
+ }
137
+ if (!res.ok) {
138
+ return {
139
+ ok: false,
140
+ reason: "server-error",
141
+ status: res.status,
142
+ message: `AgentChat API returned ${res.status}`
143
+ };
144
+ }
145
+ const body = await res.json().catch(() => null);
146
+ const email = typeof body?.email === "string" ? body.email : typeof body?.email_masked === "string" ? body.email_masked : null;
147
+ if (!body || typeof body.handle !== "string" || email === null || typeof body.created_at !== "string") {
148
+ return {
149
+ ok: false,
150
+ reason: "unexpected-shape",
151
+ status: res.status,
152
+ message: "AgentChat /agents/me returned an unrecognized shape"
153
+ };
154
+ }
155
+ return {
156
+ ok: true,
157
+ agent: {
158
+ handle: body.handle,
159
+ displayName: typeof body.display_name === "string" ? body.display_name : null,
160
+ email,
161
+ createdAt: body.created_at
162
+ }
163
+ };
164
+ }
165
+ async function registerAgentStart(input, opts = {}) {
166
+ const res = await post("/v1/register", input, opts);
167
+ if (res.kind === "network") {
168
+ return { ok: false, reason: "network-error", message: res.message };
169
+ }
170
+ if (res.kind === "timeout") {
171
+ return { ok: false, reason: "network-error", message: "request timed out" };
172
+ }
173
+ const body = res.body ?? {};
174
+ if (res.status === 200) {
175
+ if (typeof body.pending_id !== "string") {
176
+ return {
177
+ ok: false,
178
+ reason: "server-error",
179
+ status: 200,
180
+ message: "AgentChat /register returned no pending_id"
181
+ };
182
+ }
183
+ return { ok: true, pendingId: body.pending_id };
184
+ }
185
+ const code = typeof body.code === "string" ? body.code : "";
186
+ const message = typeof body.message === "string" ? body.message : `status ${res.status}`;
187
+ if (res.status === 400 && code === "INVALID_HANDLE") return { ok: false, reason: "invalid-handle", message, status: 400 };
188
+ if (res.status === 400 && code === "VALIDATION_ERROR") return { ok: false, reason: "validation", message, status: 400 };
189
+ if (res.status === 409 && code === "HANDLE_TAKEN") return { ok: false, reason: "handle-taken", message, status: 409 };
190
+ if (res.status === 409 && code === "EMAIL_TAKEN") return { ok: false, reason: "email-taken", message, status: 409 };
191
+ if (res.status === 409 && code === "EMAIL_IS_OWNER") return { ok: false, reason: "email-is-owner", message, status: 409 };
192
+ if (res.status === 409 && code === "EMAIL_EXHAUSTED") return { ok: false, reason: "email-exhausted", message, status: 409 };
193
+ if (res.status === 429) {
194
+ return {
195
+ ok: false,
196
+ reason: "rate-limited",
197
+ message,
198
+ status: 429,
199
+ retryAfterSeconds: res.retryAfterSeconds
200
+ };
201
+ }
202
+ if (res.status >= 500 || code === "OTP_FAILED") return { ok: false, reason: "otp-failed", message, status: res.status };
203
+ return { ok: false, reason: "server-error", status: res.status, message };
204
+ }
205
+ async function registerAgentVerify(input, opts = {}) {
206
+ const res = await post("/v1/register/verify", { pending_id: input.pendingId, code: input.code }, opts);
207
+ if (res.kind === "network") return { ok: false, reason: "network-error", message: res.message };
208
+ if (res.kind === "timeout") return { ok: false, reason: "network-error", message: "request timed out" };
209
+ const body = res.body ?? {};
210
+ if (res.status === 201) {
211
+ const agent = body.agent;
212
+ if (typeof body.api_key !== "string" || !agent || typeof agent.handle !== "string" || typeof agent.email !== "string" || typeof agent.created_at !== "string") {
213
+ return {
214
+ ok: false,
215
+ reason: "unexpected-shape",
216
+ status: 201,
217
+ message: "AgentChat /register/verify returned an unrecognized shape"
218
+ };
219
+ }
220
+ return {
221
+ ok: true,
222
+ apiKey: body.api_key,
223
+ agent: {
224
+ handle: agent.handle,
225
+ displayName: typeof agent.display_name === "string" ? agent.display_name : null,
226
+ email: agent.email,
227
+ createdAt: agent.created_at
228
+ }
229
+ };
230
+ }
231
+ const code = typeof body.code === "string" ? body.code : "";
232
+ const message = typeof body.message === "string" ? body.message : `status ${res.status}`;
233
+ if (res.status === 400 && code === "EXPIRED") return { ok: false, reason: "expired", message, status: 400 };
234
+ if (res.status === 400 && code === "INVALID_CODE") return { ok: false, reason: "invalid-code", message, status: 400 };
235
+ if (res.status === 400 && code === "VALIDATION_ERROR") return { ok: false, reason: "validation", message, status: 400 };
236
+ if (res.status === 409 && code === "HANDLE_TAKEN") return { ok: false, reason: "handle-taken", message, status: 409 };
237
+ if (res.status === 409 && code === "EMAIL_TAKEN") return { ok: false, reason: "email-taken", message, status: 409 };
238
+ if (res.status === 409 && code === "EMAIL_IS_OWNER") return { ok: false, reason: "email-is-owner", message, status: 409 };
239
+ if (res.status === 429) {
240
+ return { ok: false, reason: "rate-limited", message, status: 429, retryAfterSeconds: res.retryAfterSeconds };
241
+ }
242
+ return { ok: false, reason: "server-error", status: res.status, message };
243
+ }
244
+ async function post(path, body, opts) {
245
+ const base = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
246
+ const url = `${base}${path}`;
247
+ const controller = new AbortController();
248
+ const fetchImpl = opts.fetch ?? fetch;
249
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
250
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
251
+ try {
252
+ const res = await fetchImpl(url, {
253
+ method: "POST",
254
+ headers: { "content-type": "application/json", accept: "application/json" },
255
+ body: JSON.stringify(body),
256
+ signal: controller.signal
257
+ });
258
+ const parsed = await res.json().catch(() => null);
259
+ const retryAfterHeader = res.headers.get("retry-after");
260
+ const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : void 0;
261
+ return {
262
+ kind: "http",
263
+ status: res.status,
264
+ body: parsed,
265
+ retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : void 0
266
+ };
267
+ } catch (err) {
268
+ if (err instanceof Error && err.name === "AbortError") return { kind: "timeout" };
269
+ return { kind: "network", message: err instanceof Error ? err.message : String(err) };
270
+ } finally {
271
+ clearTimeout(timer);
272
+ }
273
+ }
274
+ async function assertApiKeyValid(apiKey, opts = {}) {
275
+ const result = await validateApiKey(apiKey, opts);
276
+ if (result.ok) return result.agent;
277
+ const class_ = result.reason === "unauthorized" || result.reason === "forbidden" || result.reason === "deleted" ? "terminal-auth" : result.reason === "server-error" ? "retry-transient" : "retry-transient";
278
+ throw new AgentChatChannelError(class_, `${result.message} [reason=${result.reason}]`, {
279
+ statusCode: result.status
280
+ });
281
+ }
282
+
283
+ // src/setup-wizard.ts
284
+ var HANDLE_PATTERN = /^[a-z0-9_.-]{3,32}$/;
285
+ var MIN_API_KEY_LENGTH = 20;
286
+ var OTP_ATTEMPTS = 3;
287
+ function readSection(cfg) {
288
+ const channels = cfg?.channels;
289
+ const sec = channels?.[AGENTCHAT_CHANNEL_ID];
290
+ return sec && typeof sec === "object" && !Array.isArray(sec) ? sec : void 0;
291
+ }
292
+ function readAccountRaw(cfg, accountId) {
293
+ const sec = readSection(cfg);
294
+ if (!sec) return void 0;
295
+ const accounts = sec.accounts;
296
+ if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
297
+ const entry = accounts[accountId];
298
+ if (entry && typeof entry === "object") return entry;
299
+ }
300
+ if (accountId === AGENTCHAT_DEFAULT_ACCOUNT_ID) {
301
+ const { accounts: _accounts, ...rest } = sec;
302
+ return rest;
303
+ }
304
+ return void 0;
305
+ }
306
+ function readAccountApiKey(cfg, accountId) {
307
+ const raw = readAccountRaw(cfg, accountId);
308
+ const value = raw?.apiKey;
309
+ return typeof value === "string" && value.length >= MIN_API_KEY_LENGTH ? value : void 0;
310
+ }
311
+ function readAccountApiBase(cfg, accountId) {
312
+ const raw = readAccountRaw(cfg, accountId);
313
+ const value = raw?.apiBase;
314
+ return typeof value === "string" && value.length > 0 ? value : void 0;
315
+ }
316
+ function writeAccountPatch(cfg, accountId, patch) {
317
+ const channels = {
318
+ ...cfg?.channels ?? {}
319
+ };
320
+ const existing = channels[AGENTCHAT_CHANNEL_ID];
321
+ const section = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
322
+ const clean = {};
323
+ if (patch.apiKey !== void 0) clean.apiKey = patch.apiKey;
324
+ if (patch.apiBase !== void 0) clean.apiBase = patch.apiBase;
325
+ if (patch.agentHandle !== void 0) clean.agentHandle = patch.agentHandle;
326
+ const hasAccountsMap = typeof section.accounts === "object" && section.accounts !== null && !Array.isArray(section.accounts);
327
+ if (accountId === AGENTCHAT_DEFAULT_ACCOUNT_ID && !hasAccountsMap) {
328
+ Object.assign(section, clean);
329
+ } else {
330
+ const accounts = {
331
+ ...section.accounts ?? {}
332
+ };
333
+ const prev = typeof accounts[accountId] === "object" && accounts[accountId] !== null ? accounts[accountId] : {};
334
+ accounts[accountId] = { ...prev, ...clean };
335
+ section.accounts = accounts;
336
+ }
337
+ channels[AGENTCHAT_CHANNEL_ID] = section;
338
+ return { ...cfg, channels };
339
+ }
340
+ function isAccountConfigured(cfg, accountId) {
341
+ return Boolean(readAccountApiKey(cfg, accountId ?? AGENTCHAT_DEFAULT_ACCOUNT_ID));
342
+ }
343
+ async function promptApiKey(prompter) {
344
+ const value = await prompter.text({
345
+ message: "Paste your AgentChat API key",
346
+ placeholder: "ac_live_...",
347
+ validate: (v) => {
348
+ const trimmed = v.trim();
349
+ if (!trimmed) return "API key is required";
350
+ if (trimmed.length < MIN_API_KEY_LENGTH)
351
+ return `Looks too short (${trimmed.length} chars \u2014 expect \u2265${MIN_API_KEY_LENGTH})`;
352
+ return void 0;
353
+ }
354
+ });
355
+ return value.trim();
356
+ }
357
+ async function promptEmail(prompter) {
358
+ const value = await prompter.text({
359
+ message: "Your email address (for OTP verification)",
360
+ placeholder: "you@example.com",
361
+ validate: (v) => {
362
+ const trimmed = v.trim();
363
+ if (!trimmed) return "Email is required";
364
+ if (!/.+@.+\..+/.test(trimmed)) return "Not a valid email address";
365
+ return void 0;
366
+ }
367
+ });
368
+ return value.trim();
369
+ }
370
+ async function promptHandle(prompter, initial) {
371
+ const value = await prompter.text({
372
+ message: "Pick a handle for your agent",
373
+ placeholder: "alice",
374
+ initialValue: initial,
375
+ validate: (v) => {
376
+ const trimmed = v.trim().toLowerCase();
377
+ if (!trimmed) return "Handle is required";
378
+ if (!HANDLE_PATTERN.test(trimmed))
379
+ return "3\u201332 chars; lowercase a-z / 0-9 / dot / underscore / dash";
380
+ return void 0;
381
+ }
382
+ });
383
+ return value.trim().toLowerCase();
384
+ }
385
+ async function promptOtp(prompter) {
386
+ const value = await prompter.text({
387
+ message: "Enter the 6-digit code we emailed you",
388
+ placeholder: "123456",
389
+ validate: (v) => /^\d{6}$/.test(v.trim()) ? void 0 : "6-digit numeric code required"
390
+ });
391
+ return value.trim();
392
+ }
393
+ async function promptOptionalText(prompter, message, placeholder) {
394
+ const value = await prompter.text({
395
+ message,
396
+ placeholder,
397
+ validate: () => void 0
398
+ });
399
+ return value.trim();
400
+ }
401
+ function describeRegisterStartFailure(r) {
402
+ switch (r.reason) {
403
+ case "invalid-handle":
404
+ return "That handle is not valid. Use 3\u201332 chars, lowercase a-z / 0-9 / . _ -.";
405
+ case "handle-taken":
406
+ return "That handle is already taken. Pick another.";
407
+ case "email-taken":
408
+ return "That email already has an agent. Sign in with the existing key instead, or use a different email.";
409
+ case "email-is-owner":
410
+ return "That email is reserved. Use a different address.";
411
+ case "email-exhausted":
412
+ return "This email has hit the per-address agent limit. Use a different email or delete an old agent.";
413
+ case "rate-limited":
414
+ return r.retryAfterSeconds ? `Too many attempts. Try again in ${r.retryAfterSeconds}s.` : "Too many attempts. Try again in a few minutes.";
415
+ case "otp-failed":
416
+ return "Could not send the verification email. Check the address and retry.";
417
+ case "network-error":
418
+ return `Network error: ${r.message}`;
419
+ case "validation":
420
+ return `Validation error: ${r.message}`;
421
+ case "server-error":
422
+ return `AgentChat API error (${r.status ?? "??"}): ${r.message}`;
423
+ }
424
+ }
425
+ function describeRegisterVerifyFailure(r) {
426
+ switch (r.reason) {
427
+ case "expired":
428
+ return "That code has expired. We'll request a new one \u2014 restart registration.";
429
+ case "invalid-code":
430
+ return "Wrong code. Check the email and try again.";
431
+ case "rate-limited":
432
+ return r.retryAfterSeconds ? `Too many attempts. Wait ${r.retryAfterSeconds}s before retrying.` : "Too many attempts. Try again in a few minutes.";
433
+ case "handle-taken":
434
+ return "Someone else just took that handle. Restart and pick another.";
435
+ case "email-taken":
436
+ return "That email was registered by someone else between these steps. Start over.";
437
+ case "email-is-owner":
438
+ return "That email is reserved.";
439
+ case "network-error":
440
+ return `Network error: ${r.message}`;
441
+ case "unexpected-shape":
442
+ return `Server returned an unexpected response (${r.status ?? "??"}). Try again shortly.`;
443
+ case "validation":
444
+ return `Validation error: ${r.message}`;
445
+ case "server-error":
446
+ return `AgentChat API error (${r.status ?? "??"}): ${r.message}`;
447
+ }
448
+ }
449
+ async function runHaveKeyFlow(cfg, accountId, prompter, apiBase) {
450
+ const apiKey = await promptApiKey(prompter);
451
+ const progress = prompter.progress("Validating key against AgentChat\u2026");
452
+ const result = await validateApiKey(apiKey, { apiBase });
453
+ if (!result.ok) {
454
+ progress.stop("Key rejected.");
455
+ await prompter.note(
456
+ [
457
+ `AgentChat rejected the key (${result.reason}):`,
458
+ ` ${result.message}`,
459
+ "",
460
+ result.reason === "unauthorized" || result.reason === "deleted" ? "The key is invalid, revoked, or the agent was deleted. Grab a fresh key from agentchat.me/dashboard." : result.reason === "unreachable" || result.reason === "network-error" ? "Could not reach the API. Check your network, then run setup again." : "Try again, or pick the register flow."
461
+ ].join("\n"),
462
+ "Validation failed"
463
+ );
464
+ return runNewAccountFlow(cfg, accountId, prompter, apiBase);
465
+ }
466
+ progress.stop(`Authenticated as @${result.agent.handle}.`);
467
+ const next = writeAccountPatch(cfg, accountId, {
468
+ apiKey,
469
+ agentHandle: result.agent.handle,
470
+ ...apiBase ? { apiBase } : {}
471
+ });
472
+ await prompter.note(
473
+ [
474
+ `Connected to AgentChat as @${result.agent.handle}.`,
475
+ `Email: ${result.agent.email}`,
476
+ result.agent.displayName ? `Display name: ${result.agent.displayName}` : void 0
477
+ ].filter((v) => Boolean(v)).join("\n"),
478
+ "AgentChat configured"
479
+ );
480
+ return { cfg: next };
481
+ }
482
+ async function runRegisterFlow(cfg, accountId, prompter, apiBase) {
483
+ await prompter.note(
484
+ [
485
+ "We will email a 6-digit code to verify ownership of this address.",
486
+ "The email is hashed server-side and used only for account recovery."
487
+ ].join("\n"),
488
+ "New-agent registration"
489
+ );
490
+ const email = await promptEmail(prompter);
491
+ const handle = await promptHandle(prompter);
492
+ const displayName = await promptOptionalText(
493
+ prompter,
494
+ "Display name (optional, press Enter to skip)",
495
+ handle
496
+ );
497
+ const description = await promptOptionalText(
498
+ prompter,
499
+ "Short description of your agent (optional)",
500
+ "Research assistant that summarises papers"
501
+ );
502
+ const startProgress = prompter.progress("Requesting OTP\u2026");
503
+ const start = await registerAgentStart(
504
+ {
505
+ email,
506
+ handle,
507
+ displayName: displayName || void 0,
508
+ description: description || void 0
509
+ },
510
+ { apiBase }
511
+ );
512
+ if (!start.ok) {
513
+ startProgress.stop("Registration could not start.");
514
+ await prompter.note(describeRegisterStartFailure(start), "Registration failed");
515
+ const retry = await prompter.confirm({
516
+ message: start.reason === "handle-taken" || start.reason === "invalid-handle" ? "Try a different handle?" : "Try again?",
517
+ initialValue: true
518
+ });
519
+ if (!retry) return { cfg };
520
+ return runRegisterFlow(cfg, accountId, prompter, apiBase);
521
+ }
522
+ startProgress.stop(`OTP sent to ${email}.`);
523
+ const pendingId = start.pendingId;
524
+ for (let attempt = 1; attempt <= OTP_ATTEMPTS; attempt += 1) {
525
+ const code = await promptOtp(prompter);
526
+ const verifyProgress = prompter.progress("Verifying code\u2026");
527
+ const verify = await registerAgentVerify({ pendingId, code }, { apiBase });
528
+ if (verify.ok) {
529
+ verifyProgress.stop(`Verified. Agent @${verify.agent.handle} created.`);
530
+ const next = writeAccountPatch(cfg, accountId, {
531
+ apiKey: verify.apiKey,
532
+ agentHandle: verify.agent.handle,
533
+ ...apiBase ? { apiBase } : {}
534
+ });
535
+ await prompter.note(
536
+ [
537
+ "Your AgentChat API key has been written to config.",
538
+ "",
539
+ ` handle: @${verify.agent.handle}`,
540
+ ` email: ${verify.agent.email}`,
541
+ "",
542
+ "Keep the key safe \u2014 it authenticates sends as this agent.",
543
+ "You can rotate it any time from agentchat.me/dashboard."
544
+ ].join("\n"),
545
+ "Registration complete"
546
+ );
547
+ return { cfg: next };
548
+ }
549
+ verifyProgress.stop(`Attempt ${attempt}/${OTP_ATTEMPTS} failed (${verify.reason}).`);
550
+ await prompter.note(describeRegisterVerifyFailure(verify), "Code rejected");
551
+ if (verify.reason === "expired") {
552
+ return runRegisterFlow(cfg, accountId, prompter, apiBase);
553
+ }
554
+ if (verify.reason !== "invalid-code") {
555
+ const retry = await prompter.confirm({
556
+ message: "Start over with different details?",
557
+ initialValue: true
558
+ });
559
+ if (!retry) return { cfg };
560
+ return runRegisterFlow(cfg, accountId, prompter, apiBase);
561
+ }
562
+ }
563
+ await prompter.note(
564
+ "Too many invalid codes. Restart setup to request a new one.",
565
+ "Registration aborted"
566
+ );
567
+ return { cfg };
568
+ }
569
+ async function runEditFlow(cfg, accountId, prompter) {
570
+ const currentKey = readAccountApiKey(cfg, accountId);
571
+ const currentBase = readAccountApiBase(cfg, accountId);
572
+ const choice = await prompter.select({
573
+ message: "AgentChat is already configured. What would you like to do?",
574
+ options: [
575
+ {
576
+ value: "validate",
577
+ label: "Re-validate the current key",
578
+ hint: "Hit /agents/me to confirm it still authenticates"
579
+ },
580
+ {
581
+ value: "rotate",
582
+ label: "Rotate to a new API key",
583
+ hint: "Paste a freshly generated key or register a new agent"
584
+ },
585
+ {
586
+ value: "change-base",
587
+ label: "Change API base URL",
588
+ hint: "Only for self-hosted AgentChat deployments"
589
+ },
590
+ { value: "skip", label: "Leave as is", hint: "No changes" }
591
+ ],
592
+ initialValue: "validate"
593
+ });
594
+ if (choice === "skip") return { cfg };
595
+ if (choice === "validate") {
596
+ if (!currentKey) {
597
+ await prompter.note("No API key is currently set.", "Nothing to validate");
598
+ return runNewAccountFlow(cfg, accountId, prompter, currentBase);
599
+ }
600
+ const progress = prompter.progress("Probing /agents/me\u2026");
601
+ const result = await validateApiKey(currentKey, { apiBase: currentBase });
602
+ if (result.ok) {
603
+ progress.stop(`Still authenticated as @${result.agent.handle}.`);
604
+ return { cfg };
605
+ }
606
+ progress.stop("Key no longer works.");
607
+ await prompter.note(`${result.message} [reason=${result.reason}]`, "Re-validation failed");
608
+ const doRotate = await prompter.confirm({
609
+ message: "Rotate to a new key now?",
610
+ initialValue: true
611
+ });
612
+ if (doRotate) return runNewAccountFlow(cfg, accountId, prompter, currentBase);
613
+ return { cfg };
614
+ }
615
+ if (choice === "rotate") {
616
+ return runNewAccountFlow(cfg, accountId, prompter, currentBase);
617
+ }
618
+ const nextBase = await promptOptionalText(
619
+ prompter,
620
+ "New API base URL (blank to reset to default)",
621
+ currentBase ?? "https://api.agentchat.me"
622
+ );
623
+ if (nextBase) {
624
+ try {
625
+ const url = new URL(nextBase);
626
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
627
+ await prompter.note("API base must use http:// or https://. Not updated.", "Ignored");
628
+ return { cfg };
629
+ }
630
+ } catch {
631
+ await prompter.note("Not a valid URL. API base not updated.", "Ignored");
632
+ return { cfg };
633
+ }
634
+ }
635
+ const next = writeAccountPatch(cfg, accountId, { apiBase: nextBase || void 0 });
636
+ await prompter.note(
637
+ nextBase ? `API base set to ${nextBase}` : "API base reset to default",
638
+ "Updated"
639
+ );
640
+ return { cfg: next };
641
+ }
642
+ async function runNewAccountFlow(cfg, accountId, prompter, apiBase) {
643
+ const choice = await prompter.select({
644
+ message: "How do you want to connect this agent?",
645
+ options: [
646
+ {
647
+ value: "register",
648
+ label: "Register a new agent (email + OTP)",
649
+ hint: "Mint a fresh API key \u2014 ~60 seconds"
650
+ },
651
+ {
652
+ value: "have-key",
653
+ label: "I already have an API key",
654
+ hint: "Paste ac_live_..."
655
+ }
656
+ ],
657
+ initialValue: "register"
658
+ });
659
+ if (choice === "have-key") return runHaveKeyFlow(cfg, accountId, prompter, apiBase);
660
+ return runRegisterFlow(cfg, accountId, prompter, apiBase);
661
+ }
662
+ var agentchatSetupWizard = {
663
+ channel: AGENTCHAT_CHANNEL_ID,
664
+ resolveShouldPromptAccountIds: () => false,
665
+ status: {
666
+ configuredLabel: "connected",
667
+ unconfiguredLabel: "needs API key",
668
+ configuredHint: "configured",
669
+ unconfiguredHint: "needs agent credentials",
670
+ configuredScore: 2,
671
+ unconfiguredScore: 0,
672
+ resolveConfigured: ({ cfg, accountId }) => isAccountConfigured(cfg, accountId),
673
+ resolveStatusLines: async ({ cfg, accountId, configured }) => {
674
+ if (!configured) return ["AgentChat: needs API key \u2014 run setup to register or paste one"];
675
+ const id = accountId ?? AGENTCHAT_DEFAULT_ACCOUNT_ID;
676
+ const key = readAccountApiKey(cfg, id);
677
+ const apiBase = readAccountApiBase(cfg, id);
678
+ if (!key) return ["AgentChat: configured (no key visible)"];
679
+ try {
680
+ const result = await validateApiKey(key, { apiBase, timeoutMs: 3e3 });
681
+ if (result.ok) return [`AgentChat: connected as @${result.agent.handle}`];
682
+ return [`AgentChat: configured (live probe failed: ${result.reason})`];
683
+ } catch {
684
+ return ["AgentChat: configured (live probe unreachable)"];
685
+ }
686
+ }
687
+ },
688
+ introNote: {
689
+ title: "AgentChat",
690
+ lines: [
691
+ "A messaging platform for AI agents \u2014 handle-based routing, realtime WebSocket,",
692
+ "typed error taxonomy, idempotent sends.",
693
+ "",
694
+ "You can paste an existing API key, or register a new agent inline (email + OTP)."
695
+ ]
696
+ },
697
+ prepare: async ({ cfg, accountId, credentialValues }) => {
698
+ const flow = isAccountConfigured(cfg, accountId) ? "edit" : "new";
699
+ return { credentialValues: { ...credentialValues, _flow: flow } };
700
+ },
701
+ credentials: [],
702
+ finalize: async ({ cfg, accountId, prompter, credentialValues }) => {
703
+ const rawFlow = credentialValues._flow;
704
+ const flow = rawFlow === "edit" ? "edit" : "new";
705
+ const apiBase = readAccountApiBase(cfg, accountId);
706
+ return flow === "edit" ? await runEditFlow(cfg, accountId, prompter) : await runNewAccountFlow(cfg, accountId, prompter, apiBase);
707
+ },
708
+ completionNote: {
709
+ title: "AgentChat is ready",
710
+ lines: [
711
+ "The channel runtime will auto-connect on the next `openclaw` boot.",
712
+ "Messages addressed to your handle arrive over WebSocket; sends go out",
713
+ "over HTTPS with at-least-once + idempotent semantics."
714
+ ]
715
+ },
716
+ disable: (cfg) => {
717
+ const channels = {
718
+ ...cfg?.channels ?? {}
719
+ };
720
+ const existing = channels[AGENTCHAT_CHANNEL_ID];
721
+ const section = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
722
+ section.enabled = false;
723
+ channels[AGENTCHAT_CHANNEL_ID] = section;
724
+ return { ...cfg, channels };
725
+ }
726
+ };
727
+
728
+ // src/channel.ts
729
+ var AGENTCHAT_CHANNEL_ID = "agentchat";
730
+ var AGENTCHAT_DEFAULT_ACCOUNT_ID = "default";
731
+ var MIN_API_KEY_LENGTH2 = 20;
732
+ function readChannelSection(cfg) {
733
+ const channels = cfg?.channels;
734
+ const section = channels?.[AGENTCHAT_CHANNEL_ID];
735
+ return section && typeof section === "object" && !Array.isArray(section) ? section : void 0;
736
+ }
737
+ function readAccountRaw2(section, accountId) {
738
+ if (!section) return void 0;
739
+ const { accounts } = section;
740
+ if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
741
+ const entry = accounts[accountId];
742
+ if (entry && typeof entry === "object") return entry;
743
+ }
744
+ if (accountId === AGENTCHAT_DEFAULT_ACCOUNT_ID) {
745
+ const { accounts: _accounts, ...rest } = section;
746
+ return rest;
747
+ }
748
+ return void 0;
749
+ }
750
+ function splitEnabledFromRaw(raw) {
751
+ if (!raw) return { enabled: true, forParse: void 0 };
752
+ const { enabled, ...rest } = raw;
753
+ return { enabled: enabled !== false, forParse: rest };
754
+ }
755
+ function isApiKeyPresent(value) {
756
+ return typeof value === "string" && value.length >= MIN_API_KEY_LENGTH2;
757
+ }
758
+ var uiHints = {
759
+ apiKey: {
760
+ label: "AgentChat API key",
761
+ placeholder: "ac_live_...",
762
+ sensitive: true,
763
+ help: "Obtain from AgentChat dashboard \u2192 Settings \u2192 API keys."
764
+ },
765
+ apiBase: {
766
+ label: "API base URL",
767
+ placeholder: "https://api.agentchat.me",
768
+ help: "Override only when targeting a self-hosted AgentChat instance.",
769
+ advanced: true
770
+ },
771
+ agentHandle: {
772
+ label: "Agent handle",
773
+ placeholder: "my-agent",
774
+ help: "3\u201332 chars, lowercase alphanumeric plus . _ -"
775
+ },
776
+ reconnect: { label: "Reconnect backoff", advanced: true },
777
+ ping: { label: "WebSocket ping cadence", advanced: true },
778
+ outbound: { label: "Outbound send tuning", advanced: true },
779
+ observability: { label: "Logs & metrics", advanced: true }
780
+ };
781
+ var agentchatPlugin = {
782
+ id: AGENTCHAT_CHANNEL_ID,
783
+ meta: {
784
+ id: AGENTCHAT_CHANNEL_ID,
785
+ label: "AgentChat",
786
+ selectionLabel: "AgentChat (messaging platform for agents)",
787
+ detailLabel: "AgentChat",
788
+ docsPath: "/channels/agentchat",
789
+ docsLabel: "agentchat",
790
+ blurb: "connect your agent to the AgentChat messaging platform \u2014 handles, contacts, groups, presence, attachments.",
791
+ systemImage: "message",
792
+ markdownCapable: true,
793
+ order: 100
794
+ },
795
+ capabilities: {
796
+ chatTypes: ["direct", "group"],
797
+ media: true,
798
+ reactions: false,
799
+ edit: false,
800
+ unsend: true,
801
+ reply: true,
802
+ threads: false,
803
+ nativeCommands: false
804
+ },
805
+ reload: {
806
+ configPrefixes: [`channels.${AGENTCHAT_CHANNEL_ID}.`]
807
+ },
808
+ configSchema: channelCore.buildChannelConfigSchema(agentchatChannelConfigSchema, { uiHints }),
809
+ setupWizard: agentchatSetupWizard,
810
+ config: {
811
+ listAccountIds(cfg) {
812
+ const section = readChannelSection(cfg);
813
+ if (!section) return [];
814
+ const { accounts } = section;
815
+ if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
816
+ const ids = Object.keys(accounts);
817
+ if (ids.length > 0) return ids;
818
+ }
819
+ const hasFlatFields = typeof section.apiKey === "string" || typeof section.apiBase === "string" || typeof section.agentHandle === "string";
820
+ return hasFlatFields ? [AGENTCHAT_DEFAULT_ACCOUNT_ID] : [];
821
+ },
822
+ resolveAccount(cfg, accountId) {
823
+ const id = accountId ?? AGENTCHAT_DEFAULT_ACCOUNT_ID;
824
+ const section = readChannelSection(cfg);
825
+ const raw = readAccountRaw2(section, id);
826
+ const { enabled, forParse } = splitEnabledFromRaw(raw);
827
+ let config = null;
828
+ let parseError = null;
829
+ if (forParse && Object.keys(forParse).length > 0) {
830
+ try {
831
+ config = parseChannelConfig(forParse);
832
+ } catch (e) {
833
+ parseError = e instanceof Error ? e.message : String(e);
834
+ }
835
+ }
836
+ const configured = Boolean(config && isApiKeyPresent(config.apiKey));
837
+ return { accountId: id, enabled, configured, config, parseError };
838
+ },
839
+ defaultAccountId() {
840
+ return AGENTCHAT_DEFAULT_ACCOUNT_ID;
841
+ },
842
+ isEnabled(account) {
843
+ return account.enabled;
844
+ },
845
+ disabledReason(account) {
846
+ return account.enabled ? "" : "channel disabled via channels.agentchat.enabled=false";
847
+ },
848
+ isConfigured(account) {
849
+ return account.configured;
850
+ },
851
+ unconfiguredReason(account) {
852
+ if (account.parseError) return `config invalid: ${account.parseError}`;
853
+ if (!account.config) return "missing channels.agentchat configuration";
854
+ if (!isApiKeyPresent(account.config.apiKey)) return "missing or too-short channels.agentchat.apiKey";
855
+ return "";
856
+ },
857
+ hasConfiguredState({ cfg }) {
858
+ const section = readChannelSection(cfg);
859
+ if (!section) return false;
860
+ const { accounts } = section;
861
+ if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
862
+ for (const entry of Object.values(accounts)) {
863
+ if (entry && typeof entry === "object") {
864
+ const candidate = entry.apiKey;
865
+ if (isApiKeyPresent(candidate)) return true;
866
+ }
867
+ }
868
+ }
869
+ return isApiKeyPresent(section.apiKey);
870
+ },
871
+ describeAccount(account) {
872
+ return {
873
+ accountId: account.accountId,
874
+ enabled: account.enabled,
875
+ configured: account.configured,
876
+ linked: account.configured
877
+ };
878
+ }
879
+ },
880
+ setup: {
881
+ /**
882
+ * Pre-write gate: cheap, sync check that the caller supplied an API key
883
+ * that's at least plausibly shaped. Real authentication happens in
884
+ * `afterAccountConfigWritten` via a live /agents/me probe.
885
+ */
886
+ validateInput({ input }) {
887
+ if (typeof input.token !== "string" || input.token.trim().length === 0) {
888
+ return "apiKey is required \u2014 pass via --token or run the register flow";
889
+ }
890
+ if (input.token.length < MIN_API_KEY_LENGTH2) {
891
+ return `apiKey looks too short (got ${input.token.length} chars, expect \u2265${MIN_API_KEY_LENGTH2})`;
892
+ }
893
+ if (typeof input.url === "string" && input.url.length > 0) {
894
+ try {
895
+ const parsed = new URL(input.url);
896
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
897
+ return "apiBase must be http(s) \u2014 e.g. https://api.agentchat.me";
898
+ }
899
+ } catch {
900
+ return "apiBase is not a valid URL";
901
+ }
902
+ }
903
+ return null;
904
+ },
905
+ applyAccountConfig({ cfg, accountId, input }) {
906
+ const channels = {
907
+ ...cfg?.channels ?? {}
908
+ };
909
+ const currentSection = channels[AGENTCHAT_CHANNEL_ID];
910
+ const section = currentSection && typeof currentSection === "object" && !Array.isArray(currentSection) ? { ...currentSection } : {};
911
+ const patch = {};
912
+ if (typeof input.token === "string" && input.token.length > 0) patch.apiKey = input.token;
913
+ if (typeof input.url === "string" && input.url.length > 0) patch.apiBase = input.url;
914
+ if (accountId === AGENTCHAT_DEFAULT_ACCOUNT_ID && !section.accounts) {
915
+ Object.assign(section, patch);
916
+ } else {
917
+ const accounts = {
918
+ ...section.accounts ?? {}
919
+ };
920
+ const prevAccount = typeof accounts[accountId] === "object" && accounts[accountId] !== null ? accounts[accountId] : {};
921
+ accounts[accountId] = { ...prevAccount, ...patch };
922
+ section.accounts = accounts;
923
+ }
924
+ channels[AGENTCHAT_CHANNEL_ID] = section;
925
+ return { ...cfg, channels };
926
+ },
927
+ /**
928
+ * Post-write probe: call `GET /v1/agents/me` with the key we just wrote
929
+ * so the operator sees a clear "✓ authenticated as @handle" on success
930
+ * — or a specific failure reason (invalid, revoked, unreachable) they
931
+ * can act on *before* the runtime starts flapping reconnects in prod.
932
+ *
933
+ * Never throws: setup-time UX is "warn and proceed". If the probe can't
934
+ * reach the API (airgapped CI, corp proxy during first boot), we still
935
+ * want the config written so the user can retry without reconfiguring.
936
+ */
937
+ async afterAccountConfigWritten({ cfg, accountId, input, runtime }) {
938
+ const apiKey = typeof input.token === "string" ? input.token : void 0;
939
+ if (!apiKey) return;
940
+ const apiBase = typeof input.url === "string" && input.url.length > 0 ? input.url : void 0;
941
+ const logger = runtime?.logger;
942
+ try {
943
+ const result = await validateApiKey(apiKey, { apiBase });
944
+ if (result.ok) {
945
+ logger?.info?.(
946
+ `[agentchat:${accountId}] authenticated as @${result.agent.handle} (${result.agent.email})`
947
+ );
948
+ } else {
949
+ logger?.warn?.(
950
+ `[agentchat:${accountId}] api key did not pass live check (${result.reason}): ${result.message}`
951
+ );
952
+ }
953
+ } catch (err) {
954
+ logger?.warn?.(
955
+ `[agentchat:${accountId}] live key validation failed: ${err instanceof Error ? err.message : String(err)}`
956
+ );
957
+ }
958
+ }
959
+ }
960
+ };
961
+ var agentchatChannelEntry = channelCore.defineChannelPluginEntry({
962
+ id: AGENTCHAT_CHANNEL_ID,
963
+ name: "AgentChat",
964
+ description: "Connect OpenClaw agents to the AgentChat messaging platform.",
965
+ plugin: agentchatPlugin
966
+ });
967
+ var agentchatSetupEntry = channelCore.defineSetupPluginEntry(agentchatPlugin);
968
+
969
+ // src/configured-state.ts
970
+ function hasAgentChatConfiguredState(config) {
971
+ if (!config || typeof config !== "object") return false;
972
+ const key = config.apiKey;
973
+ return typeof key === "string" && key.length >= 20;
974
+ }
975
+ function createLogger(options) {
976
+ if (options.delegate) {
977
+ return options.delegate;
978
+ }
979
+ const pinoLogger = pino__default.default({
980
+ level: options.level,
981
+ base: { plugin: "@agentchatme/openclaw" },
982
+ redact: {
983
+ paths: buildRedactPaths(options.redactKeys),
984
+ censor: "[REDACTED]"
985
+ },
986
+ timestamp: pino__default.default.stdTimeFunctions.isoTime
987
+ });
988
+ return wrapPino(pinoLogger);
989
+ }
990
+ function buildRedactPaths(keys) {
991
+ const prefixes = ["", "config.", "headers.", "context.", "req.headers.", "res.headers."];
992
+ const paths = [];
993
+ for (const key of keys) {
994
+ for (const prefix of prefixes) {
995
+ paths.push(`${prefix}${key}`);
996
+ }
997
+ }
998
+ paths.push(...keys.map((k) => `*.${k}`));
999
+ return paths;
1000
+ }
1001
+ function wrapPino(p) {
1002
+ return {
1003
+ trace: (obj, msg) => p.trace(obj, msg),
1004
+ debug: (obj, msg) => p.debug(obj, msg),
1005
+ info: (obj, msg) => p.info(obj, msg),
1006
+ warn: (obj, msg) => p.warn(obj, msg),
1007
+ error: (obj, msg) => p.error(obj, msg),
1008
+ child: (bindings) => wrapPino(p.child(bindings))
1009
+ };
1010
+ }
1011
+
1012
+ // src/metrics.ts
1013
+ function createNoopMetrics() {
1014
+ return {
1015
+ incInboundDelivered: () => void 0,
1016
+ incOutboundSent: () => void 0,
1017
+ incOutboundFailed: () => void 0,
1018
+ incReconnect: () => void 0,
1019
+ observeSendLatency: () => void 0,
1020
+ setConnectionState: () => void 0,
1021
+ setInFlightDepth: () => void 0
1022
+ };
1023
+ }
1024
+
1025
+ // src/state-machine.ts
1026
+ function transition(state, event) {
1027
+ switch (state.kind) {
1028
+ case "DISCONNECTED": {
1029
+ if (event.type === "CONNECT") {
1030
+ return { kind: "CONNECTING", attempt: event.attempt, startedAt: event.now };
1031
+ }
1032
+ if (event.type === "DRAIN_REQUESTED") return { kind: "CLOSED" };
1033
+ if (event.type === "AUTH_REJECTED") return { kind: "AUTH_FAIL", reason: event.reason };
1034
+ return state;
1035
+ }
1036
+ case "CONNECTING": {
1037
+ if (event.type === "SOCKET_OPEN") return { kind: "AUTHENTICATING", startedAt: event.now };
1038
+ if (event.type === "SOCKET_CLOSED") return { kind: "DISCONNECTED" };
1039
+ if (event.type === "AUTH_REJECTED") return { kind: "AUTH_FAIL", reason: event.reason };
1040
+ if (event.type === "DRAIN_REQUESTED") return { kind: "CLOSED" };
1041
+ return state;
1042
+ }
1043
+ case "AUTHENTICATING": {
1044
+ if (event.type === "HELLO_OK") return { kind: "READY", connectedAt: event.now };
1045
+ if (event.type === "AUTH_REJECTED") return { kind: "AUTH_FAIL", reason: event.reason };
1046
+ if (event.type === "SOCKET_CLOSED") return { kind: "DISCONNECTED" };
1047
+ if (event.type === "DRAIN_REQUESTED") return { kind: "CLOSED" };
1048
+ return state;
1049
+ }
1050
+ case "READY": {
1051
+ if (event.type === "PING_TIMEOUT") {
1052
+ return { kind: "DEGRADED", since: event.now, reason: "ping-timeout" };
1053
+ }
1054
+ if (event.type === "BACKPRESSURE") {
1055
+ return { kind: "DEGRADED", since: event.now, reason: event.reason };
1056
+ }
1057
+ if (event.type === "SOCKET_CLOSED") return { kind: "DISCONNECTED" };
1058
+ if (event.type === "AUTH_REJECTED") return { kind: "AUTH_FAIL", reason: event.reason };
1059
+ if (event.type === "DRAIN_REQUESTED") {
1060
+ return { kind: "DRAINING", since: event.now, deadline: event.deadline };
1061
+ }
1062
+ return state;
1063
+ }
1064
+ case "DEGRADED": {
1065
+ if (event.type === "RECOVERED") return { kind: "READY", connectedAt: event.now };
1066
+ if (event.type === "SOCKET_CLOSED") return { kind: "DISCONNECTED" };
1067
+ if (event.type === "AUTH_REJECTED") return { kind: "AUTH_FAIL", reason: event.reason };
1068
+ if (event.type === "DRAIN_REQUESTED") {
1069
+ return { kind: "DRAINING", since: event.now, deadline: event.deadline };
1070
+ }
1071
+ return state;
1072
+ }
1073
+ case "DRAINING": {
1074
+ if (event.type === "DRAIN_COMPLETED") return { kind: "CLOSED" };
1075
+ if (event.type === "SOCKET_CLOSED") return { kind: "CLOSED" };
1076
+ return state;
1077
+ }
1078
+ case "AUTH_FAIL": {
1079
+ if (event.type === "RECONFIGURED") return { kind: "DISCONNECTED" };
1080
+ if (event.type === "DRAIN_REQUESTED") return { kind: "CLOSED" };
1081
+ return state;
1082
+ }
1083
+ case "CLOSED": {
1084
+ return state;
1085
+ }
1086
+ }
1087
+ }
1088
+ function canSend(state) {
1089
+ return state.kind === "READY" || state.kind === "DEGRADED" || state.kind === "DRAINING";
1090
+ }
1091
+ function isTerminal(state) {
1092
+ return state.kind === "CLOSED" || state.kind === "AUTH_FAIL";
1093
+ }
1094
+
1095
+ // src/ws-client.ts
1096
+ var HELLO_ACK_TIMEOUT_MS = 4e3;
1097
+ var RECONNECT_HARD_CAP_ATTEMPTS = 60;
1098
+ var MAX_FRAME_BYTES = 2 * 1024 * 1024;
1099
+ var AUTH_CLOSE_CODES = /* @__PURE__ */ new Set([1008, 4401, 4403]);
1100
+ var AgentchatWsClient = class {
1101
+ config;
1102
+ logger;
1103
+ metrics;
1104
+ now;
1105
+ WebSocketCtor;
1106
+ random;
1107
+ _setTimeout;
1108
+ _clearTimeout;
1109
+ _setInterval;
1110
+ _clearInterval;
1111
+ state = { kind: "DISCONNECTED" };
1112
+ ws = null;
1113
+ attempt = 0;
1114
+ helloAckTimer = null;
1115
+ pingTimer = null;
1116
+ pongTimer = null;
1117
+ reconnectTimer = null;
1118
+ drainTimer = null;
1119
+ listeners = {
1120
+ stateChanged: /* @__PURE__ */ new Set(),
1121
+ inboundFrame: /* @__PURE__ */ new Set(),
1122
+ error: /* @__PURE__ */ new Set(),
1123
+ authenticated: /* @__PURE__ */ new Set(),
1124
+ closed: /* @__PURE__ */ new Set()
1125
+ };
1126
+ constructor(options) {
1127
+ this.config = options.config;
1128
+ this.logger = options.logger.child({ component: "ws-client" });
1129
+ this.metrics = options.metrics;
1130
+ this.now = options.now ?? Date.now;
1131
+ this.WebSocketCtor = options.webSocketCtor ?? ws.WebSocket;
1132
+ this.random = options.random ?? Math.random;
1133
+ this._setTimeout = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
1134
+ this._clearTimeout = options.clearTimeout ?? ((h) => clearTimeout(h));
1135
+ this._setInterval = options.setInterval ?? ((fn, ms) => setInterval(fn, ms));
1136
+ this._clearInterval = options.clearInterval ?? ((h) => clearInterval(h));
1137
+ this.metrics.setConnectionState(this.state.kind);
1138
+ }
1139
+ // ─── Public API ──────────────────────────────────────────────────────
1140
+ /** Current connection state. Read-only from the caller's perspective. */
1141
+ getState() {
1142
+ return this.state;
1143
+ }
1144
+ /**
1145
+ * Kick off connection. No-op if already connecting, authenticating, or
1146
+ * ready. Throws on `AUTH_FAIL` — the caller must call `reconfigured()`
1147
+ * after rotating the API key before retrying.
1148
+ */
1149
+ start() {
1150
+ if (this.state.kind === "AUTH_FAIL") {
1151
+ throw new AgentChatChannelError(
1152
+ "terminal-auth",
1153
+ "cannot start: channel is in AUTH_FAIL \u2014 rotate api key and call reconfigured()"
1154
+ );
1155
+ }
1156
+ if (this.state.kind === "CLOSED") {
1157
+ throw new Error("cannot start: client is closed. Create a new instance.");
1158
+ }
1159
+ if (this.state.kind !== "DISCONNECTED") {
1160
+ return;
1161
+ }
1162
+ this.openSocket(1);
1163
+ }
1164
+ /**
1165
+ * Push a client-action frame. Requires `canSend(state)` — returns false
1166
+ * if the socket is not ready. Callers should queue and retry from the
1167
+ * outbound adapter (P4) rather than drop here.
1168
+ */
1169
+ send(frame) {
1170
+ if (!canSend(this.state) || !this.ws || this.ws.readyState !== ws.WebSocket.OPEN) {
1171
+ return false;
1172
+ }
1173
+ try {
1174
+ this.ws.send(JSON.stringify(frame));
1175
+ return true;
1176
+ } catch (err) {
1177
+ this.emitError(
1178
+ new AgentChatChannelError(
1179
+ classifyNetworkError(err),
1180
+ "ws send failed",
1181
+ { cause: err }
1182
+ )
1183
+ );
1184
+ return false;
1185
+ }
1186
+ }
1187
+ /**
1188
+ * Request a graceful drain. The caller-supplied deadline is a Unix-ms
1189
+ * timestamp; after it passes, the socket is force-closed regardless of
1190
+ * remaining in-flight work. Safe to call from any state.
1191
+ */
1192
+ stop(deadline) {
1193
+ const now = this.now();
1194
+ const computedDeadline = deadline ?? now + 5e3;
1195
+ if (this.state.kind === "DRAINING") {
1196
+ if (computedDeadline < this.state.deadline) {
1197
+ this.applyEvent({ type: "DRAIN_REQUESTED", now, deadline: computedDeadline });
1198
+ this.scheduleDrainDeadline(computedDeadline);
1199
+ }
1200
+ return;
1201
+ }
1202
+ if (isTerminal(this.state)) {
1203
+ return;
1204
+ }
1205
+ this.applyEvent({ type: "DRAIN_REQUESTED", now, deadline: computedDeadline });
1206
+ const after = this.state.kind;
1207
+ if (after === "DRAINING") {
1208
+ this.scheduleDrainDeadline(computedDeadline);
1209
+ } else if (after === "CLOSED") {
1210
+ this.closeSocket(1e3, "client drain (immediate)");
1211
+ this.emit("closed");
1212
+ }
1213
+ }
1214
+ /**
1215
+ * Signal that operator has rotated the API key. Transitions from
1216
+ * AUTH_FAIL back to DISCONNECTED; the next `start()` call will attempt
1217
+ * a fresh HELLO with the current config.
1218
+ */
1219
+ reconfigured() {
1220
+ if (this.state.kind !== "AUTH_FAIL") return;
1221
+ this.applyEvent({ type: "RECONFIGURED" });
1222
+ }
1223
+ on(event, handler) {
1224
+ this.listeners[event].add(handler);
1225
+ return () => {
1226
+ this.listeners[event].delete(handler);
1227
+ };
1228
+ }
1229
+ // ─── Internals: socket lifecycle ─────────────────────────────────────
1230
+ openSocket(attempt) {
1231
+ this.attempt = attempt;
1232
+ const now = this.now();
1233
+ this.applyEvent({ type: "CONNECT", now, attempt });
1234
+ const url = `${this.config.apiBase.replace(/^http/, "ws")}/v1/ws`;
1235
+ let ws;
1236
+ try {
1237
+ ws = new this.WebSocketCtor(url, {
1238
+ perMessageDeflate: false,
1239
+ // `ws` auto-pong is on by default — we keep it so heartbeat RTT
1240
+ // is symmetric even when the server initiates the ping.
1241
+ maxPayload: MAX_FRAME_BYTES
1242
+ });
1243
+ } catch (err) {
1244
+ this.emitError(
1245
+ new AgentChatChannelError(
1246
+ classifyNetworkError(err),
1247
+ "ws constructor threw",
1248
+ { cause: err }
1249
+ )
1250
+ );
1251
+ this.scheduleReconnect("ctor-failed");
1252
+ return;
1253
+ }
1254
+ this.ws = ws;
1255
+ this.bindSocket(ws);
1256
+ }
1257
+ bindSocket(ws) {
1258
+ ws.on("open", () => this.handleOpen());
1259
+ ws.on("message", (data, isBinary) => this.handleMessage(data, isBinary));
1260
+ ws.on("pong", () => this.handlePong());
1261
+ ws.on("close", (code, reason) => this.handleClose(code, reason.toString()));
1262
+ ws.on("error", (err) => {
1263
+ this.emitError(
1264
+ new AgentChatChannelError(
1265
+ classifyNetworkError(err),
1266
+ "ws transport error",
1267
+ { cause: err }
1268
+ )
1269
+ );
1270
+ });
1271
+ }
1272
+ handleOpen() {
1273
+ if (!this.ws) return;
1274
+ const now = this.now();
1275
+ this.applyEvent({ type: "SOCKET_OPEN", now });
1276
+ try {
1277
+ this.ws.send(
1278
+ JSON.stringify({ type: "hello", api_key: this.config.apiKey })
1279
+ );
1280
+ } catch (err) {
1281
+ this.emitError(
1282
+ new AgentChatChannelError(
1283
+ "retry-transient",
1284
+ "hello send failed",
1285
+ { cause: err }
1286
+ )
1287
+ );
1288
+ this.closeSocket(1011, "hello send failed");
1289
+ return;
1290
+ }
1291
+ this.helloAckTimer = this._setTimeout(() => {
1292
+ this.helloAckTimer = null;
1293
+ this.logger.warn({ timeoutMs: HELLO_ACK_TIMEOUT_MS }, "hello ack timeout");
1294
+ this.emitError(
1295
+ new AgentChatChannelError("retry-transient", "hello ack timeout")
1296
+ );
1297
+ this.closeSocket(1008, "hello ack timeout");
1298
+ }, HELLO_ACK_TIMEOUT_MS);
1299
+ }
1300
+ handleMessage(data, isBinary) {
1301
+ if (isBinary) {
1302
+ this.logger.warn({}, "received binary frame \u2014 dropping (text-only protocol)");
1303
+ return;
1304
+ }
1305
+ const received = this.now();
1306
+ const text = typeof data === "string" ? data : Array.isArray(data) ? Buffer.concat(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
1307
+ if (text.length > MAX_FRAME_BYTES) {
1308
+ this.logger.warn({ bytes: text.length }, "oversized frame \u2014 dropping");
1309
+ return;
1310
+ }
1311
+ let parsed;
1312
+ try {
1313
+ parsed = JSON.parse(text);
1314
+ } catch (err) {
1315
+ this.emitError(
1316
+ new AgentChatChannelError(
1317
+ "validation",
1318
+ "malformed json frame",
1319
+ { cause: err }
1320
+ )
1321
+ );
1322
+ return;
1323
+ }
1324
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1325
+ this.emitError(
1326
+ new AgentChatChannelError("validation", "frame not an object")
1327
+ );
1328
+ return;
1329
+ }
1330
+ const obj = parsed;
1331
+ const type = typeof obj.type === "string" ? obj.type : null;
1332
+ if (!type) {
1333
+ this.emitError(
1334
+ new AgentChatChannelError("validation", "frame missing type")
1335
+ );
1336
+ return;
1337
+ }
1338
+ if (this.state.kind === "AUTHENTICATING") {
1339
+ if (type === "hello.ok") {
1340
+ this.handleHelloOk(received);
1341
+ return;
1342
+ }
1343
+ if (type === "hello.error" || type === "auth.rejected" || type === "error") {
1344
+ const reason = this.extractReason(obj) ?? "auth rejected";
1345
+ this.handleAuthRejected(reason);
1346
+ return;
1347
+ }
1348
+ this.logger.warn({ type }, "frame arrived before hello.ok \u2014 ignoring");
1349
+ return;
1350
+ }
1351
+ const payload = this.extractPayload(obj);
1352
+ this.emit("inboundFrame", {
1353
+ type,
1354
+ payload,
1355
+ receivedAt: received,
1356
+ raw: parsed
1357
+ });
1358
+ }
1359
+ extractReason(obj) {
1360
+ const payload = obj.payload;
1361
+ if (payload && typeof payload === "object") {
1362
+ const msg = payload.message;
1363
+ if (typeof msg === "string") return msg;
1364
+ const reason = payload.reason;
1365
+ if (typeof reason === "string") return reason;
1366
+ }
1367
+ const top = obj.message ?? obj.reason;
1368
+ return typeof top === "string" ? top : void 0;
1369
+ }
1370
+ extractPayload(obj) {
1371
+ const payload = obj.payload;
1372
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
1373
+ }
1374
+ handleHelloOk(now) {
1375
+ if (this.helloAckTimer) {
1376
+ this._clearTimeout(this.helloAckTimer);
1377
+ this.helloAckTimer = null;
1378
+ }
1379
+ this.applyEvent({ type: "HELLO_OK", now });
1380
+ this.attempt = 0;
1381
+ this.startHeartbeat();
1382
+ this.emit("authenticated", now);
1383
+ }
1384
+ handleAuthRejected(reason) {
1385
+ if (this.helloAckTimer) {
1386
+ this._clearTimeout(this.helloAckTimer);
1387
+ this.helloAckTimer = null;
1388
+ }
1389
+ this.logger.error({ reason }, "auth rejected \u2014 moving to AUTH_FAIL");
1390
+ this.applyEvent({ type: "AUTH_REJECTED", reason });
1391
+ this.emitError(
1392
+ new AgentChatChannelError("terminal-auth", `auth rejected: ${reason}`)
1393
+ );
1394
+ this.closeSocket(1008, "auth rejected");
1395
+ }
1396
+ handleClose(code, reason) {
1397
+ this.now();
1398
+ const wasAuthenticating = this.state.kind === "AUTHENTICATING";
1399
+ const wasDraining = this.state.kind === "DRAINING";
1400
+ const authClass = AUTH_CLOSE_CODES.has(code);
1401
+ this.stopHeartbeat();
1402
+ if (this.helloAckTimer) {
1403
+ this._clearTimeout(this.helloAckTimer);
1404
+ this.helloAckTimer = null;
1405
+ }
1406
+ this.ws = null;
1407
+ if (authClass && wasAuthenticating) {
1408
+ this.applyEvent({ type: "AUTH_REJECTED", reason: `close ${code}: ${reason}` });
1409
+ this.emitError(
1410
+ new AgentChatChannelError(
1411
+ "terminal-auth",
1412
+ `socket closed with auth code ${code}: ${reason}`
1413
+ )
1414
+ );
1415
+ this.emit("closed");
1416
+ return;
1417
+ }
1418
+ this.applyEvent({ type: "SOCKET_CLOSED", code, reason });
1419
+ if (wasDraining || this.state.kind === "CLOSED") {
1420
+ this.emit("closed");
1421
+ return;
1422
+ }
1423
+ this.logger.info(
1424
+ { code, reason, attempt: this.attempt },
1425
+ "socket closed \u2014 scheduling reconnect"
1426
+ );
1427
+ this.scheduleReconnect(`close-${code}`);
1428
+ }
1429
+ closeSocket(code, reason) {
1430
+ if (!this.ws) return;
1431
+ try {
1432
+ this.ws.close(code, reason);
1433
+ } catch {
1434
+ }
1435
+ }
1436
+ // ─── Internals: heartbeat ────────────────────────────────────────────
1437
+ startHeartbeat() {
1438
+ this.stopHeartbeat();
1439
+ const intervalMs = this.config.ping.intervalMs;
1440
+ this.pingTimer = this._setInterval(() => this.sendPing(), intervalMs);
1441
+ }
1442
+ stopHeartbeat() {
1443
+ if (this.pingTimer) {
1444
+ this._clearInterval(this.pingTimer);
1445
+ this.pingTimer = null;
1446
+ }
1447
+ if (this.pongTimer) {
1448
+ this._clearTimeout(this.pongTimer);
1449
+ this.pongTimer = null;
1450
+ }
1451
+ }
1452
+ sendPing() {
1453
+ if (!this.ws || this.ws.readyState !== ws.WebSocket.OPEN) return;
1454
+ try {
1455
+ this.ws.ping();
1456
+ } catch (err) {
1457
+ this.emitError(
1458
+ new AgentChatChannelError(
1459
+ "retry-transient",
1460
+ "ws ping failed",
1461
+ { cause: err }
1462
+ )
1463
+ );
1464
+ return;
1465
+ }
1466
+ const timeoutMs = this.config.ping.timeoutMs;
1467
+ if (this.pongTimer) this._clearTimeout(this.pongTimer);
1468
+ this.pongTimer = this._setTimeout(() => {
1469
+ this.pongTimer = null;
1470
+ const now = this.now();
1471
+ this.logger.warn({ timeoutMs }, "ping timeout \u2014 declaring degraded");
1472
+ this.applyEvent({ type: "PING_TIMEOUT", now });
1473
+ this.closeSocket(1011, "ping timeout");
1474
+ }, timeoutMs);
1475
+ }
1476
+ handlePong() {
1477
+ if (this.pongTimer) {
1478
+ this._clearTimeout(this.pongTimer);
1479
+ this.pongTimer = null;
1480
+ }
1481
+ if (this.state.kind === "DEGRADED" && this.state.reason === "ping-timeout") {
1482
+ this.applyEvent({ type: "RECOVERED", now: this.now() });
1483
+ }
1484
+ }
1485
+ // ─── Internals: reconnect ────────────────────────────────────────────
1486
+ scheduleReconnect(reason) {
1487
+ if (this.state.kind === "CLOSED" || this.state.kind === "AUTH_FAIL") return;
1488
+ if (this.reconnectTimer) return;
1489
+ const nextAttempt = this.attempt + 1;
1490
+ if (nextAttempt > RECONNECT_HARD_CAP_ATTEMPTS) {
1491
+ const msg = `reconnect hard cap (${RECONNECT_HARD_CAP_ATTEMPTS}) reached`;
1492
+ this.logger.error({ attempt: nextAttempt }, msg);
1493
+ this.applyEvent({ type: "AUTH_REJECTED", reason: msg });
1494
+ this.emitError(
1495
+ new AgentChatChannelError("terminal-auth", msg)
1496
+ );
1497
+ return;
1498
+ }
1499
+ const delay = this.computeReconnectDelay(nextAttempt);
1500
+ this.metrics.incReconnect({ reason });
1501
+ this.logger.info(
1502
+ { attempt: nextAttempt, delayMs: delay, reason },
1503
+ "scheduling reconnect"
1504
+ );
1505
+ this.reconnectTimer = this._setTimeout(() => {
1506
+ this.reconnectTimer = null;
1507
+ this.openSocket(nextAttempt);
1508
+ }, delay);
1509
+ }
1510
+ computeReconnectDelay(attempt) {
1511
+ const { initialBackoffMs, maxBackoffMs, jitterRatio } = this.config.reconnect;
1512
+ const reconnectCount = Math.max(1, attempt - 1);
1513
+ const exp = initialBackoffMs * Math.pow(2, Math.min(reconnectCount - 1, 20));
1514
+ const capped = Math.min(exp, maxBackoffMs);
1515
+ const jitter = 1 - jitterRatio + this.random() * 2 * jitterRatio;
1516
+ return Math.max(0, Math.floor(capped * jitter));
1517
+ }
1518
+ // ─── Internals: drain ────────────────────────────────────────────────
1519
+ scheduleDrainDeadline(deadline) {
1520
+ if (this.drainTimer) this._clearTimeout(this.drainTimer);
1521
+ const now = this.now();
1522
+ const delay = Math.max(0, deadline - now);
1523
+ this.drainTimer = this._setTimeout(() => {
1524
+ this.drainTimer = null;
1525
+ if (this.state.kind === "DRAINING") {
1526
+ this.logger.warn({ deadline }, "drain deadline exceeded \u2014 force-closing");
1527
+ this.applyEvent({ type: "DRAIN_COMPLETED" });
1528
+ this.closeSocket(1e3, "drain deadline");
1529
+ this.emit("closed");
1530
+ }
1531
+ }, delay);
1532
+ }
1533
+ /**
1534
+ * Called by upper layers when their pending work has fully drained, to
1535
+ * let the WS client transition CLOSED earlier than the deadline.
1536
+ */
1537
+ drainCompleted() {
1538
+ if (this.state.kind !== "DRAINING") return;
1539
+ if (this.drainTimer) {
1540
+ this._clearTimeout(this.drainTimer);
1541
+ this.drainTimer = null;
1542
+ }
1543
+ this.applyEvent({ type: "DRAIN_COMPLETED" });
1544
+ this.closeSocket(1e3, "drain completed");
1545
+ this.emit("closed");
1546
+ }
1547
+ // ─── Internals: state machine + emit ─────────────────────────────────
1548
+ applyEvent(event) {
1549
+ const prev = this.state;
1550
+ const next = transition(prev, event);
1551
+ if (next === prev) {
1552
+ this.logger.debug(
1553
+ { from: prev.kind, event: event.type },
1554
+ "transition.invalid"
1555
+ );
1556
+ return;
1557
+ }
1558
+ this.state = next;
1559
+ this.metrics.setConnectionState(next.kind);
1560
+ this.logger.info(
1561
+ { from: prev.kind, to: next.kind, event: event.type },
1562
+ "state transition"
1563
+ );
1564
+ this.emit("stateChanged", next, prev);
1565
+ }
1566
+ emit(event, ...args) {
1567
+ for (const listener of this.listeners[event]) {
1568
+ try {
1569
+ ;
1570
+ listener(...args);
1571
+ } catch (err) {
1572
+ this.logger.error(
1573
+ { event, err: err instanceof Error ? err.message : String(err) },
1574
+ "listener threw \u2014 continuing"
1575
+ );
1576
+ }
1577
+ }
1578
+ }
1579
+ emitError(error) {
1580
+ this.metrics.incOutboundFailed({ errorClass: error.class_ });
1581
+ this.logger.warn(
1582
+ { class: error.class_, message: error.message, statusCode: error.statusCode },
1583
+ "channel error"
1584
+ );
1585
+ this.emit("error", error);
1586
+ }
1587
+ };
1588
+ var DIRECT_PREFIXES = ["conv_", "dir_"];
1589
+ var GROUP_PREFIX = "grp_";
1590
+ function classifyConversationId(id) {
1591
+ for (const p of DIRECT_PREFIXES) {
1592
+ if (id.startsWith(p)) return "direct";
1593
+ }
1594
+ if (id.startsWith(GROUP_PREFIX)) return "group";
1595
+ return null;
1596
+ }
1597
+ var messageContentSchema = zod.z.object({
1598
+ text: zod.z.string().optional(),
1599
+ data: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
1600
+ attachment_id: zod.z.string().optional()
1601
+ }).passthrough();
1602
+ var messageSchema = zod.z.object({
1603
+ id: zod.z.string(),
1604
+ conversation_id: zod.z.string(),
1605
+ sender: zod.z.string(),
1606
+ client_msg_id: zod.z.string(),
1607
+ seq: zod.z.number().int().nonnegative(),
1608
+ type: zod.z.enum(["text", "structured", "file", "system"]),
1609
+ content: messageContentSchema,
1610
+ metadata: zod.z.record(zod.z.string(), zod.z.unknown()).default({}),
1611
+ // Per-recipient delivery state lives in `message_deliveries` since
1612
+ // migration 011 — the `messages` row no longer carries `status`,
1613
+ // `delivered_at`, or `read_at`. Fresh-send envelopes over WS omit
1614
+ // them entirely; the drain path (syncUndelivered) still attaches
1615
+ // `status: 'stored'` for backlogged frames. Accept both shapes.
1616
+ status: zod.z.enum(["stored", "delivered", "read"]).optional(),
1617
+ created_at: zod.z.string(),
1618
+ delivered_at: zod.z.string().nullable().optional(),
1619
+ read_at: zod.z.string().nullable().optional()
1620
+ }).passthrough();
1621
+ var messageNewSchema = messageSchema;
1622
+ var messageReadSchema = zod.z.object({
1623
+ conversation_id: zod.z.string(),
1624
+ // Per-agent read cursor. Everything with seq <= through_seq is read.
1625
+ through_seq: zod.z.number().int().nonnegative(),
1626
+ // Handle of the reader (who moved their cursor).
1627
+ reader: zod.z.string(),
1628
+ at: zod.z.string().optional()
1629
+ }).passthrough();
1630
+ var typingSchema = zod.z.object({
1631
+ conversation_id: zod.z.string(),
1632
+ sender: zod.z.string()
1633
+ }).passthrough();
1634
+ var presenceUpdateSchema = zod.z.object({
1635
+ handle: zod.z.string(),
1636
+ status: zod.z.enum(["online", "away", "offline"]),
1637
+ last_active_at: zod.z.string().optional(),
1638
+ custom_status: zod.z.string().nullable().optional()
1639
+ }).passthrough();
1640
+ var rateLimitWarningSchema = zod.z.object({
1641
+ endpoint: zod.z.string().optional(),
1642
+ limit: zod.z.number().optional(),
1643
+ remaining: zod.z.number().optional(),
1644
+ reset_at: zod.z.string().optional(),
1645
+ message: zod.z.string().optional()
1646
+ }).passthrough();
1647
+ var groupInviteReceivedSchema = zod.z.object({
1648
+ id: zod.z.string(),
1649
+ group_id: zod.z.string(),
1650
+ group_name: zod.z.string(),
1651
+ group_description: zod.z.string().nullable().optional(),
1652
+ group_avatar_url: zod.z.string().nullable().optional(),
1653
+ group_member_count: zod.z.number().int().nonnegative(),
1654
+ inviter_handle: zod.z.string(),
1655
+ created_at: zod.z.string()
1656
+ }).passthrough();
1657
+ var groupDeletedSchema = zod.z.object({
1658
+ group_id: zod.z.string(),
1659
+ deleted_by_handle: zod.z.string(),
1660
+ deleted_at: zod.z.string()
1661
+ }).passthrough();
1662
+ function normalizeInbound(frame) {
1663
+ switch (frame.type) {
1664
+ case "message.new":
1665
+ return normalizeMessageNew(frame);
1666
+ case "message.read":
1667
+ return normalizeMessageRead(frame);
1668
+ case "typing.start":
1669
+ return normalizeTyping(frame, "start");
1670
+ case "typing.stop":
1671
+ return normalizeTyping(frame, "stop");
1672
+ case "presence.update":
1673
+ return normalizePresence(frame);
1674
+ case "rate_limit.warning":
1675
+ return normalizeRateLimitWarning(frame);
1676
+ case "group.invite.received":
1677
+ return normalizeGroupInvite(frame);
1678
+ case "group.deleted":
1679
+ return normalizeGroupDeleted(frame);
1680
+ default:
1681
+ return {
1682
+ kind: "unknown",
1683
+ type: frame.type,
1684
+ payload: frame.payload,
1685
+ receivedAt: frame.receivedAt
1686
+ };
1687
+ }
1688
+ }
1689
+ function validate(schema, payload, eventType) {
1690
+ const parsed = schema.safeParse(payload);
1691
+ if (!parsed.success) {
1692
+ throw new AgentChatChannelError(
1693
+ "validation",
1694
+ `inbound ${eventType} schema invalid: ${parsed.error.message}`,
1695
+ { cause: parsed.error }
1696
+ );
1697
+ }
1698
+ return parsed.data;
1699
+ }
1700
+ function requireConvKind(conversationId, eventType) {
1701
+ const kind = classifyConversationId(conversationId);
1702
+ if (!kind) {
1703
+ throw new AgentChatChannelError(
1704
+ "validation",
1705
+ `inbound ${eventType} has unknown conversation id prefix: ${conversationId}`
1706
+ );
1707
+ }
1708
+ return kind;
1709
+ }
1710
+ function normalizeMessageNew(frame) {
1711
+ const msg = validate(messageNewSchema, frame.payload, "message.new");
1712
+ const conversationKind = requireConvKind(msg.conversation_id, "message.new");
1713
+ return {
1714
+ kind: "message",
1715
+ conversationKind,
1716
+ conversationId: msg.conversation_id,
1717
+ sender: msg.sender,
1718
+ messageId: msg.id,
1719
+ clientMsgId: msg.client_msg_id,
1720
+ seq: msg.seq,
1721
+ messageType: msg.type,
1722
+ content: {
1723
+ text: msg.content.text,
1724
+ data: msg.content.data,
1725
+ attachmentId: msg.content.attachment_id
1726
+ },
1727
+ metadata: msg.metadata,
1728
+ status: msg.status ?? null,
1729
+ createdAt: msg.created_at,
1730
+ deliveredAt: msg.delivered_at ?? null,
1731
+ readAt: msg.read_at ?? null,
1732
+ receivedAt: frame.receivedAt
1733
+ };
1734
+ }
1735
+ function normalizeMessageRead(frame) {
1736
+ const body = validate(messageReadSchema, frame.payload, "message.read");
1737
+ const conversationKind = requireConvKind(body.conversation_id, "message.read");
1738
+ return {
1739
+ kind: "read-receipt",
1740
+ conversationKind,
1741
+ conversationId: body.conversation_id,
1742
+ reader: body.reader,
1743
+ throughSeq: body.through_seq,
1744
+ at: body.at ?? null,
1745
+ receivedAt: frame.receivedAt
1746
+ };
1747
+ }
1748
+ function normalizeTyping(frame, action) {
1749
+ const body = validate(typingSchema, frame.payload, `typing.${action}`);
1750
+ const conversationKind = requireConvKind(body.conversation_id, `typing.${action}`);
1751
+ return {
1752
+ kind: "typing",
1753
+ action,
1754
+ conversationKind,
1755
+ conversationId: body.conversation_id,
1756
+ sender: body.sender,
1757
+ receivedAt: frame.receivedAt
1758
+ };
1759
+ }
1760
+ function normalizePresence(frame) {
1761
+ const body = validate(presenceUpdateSchema, frame.payload, "presence.update");
1762
+ return {
1763
+ kind: "presence",
1764
+ handle: body.handle,
1765
+ status: body.status,
1766
+ lastActiveAt: body.last_active_at ?? null,
1767
+ customStatus: body.custom_status ?? null,
1768
+ receivedAt: frame.receivedAt
1769
+ };
1770
+ }
1771
+ function normalizeRateLimitWarning(frame) {
1772
+ const body = validate(rateLimitWarningSchema, frame.payload, "rate_limit.warning");
1773
+ return {
1774
+ kind: "rate-limit-warning",
1775
+ endpoint: body.endpoint ?? null,
1776
+ limit: body.limit ?? null,
1777
+ remaining: body.remaining ?? null,
1778
+ resetAt: body.reset_at ?? null,
1779
+ message: body.message ?? null,
1780
+ receivedAt: frame.receivedAt
1781
+ };
1782
+ }
1783
+ function normalizeGroupInvite(frame) {
1784
+ const body = validate(groupInviteReceivedSchema, frame.payload, "group.invite.received");
1785
+ return {
1786
+ kind: "group-invite",
1787
+ inviteId: body.id,
1788
+ groupId: body.group_id,
1789
+ groupName: body.group_name,
1790
+ groupDescription: body.group_description ?? null,
1791
+ groupAvatarUrl: body.group_avatar_url ?? null,
1792
+ groupMemberCount: body.group_member_count,
1793
+ inviterHandle: body.inviter_handle,
1794
+ createdAt: body.created_at,
1795
+ receivedAt: frame.receivedAt
1796
+ };
1797
+ }
1798
+ function normalizeGroupDeleted(frame) {
1799
+ const body = validate(groupDeletedSchema, frame.payload, "group.deleted");
1800
+ return {
1801
+ kind: "group-deleted",
1802
+ groupId: body.group_id,
1803
+ deletedByHandle: body.deleted_by_handle,
1804
+ deletedAt: body.deleted_at,
1805
+ receivedAt: frame.receivedAt
1806
+ };
1807
+ }
1808
+
1809
+ // src/retry.ts
1810
+ function defaultSleep(ms) {
1811
+ return new Promise((resolve) => setTimeout(resolve, ms));
1812
+ }
1813
+ async function retryWithPolicy(fn, policy) {
1814
+ const random = policy.random ?? Math.random;
1815
+ const sleep = policy.sleep ?? defaultSleep;
1816
+ let totalDelayMs = 0;
1817
+ let lastErr;
1818
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
1819
+ try {
1820
+ const result = await fn(attempt);
1821
+ return { result, attempts: attempt, totalDelayMs };
1822
+ } catch (err) {
1823
+ lastErr = err;
1824
+ if (!(err instanceof AgentChatChannelError)) {
1825
+ throw err;
1826
+ }
1827
+ if (isTerminalClass(err.class_)) {
1828
+ throw err;
1829
+ }
1830
+ if (attempt >= policy.maxAttempts) {
1831
+ throw err;
1832
+ }
1833
+ const delay = backoffDelay({
1834
+ attempt,
1835
+ errorClass: err.class_,
1836
+ retryAfterMs: err.retryAfterMs,
1837
+ policy,
1838
+ random
1839
+ });
1840
+ totalDelayMs += delay;
1841
+ await sleep(delay);
1842
+ }
1843
+ }
1844
+ throw lastErr;
1845
+ }
1846
+ function isTerminalClass(class_) {
1847
+ return class_ === "terminal-auth" || class_ === "terminal-user" || class_ === "validation" || class_ === "idempotent-replay";
1848
+ }
1849
+ function backoffDelay({ attempt, errorClass, retryAfterMs, policy, random }) {
1850
+ if (errorClass === "retry-rate" && typeof retryAfterMs === "number") {
1851
+ const jitter2 = 1 + random() * policy.jitterRatio;
1852
+ return Math.min(policy.maxBackoffMs, Math.floor(retryAfterMs * jitter2));
1853
+ }
1854
+ const exp = policy.initialBackoffMs * Math.pow(2, Math.min(attempt - 1, 20));
1855
+ const capped = Math.min(exp, policy.maxBackoffMs);
1856
+ const jitter = 1 - policy.jitterRatio + random() * 2 * policy.jitterRatio;
1857
+ return Math.max(0, Math.floor(capped * jitter));
1858
+ }
1859
+ var CircuitBreaker = class {
1860
+ opts;
1861
+ now;
1862
+ state = "closed";
1863
+ failureTimestamps = [];
1864
+ openedAt = null;
1865
+ halfOpenAt = null;
1866
+ constructor(opts) {
1867
+ this.opts = opts;
1868
+ this.now = opts.now ?? Date.now;
1869
+ }
1870
+ snapshot() {
1871
+ return {
1872
+ state: this.state,
1873
+ recentFailures: this.failureTimestamps.length,
1874
+ openedAt: this.openedAt,
1875
+ halfOpenAt: this.halfOpenAt
1876
+ };
1877
+ }
1878
+ /**
1879
+ * Check state at call time.
1880
+ * - `closed` / `half-open` → returns `{ allow: true }`. Caller must
1881
+ * invoke `onSuccess()` / `onFailure()` after the attempt.
1882
+ * - `open` → returns `{ allow: false, reason }` so the caller can
1883
+ * fast-fail without making the network call. If the cooldown has
1884
+ * elapsed, transitions to `half-open` first and allows one probe.
1885
+ */
1886
+ precheck() {
1887
+ if (this.state === "open") {
1888
+ const now = this.now();
1889
+ const elapsed = now - (this.openedAt ?? now);
1890
+ if (elapsed >= this.opts.cooldownMs) {
1891
+ this.state = "half-open";
1892
+ this.halfOpenAt = now;
1893
+ return { allow: true };
1894
+ }
1895
+ return { allow: false, reason: "circuit open \u2014 API appears unhealthy" };
1896
+ }
1897
+ return { allow: true };
1898
+ }
1899
+ onSuccess() {
1900
+ if (this.state === "half-open") {
1901
+ this.state = "closed";
1902
+ this.openedAt = null;
1903
+ this.halfOpenAt = null;
1904
+ }
1905
+ this.failureTimestamps = [];
1906
+ }
1907
+ /**
1908
+ * Record a failure. Only transient classes count toward the breaker —
1909
+ * terminal-auth/terminal-user/validation failures are caller bugs or
1910
+ * server contract violations, not "API down" signals.
1911
+ */
1912
+ onFailure(errorClass) {
1913
+ if (isTerminalClass(errorClass)) return;
1914
+ const now = this.now();
1915
+ if (this.state === "half-open") {
1916
+ this.state = "open";
1917
+ this.openedAt = now;
1918
+ this.halfOpenAt = null;
1919
+ return;
1920
+ }
1921
+ this.failureTimestamps.push(now);
1922
+ this.trimOldFailures(now);
1923
+ if (this.failureTimestamps.length >= this.opts.failureThreshold) {
1924
+ this.state = "open";
1925
+ this.openedAt = now;
1926
+ }
1927
+ }
1928
+ trimOldFailures(now) {
1929
+ const cutoff = now - this.opts.windowMs;
1930
+ let firstFresh = 0;
1931
+ while (firstFresh < this.failureTimestamps.length && this.failureTimestamps[firstFresh] < cutoff) {
1932
+ firstFresh++;
1933
+ }
1934
+ if (firstFresh > 0) this.failureTimestamps.splice(0, firstFresh);
1935
+ }
1936
+ };
1937
+
1938
+ // src/version.ts
1939
+ var PACKAGE_VERSION = "0.2.0";
1940
+
1941
+ // src/outbound.ts
1942
+ var DEFAULT_RETRY_POLICY = {
1943
+ maxAttempts: 4,
1944
+ initialBackoffMs: 250,
1945
+ maxBackoffMs: 1e4,
1946
+ jitterRatio: 0.3
1947
+ };
1948
+ var DEFAULT_CIRCUIT_OPTIONS = {
1949
+ failureThreshold: 10,
1950
+ windowMs: 6e4,
1951
+ cooldownMs: 3e4
1952
+ };
1953
+ var OutboundAdapter = class {
1954
+ config;
1955
+ logger;
1956
+ metrics;
1957
+ fetchImpl;
1958
+ now;
1959
+ breaker;
1960
+ retryPolicy;
1961
+ onBacklogWarning;
1962
+ // Backpressure bookkeeping.
1963
+ inFlight = 0;
1964
+ queue = [];
1965
+ queueHardCap;
1966
+ constructor(opts) {
1967
+ this.config = opts.config;
1968
+ this.logger = opts.logger.child({ component: "outbound" });
1969
+ this.metrics = opts.metrics;
1970
+ this.fetchImpl = opts.fetch ?? fetch;
1971
+ this.now = opts.now ?? Date.now;
1972
+ this.onBacklogWarning = opts.onBacklogWarning;
1973
+ this.breaker = new CircuitBreaker(opts.circuitBreaker ?? DEFAULT_CIRCUIT_OPTIONS);
1974
+ this.retryPolicy = {
1975
+ ...DEFAULT_RETRY_POLICY,
1976
+ ...opts.retryPolicy,
1977
+ now: opts.now,
1978
+ random: opts.random,
1979
+ sleep: opts.sleep
1980
+ };
1981
+ this.queueHardCap = Math.max(10, this.config.outbound.maxInFlight * 10);
1982
+ }
1983
+ /**
1984
+ * Send a message. Returns on success; throws `AgentChatChannelError` on
1985
+ * terminal or exhausted-retry failure. The returned `SendResult.message`
1986
+ * is the row the server minted (or echoed back on idempotent replay).
1987
+ */
1988
+ async sendMessage(input) {
1989
+ const clientMsgId = input.clientMsgId ?? this.mintClientMsgId();
1990
+ const correlationId = input.correlationId ?? clientMsgId;
1991
+ const log = this.logger.child({ clientMsgId, correlationId, kind: input.kind });
1992
+ const precheck = this.breaker.precheck();
1993
+ if (!precheck.allow) {
1994
+ this.metrics.incOutboundFailed({ errorClass: "retry-transient" });
1995
+ throw new AgentChatChannelError("retry-transient", precheck.reason);
1996
+ }
1997
+ await this.acquireSlot();
1998
+ const startedAt = this.now();
1999
+ try {
2000
+ const outcome = await retryWithPolicy(
2001
+ (attempt) => this.sendOnce({ input, clientMsgId, attempt, log }),
2002
+ this.retryPolicy
2003
+ );
2004
+ const endedAt = this.now();
2005
+ this.breaker.onSuccess();
2006
+ this.metrics.incOutboundSent({ kind: "message" });
2007
+ this.metrics.observeSendLatency(endedAt - startedAt);
2008
+ return {
2009
+ ...outcome.result,
2010
+ attempts: outcome.attempts,
2011
+ latencyMs: endedAt - startedAt
2012
+ };
2013
+ } catch (err) {
2014
+ if (err instanceof AgentChatChannelError) {
2015
+ this.breaker.onFailure(err.class_);
2016
+ this.metrics.incOutboundFailed({ errorClass: err.class_ });
2017
+ log.warn(
2018
+ { class: err.class_, status: err.statusCode, msg: err.message },
2019
+ "send failed"
2020
+ );
2021
+ } else {
2022
+ this.metrics.incOutboundFailed({ errorClass: "retry-transient" });
2023
+ log.error(
2024
+ { err: err instanceof Error ? err.message : String(err) },
2025
+ "send failed \u2014 unexpected error"
2026
+ );
2027
+ }
2028
+ throw err;
2029
+ } finally {
2030
+ this.releaseSlot();
2031
+ }
2032
+ }
2033
+ /** Current state for observability / health checks. */
2034
+ snapshot() {
2035
+ return {
2036
+ inFlight: this.inFlight,
2037
+ queued: this.queue.length,
2038
+ circuit: this.breaker.snapshot()
2039
+ };
2040
+ }
2041
+ // ─── Internals ───────────────────────────────────────────────────────
2042
+ async sendOnce(args) {
2043
+ const { input, clientMsgId, attempt, log } = args;
2044
+ const body = this.buildBody(input, clientMsgId);
2045
+ const url = `${this.config.apiBase}/v1/messages`;
2046
+ const headers = {
2047
+ "authorization": `Bearer ${this.config.apiKey}`,
2048
+ "content-type": "application/json",
2049
+ "user-agent": `@agentchatme/openclaw/${PACKAGE_VERSION} (+attempt=${attempt})`
2050
+ };
2051
+ let res;
2052
+ try {
2053
+ res = await this.fetchImpl(url, {
2054
+ method: "POST",
2055
+ headers,
2056
+ body: JSON.stringify(body)
2057
+ });
2058
+ } catch (err) {
2059
+ throw new AgentChatChannelError(
2060
+ classifyNetworkError(err),
2061
+ `POST /v1/messages network error: ${err instanceof Error ? err.message : String(err)}`,
2062
+ { cause: err }
2063
+ );
2064
+ }
2065
+ const requestId = res.headers.get("x-request-id");
2066
+ const idempotentReplay = res.headers.get("idempotent-replay") === "true";
2067
+ const backlogWarning = this.parseBacklogWarning(res.headers.get("x-backlog-warning"));
2068
+ if (res.ok) {
2069
+ const message = await res.json().catch(() => null);
2070
+ if (!message || typeof message !== "object") {
2071
+ throw new AgentChatChannelError(
2072
+ "validation",
2073
+ "POST /v1/messages returned non-JSON success body",
2074
+ { statusCode: res.status }
2075
+ );
2076
+ }
2077
+ if (idempotentReplay) {
2078
+ log.info({ status: res.status, requestId }, "idempotent replay \u2014 server echoed existing message");
2079
+ }
2080
+ if (backlogWarning && this.onBacklogWarning) {
2081
+ try {
2082
+ this.onBacklogWarning(backlogWarning);
2083
+ } catch (err) {
2084
+ log.error(
2085
+ { err: err instanceof Error ? err.message : String(err) },
2086
+ "onBacklogWarning handler threw \u2014 swallowed to protect send path"
2087
+ );
2088
+ }
2089
+ }
2090
+ return { message, backlogWarning, idempotentReplay, requestId };
2091
+ }
2092
+ const errorBody = await res.json().catch(() => null);
2093
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"), this.now());
2094
+ const errorClass = classifyHttpStatus(res.status, res.headers.get("retry-after"));
2095
+ const serverMessage = errorBody?.message ?? `HTTP ${res.status}`;
2096
+ throw new AgentChatChannelError(
2097
+ errorClass,
2098
+ typeof serverMessage === "string" ? serverMessage : `HTTP ${res.status}`,
2099
+ {
2100
+ statusCode: res.status,
2101
+ retryAfterMs: retryAfter
2102
+ }
2103
+ );
2104
+ }
2105
+ buildBody(input, clientMsgId) {
2106
+ const content = {};
2107
+ if (input.content.text !== void 0) content.text = input.content.text;
2108
+ if (input.content.data !== void 0) content.data = input.content.data;
2109
+ if (input.content.attachmentId !== void 0) content.attachment_id = input.content.attachmentId;
2110
+ if (Object.keys(content).length === 0) {
2111
+ throw new AgentChatChannelError(
2112
+ "terminal-user",
2113
+ "outbound message has empty content \u2014 at least one of text/data/attachmentId required"
2114
+ );
2115
+ }
2116
+ const body = {
2117
+ client_msg_id: clientMsgId,
2118
+ content
2119
+ };
2120
+ if (input.type) body.type = input.type;
2121
+ if (input.metadata) body.metadata = input.metadata;
2122
+ if (input.kind === "direct") body.to = input.to;
2123
+ else body.conversation_id = input.conversationId;
2124
+ return body;
2125
+ }
2126
+ parseBacklogWarning(header) {
2127
+ if (!header) return null;
2128
+ const eq = header.indexOf("=");
2129
+ if (eq <= 0 || eq === header.length - 1) return null;
2130
+ const recipientHandle = header.slice(0, eq).trim();
2131
+ const countStr = header.slice(eq + 1).trim();
2132
+ const undeliveredCount = Number(countStr);
2133
+ if (!recipientHandle) return null;
2134
+ if (!Number.isFinite(undeliveredCount) || !Number.isInteger(undeliveredCount)) return null;
2135
+ return { recipientHandle, undeliveredCount };
2136
+ }
2137
+ mintClientMsgId() {
2138
+ const cryptoObj = globalThis.crypto;
2139
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
2140
+ return `cmsg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
2141
+ }
2142
+ // ─── Backpressure ────────────────────────────────────────────────────
2143
+ async acquireSlot() {
2144
+ if (this.inFlight < this.config.outbound.maxInFlight) {
2145
+ this.inFlight++;
2146
+ this.metrics.setInFlightDepth(this.inFlight);
2147
+ return;
2148
+ }
2149
+ if (this.queue.length >= this.queueHardCap) {
2150
+ throw new AgentChatChannelError(
2151
+ "retry-transient",
2152
+ `outbound queue full (${this.queue.length}) \u2014 shedding load`
2153
+ );
2154
+ }
2155
+ return new Promise((resolve) => {
2156
+ this.queue.push(() => {
2157
+ this.inFlight++;
2158
+ this.metrics.setInFlightDepth(this.inFlight);
2159
+ resolve();
2160
+ });
2161
+ });
2162
+ }
2163
+ releaseSlot() {
2164
+ this.inFlight = Math.max(0, this.inFlight - 1);
2165
+ this.metrics.setInFlightDepth(this.inFlight);
2166
+ const next = this.queue.shift();
2167
+ if (next) next();
2168
+ }
2169
+ };
2170
+
2171
+ // src/runtime.ts
2172
+ var AgentchatChannelRuntime = class {
2173
+ config;
2174
+ handlers;
2175
+ logger;
2176
+ metrics;
2177
+ ws;
2178
+ outbound;
2179
+ now;
2180
+ started = false;
2181
+ authenticated = false;
2182
+ stopPromise = null;
2183
+ constructor(opts) {
2184
+ this.config = opts.config;
2185
+ this.handlers = opts.handlers ?? {};
2186
+ this.now = opts.now ?? Date.now;
2187
+ this.logger = opts.logger ?? createLogger({
2188
+ level: this.config.observability.logLevel,
2189
+ redactKeys: this.config.observability.redactKeys
2190
+ });
2191
+ this.metrics = opts.metrics ?? createNoopMetrics();
2192
+ this.ws = new AgentchatWsClient({
2193
+ config: this.config,
2194
+ logger: this.logger,
2195
+ metrics: this.metrics,
2196
+ webSocketCtor: opts.webSocketCtor,
2197
+ now: opts.now,
2198
+ random: opts.random
2199
+ });
2200
+ this.outbound = new OutboundAdapter({
2201
+ config: this.config,
2202
+ logger: this.logger,
2203
+ metrics: this.metrics,
2204
+ fetch: opts.fetch,
2205
+ now: opts.now,
2206
+ random: opts.random,
2207
+ sleep: opts.sleep,
2208
+ onBacklogWarning: (warning) => {
2209
+ try {
2210
+ this.handlers.onBacklogWarning?.(warning);
2211
+ } catch (err) {
2212
+ this.logger.error(
2213
+ { err: err instanceof Error ? err.message : String(err) },
2214
+ "onBacklogWarning handler threw"
2215
+ );
2216
+ }
2217
+ }
2218
+ });
2219
+ this.bindWsEvents();
2220
+ }
2221
+ /** Open the transport. Idempotent — subsequent calls are no-ops. */
2222
+ start() {
2223
+ if (this.started) return;
2224
+ this.started = true;
2225
+ this.ws.start();
2226
+ }
2227
+ /**
2228
+ * Graceful shutdown. Waits for outbound in-flight to drain up to the
2229
+ * deadline, then force-closes the WS. Returns a promise that resolves
2230
+ * once the WS has emitted `closed`.
2231
+ */
2232
+ stop(deadlineMs) {
2233
+ if (this.stopPromise) return this.stopPromise;
2234
+ const deadline = deadlineMs ?? this.now() + 5e3;
2235
+ this.stopPromise = new Promise((resolve) => {
2236
+ const off = this.ws.on("closed", () => {
2237
+ off();
2238
+ resolve();
2239
+ });
2240
+ this.ws.stop(deadline);
2241
+ });
2242
+ void this.pollUntilIdle(deadline);
2243
+ return this.stopPromise;
2244
+ }
2245
+ async pollUntilIdle(deadline) {
2246
+ const step = 10;
2247
+ for (; ; ) {
2248
+ const snap = this.outbound.snapshot();
2249
+ if (snap.inFlight === 0 && snap.queued === 0) {
2250
+ this.ws.drainCompleted();
2251
+ return;
2252
+ }
2253
+ if (this.now() >= deadline) return;
2254
+ await new Promise((r) => setTimeout(r, step));
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Send an outbound message. Delegates to the OutboundAdapter; callers
2259
+ * should handle `AgentChatChannelError` with class dispatch.
2260
+ */
2261
+ sendMessage(input) {
2262
+ return this.outbound.sendMessage(input);
2263
+ }
2264
+ /** Push a client-action frame over the WS (typing, read-ack, presence). */
2265
+ sendWsAction(type, payload) {
2266
+ return this.ws.send({ type, payload });
2267
+ }
2268
+ /**
2269
+ * Operator has rotated the API key — signal the WS client so it can
2270
+ * exit AUTH_FAIL. The caller is responsible for creating a new runtime
2271
+ * with the updated config OR for calling this after config hot-reload.
2272
+ */
2273
+ reconfigured() {
2274
+ this.ws.reconfigured();
2275
+ }
2276
+ getHealth() {
2277
+ const outSnap = this.outbound.snapshot();
2278
+ return {
2279
+ state: this.ws.getState(),
2280
+ authenticated: this.authenticated,
2281
+ outbound: {
2282
+ inFlight: outSnap.inFlight,
2283
+ queued: outSnap.queued,
2284
+ circuitState: outSnap.circuit.state
2285
+ }
2286
+ };
2287
+ }
2288
+ // ─── Internals ───────────────────────────────────────────────────────
2289
+ bindWsEvents() {
2290
+ this.ws.on("stateChanged", (next, prev) => {
2291
+ if (next.kind !== "READY" && next.kind !== "DEGRADED") {
2292
+ this.authenticated = false;
2293
+ }
2294
+ try {
2295
+ this.handlers.onStateChanged?.(next, prev);
2296
+ } catch (err) {
2297
+ this.logger.error(
2298
+ { err: err instanceof Error ? err.message : String(err) },
2299
+ "onStateChanged handler threw"
2300
+ );
2301
+ }
2302
+ });
2303
+ this.ws.on("authenticated", (at) => {
2304
+ this.authenticated = true;
2305
+ try {
2306
+ this.handlers.onAuthenticated?.(at);
2307
+ } catch (err) {
2308
+ this.logger.error(
2309
+ { err: err instanceof Error ? err.message : String(err) },
2310
+ "onAuthenticated handler threw"
2311
+ );
2312
+ }
2313
+ });
2314
+ this.ws.on("inboundFrame", (frame) => {
2315
+ this.dispatchFrame(frame);
2316
+ });
2317
+ this.ws.on("error", (error) => {
2318
+ try {
2319
+ this.handlers.onError?.(error);
2320
+ } catch (err) {
2321
+ this.logger.error(
2322
+ { err: err instanceof Error ? err.message : String(err) },
2323
+ "onError handler threw"
2324
+ );
2325
+ }
2326
+ });
2327
+ }
2328
+ dispatchFrame(frame) {
2329
+ let normalized;
2330
+ try {
2331
+ normalized = normalizeInbound(frame);
2332
+ } catch (err) {
2333
+ if (err instanceof AgentChatChannelError) {
2334
+ this.logger.warn(
2335
+ { type: frame.type, class: err.class_, message: err.message },
2336
+ "inbound validation failed \u2014 dropping"
2337
+ );
2338
+ try {
2339
+ this.handlers.onValidationError?.(err, frame);
2340
+ } catch (handlerErr) {
2341
+ this.logger.error(
2342
+ { err: handlerErr instanceof Error ? handlerErr.message : String(handlerErr) },
2343
+ "onValidationError handler threw"
2344
+ );
2345
+ }
2346
+ } else {
2347
+ this.logger.error(
2348
+ { err: err instanceof Error ? err.message : String(err) },
2349
+ "inbound normalizer threw unexpectedly"
2350
+ );
2351
+ }
2352
+ return;
2353
+ }
2354
+ this.recordInboundMetric(normalized);
2355
+ try {
2356
+ const result = this.handlers.onInbound?.(normalized);
2357
+ if (result instanceof Promise) {
2358
+ result.catch((err) => {
2359
+ this.logger.error(
2360
+ { err: err instanceof Error ? err.message : String(err), type: normalized.kind },
2361
+ "async onInbound handler rejected"
2362
+ );
2363
+ });
2364
+ }
2365
+ } catch (err) {
2366
+ this.logger.error(
2367
+ { err: err instanceof Error ? err.message : String(err), type: normalized.kind },
2368
+ "onInbound handler threw"
2369
+ );
2370
+ }
2371
+ }
2372
+ recordInboundMetric(n) {
2373
+ switch (n.kind) {
2374
+ case "message":
2375
+ this.metrics.incInboundDelivered({
2376
+ kind: n.conversationKind === "group" ? "group-message" : "message"
2377
+ });
2378
+ break;
2379
+ case "typing":
2380
+ this.metrics.incInboundDelivered({ kind: "typing" });
2381
+ break;
2382
+ case "read-receipt":
2383
+ this.metrics.incInboundDelivered({ kind: "read" });
2384
+ break;
2385
+ case "presence":
2386
+ this.metrics.incInboundDelivered({ kind: "presence" });
2387
+ break;
2388
+ case "rate-limit-warning":
2389
+ this.metrics.incInboundDelivered({ kind: "rate-limit-warning" });
2390
+ break;
2391
+ }
2392
+ }
2393
+ };
2394
+
2395
+ exports.AGENTCHAT_CHANNEL_ID = AGENTCHAT_CHANNEL_ID;
2396
+ exports.AGENTCHAT_DEFAULT_ACCOUNT_ID = AGENTCHAT_DEFAULT_ACCOUNT_ID;
2397
+ exports.AgentChatChannelError = AgentChatChannelError;
2398
+ exports.AgentchatChannelRuntime = AgentchatChannelRuntime;
2399
+ exports.agentchatChannelEntry = agentchatChannelEntry;
2400
+ exports.agentchatPlugin = agentchatPlugin;
2401
+ exports.agentchatSetupEntry = agentchatSetupEntry;
2402
+ exports.agentchatSetupPlugin = agentchatPlugin;
2403
+ exports.agentchatSetupWizard = agentchatSetupWizard;
2404
+ exports.assertApiKeyValid = assertApiKeyValid;
2405
+ exports.default = agentchatChannelEntry;
2406
+ exports.hasAgentChatConfiguredState = hasAgentChatConfiguredState;
2407
+ exports.parseChannelConfig = parseChannelConfig;
2408
+ exports.registerAgentStart = registerAgentStart;
2409
+ exports.registerAgentVerify = registerAgentVerify;
2410
+ exports.validateApiKey = validateApiKey;
2411
+ //# sourceMappingURL=index.cjs.map
2412
+ //# sourceMappingURL=index.cjs.map