@botcord/daemon 0.2.63 → 0.2.65

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.
@@ -109,6 +109,12 @@ export class OpenclawAcpAdapter {
109
109
  // synthetic test calls).
110
110
  conversationKey: stringField(opts.context, "conversationKey") ?? "default",
111
111
  });
112
+ const traceContext = {
113
+ turnId: stringField(opts.context, "turnId"),
114
+ messageId: stringField(opts.context, "messageId"),
115
+ roomId: stringField(opts.context, "roomId"),
116
+ topicId: nullableStringField(opts.context, "topicId"),
117
+ };
112
118
  const key = poolKey(opts.accountId, gateway.name);
113
119
  let handle;
114
120
  try {
@@ -199,7 +205,17 @@ export class OpenclawAcpAdapter {
199
205
  throw new Error(`newSession failed: ${err.message}`);
200
206
  }
201
207
  }
202
- handle.subscribers.set(acpSessionId, onNotification);
208
+ handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
209
+ handle.trace?.write({
210
+ stream: "turn_context",
211
+ ...traceContext,
212
+ params: {
213
+ sessionId: acpSessionId,
214
+ sessionKey,
215
+ openclawAgent,
216
+ cwd: opts.cwd,
217
+ },
218
+ });
203
219
  if (opts.signal?.aborted) {
204
220
  return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
205
221
  }
@@ -232,7 +248,18 @@ export class OpenclawAcpAdapter {
232
248
  });
233
249
  handle.subscribers.delete(acpSessionId);
234
250
  acpSessionId = fresh;
235
- handle.subscribers.set(acpSessionId, onNotification);
251
+ handle.subscribers.set(acpSessionId, { notify: onNotification, traceContext });
252
+ handle.trace?.write({
253
+ stream: "turn_context",
254
+ ...traceContext,
255
+ params: {
256
+ sessionId: acpSessionId,
257
+ sessionKey,
258
+ openclawAgent,
259
+ cwd: opts.cwd,
260
+ recreatedFrom: oldSessionId,
261
+ },
262
+ });
236
263
  log.info("openclaw-acp.session-recreated", {
237
264
  accountId: opts.accountId,
238
265
  oldSessionId,
@@ -525,19 +552,20 @@ function routeMessage(handle, msg) {
525
552
  }
526
553
  // Notification.
527
554
  if (msg?.method && msg?.params) {
555
+ const sid = msg.params?.sessionId;
556
+ const sub = typeof sid === "string" ? handle.subscribers.get(sid) : undefined;
528
557
  handle.trace?.write({
529
558
  stream: "rpc_in",
530
559
  direction: "in",
531
560
  method: msg.method,
532
561
  status: "notification",
562
+ ...(sub?.traceContext ?? {}),
533
563
  params: msg.params,
534
564
  });
535
- const sid = msg.params?.sessionId;
536
565
  if (typeof sid === "string") {
537
- const sub = handle.subscribers.get(sid);
538
566
  if (sub) {
539
567
  try {
540
- sub({ method: msg.method, params: msg.params });
568
+ sub.notify({ method: msg.method, params: msg.params });
541
569
  }
542
570
  catch (err) {
543
571
  log.warn("openclaw-acp.subscriber-threw", {
@@ -913,6 +941,14 @@ function stringField(bag, key) {
913
941
  const v = bag[key];
914
942
  return typeof v === "string" && v.length > 0 ? v : undefined;
915
943
  }
944
+ function nullableStringField(bag, key) {
945
+ if (!bag)
946
+ return undefined;
947
+ const v = bag[key];
948
+ if (v === null)
949
+ return null;
950
+ return typeof v === "string" && v.length > 0 ? v : undefined;
951
+ }
916
952
  /**
917
953
  * Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
918
954
  * always included to prevent two daemon agents from colliding on the same
@@ -6,9 +6,13 @@ import type { GatewayLogger } from "./log.js";
6
6
  export declare const TRANSCRIPT_TEXT_LIMIT: number;
7
7
  /** Soft cap on a single transcript file before rotation. */
8
8
  export declare const TRANSCRIPT_FILE_LIMIT: number;
9
+ /** Default retention window for transcript JSONL files. */
10
+ export declare const TRANSCRIPT_RETENTION_MS: number;
11
+ /** Minimum interval between background transcript retention sweeps. */
12
+ export declare const TRANSCRIPT_CLEANUP_INTERVAL_MS: number;
9
13
  /** Default root directory for per-agent transcript trees. */
10
14
  export declare function defaultTranscriptRoot(): string;
11
- export type TranscriptRecordKind = "inbound" | "dispatched" | "compose_failed" | "outbound" | "turn_error" | "attention_skipped" | "dropped";
15
+ export type TranscriptRecordKind = "inbound" | "dispatched" | "block" | "compose_failed" | "outbound" | "turn_error" | "attention_skipped" | "dropped";
12
16
  export interface TranscriptRecordBase {
13
17
  ts: string;
14
18
  kind: TranscriptRecordKind;
@@ -45,6 +49,14 @@ export interface DispatchedTranscriptRecord extends TranscriptRecordBase {
45
49
  composedText?: true;
46
50
  };
47
51
  }
52
+ export interface BlockTranscriptRecord extends TranscriptRecordBase {
53
+ kind: "block";
54
+ runtime: string;
55
+ seq: number;
56
+ blockType: string;
57
+ summary: TranscriptBlockSummary;
58
+ raw?: unknown;
59
+ }
48
60
  export interface ComposeFailedTranscriptRecord extends TranscriptRecordBase {
49
61
  kind: "compose_failed";
50
62
  error: string;
@@ -86,7 +98,7 @@ export interface DroppedTranscriptRecord extends TranscriptRecordBase {
86
98
  reason: DroppedReason;
87
99
  supersededBy?: string | null;
88
100
  }
89
- export type TranscriptRecord = InboundTranscriptRecord | DispatchedTranscriptRecord | ComposeFailedTranscriptRecord | OutboundTranscriptRecord | TurnErrorTranscriptRecord | AttentionSkippedTranscriptRecord | DroppedTranscriptRecord;
101
+ export type TranscriptRecord = InboundTranscriptRecord | DispatchedTranscriptRecord | BlockTranscriptRecord | ComposeFailedTranscriptRecord | OutboundTranscriptRecord | TurnErrorTranscriptRecord | AttentionSkippedTranscriptRecord | DroppedTranscriptRecord;
90
102
  /**
91
103
  * Truncate `value` to TRANSCRIPT_TEXT_LIMIT chars. Returns the (possibly
92
104
  * truncated) text and whether truncation occurred. Surrogate-pair aware: if
@@ -108,10 +120,14 @@ export interface CreateTranscriptWriterOptions {
108
120
  /** Defaults to `~/.botcord/agents`. */
109
121
  rootDir?: string;
110
122
  log: GatewayLogger;
111
- /** Defaults to `false` see design §6 (default-off). */
123
+ /** Defaults to `true`; pass `false` to disable persistence. */
112
124
  enabled?: boolean;
113
125
  /** Override file rotation threshold (bytes). Defaults to TRANSCRIPT_FILE_LIMIT. */
114
126
  maxFileBytes?: number;
127
+ /** Delete transcript JSONL files older than this. Defaults to 3 days. */
128
+ retentionMs?: number;
129
+ /** Minimum interval between retention sweeps. Defaults to 6 hours. */
130
+ cleanupIntervalMs?: number;
115
131
  }
116
132
  export declare function createTranscriptWriter(opts: CreateTranscriptWriterOptions): TranscriptWriter;
117
133
  /**
@@ -120,4 +136,5 @@ export declare function createTranscriptWriter(opts: CreateTranscriptWriterOptio
120
136
  * - env === "0" → false (force off)
121
137
  * - any other / unset → fall back to `configEnabled`
122
138
  */
123
- export declare function resolveTranscriptEnabled(envVal: string | undefined, configEnabled: boolean): boolean;
139
+ export declare function resolveTranscriptEnabled(envVal: string | undefined, configEnabled: boolean | undefined): boolean;
140
+ export declare function cleanupTranscriptFiles(rootDir: string, cutoffMs: number): number;
@@ -1,4 +1,4 @@
1
- import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { transcriptFilePath } from "./transcript-paths.js";
@@ -9,6 +9,10 @@ import { transcriptFilePath } from "./transcript-paths.js";
9
9
  export const TRANSCRIPT_TEXT_LIMIT = 32 * 1024;
10
10
  /** Soft cap on a single transcript file before rotation. */
11
11
  export const TRANSCRIPT_FILE_LIMIT = 8 * 1024 * 1024;
12
+ /** Default retention window for transcript JSONL files. */
13
+ export const TRANSCRIPT_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
14
+ /** Minimum interval between background transcript retention sweeps. */
15
+ export const TRANSCRIPT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
12
16
  /** Default root directory for per-agent transcript trees. */
13
17
  export function defaultTranscriptRoot() {
14
18
  return path.join(homedir(), ".botcord", "agents");
@@ -45,15 +49,21 @@ class FsTranscriptWriter {
45
49
  rootDir;
46
50
  log;
47
51
  maxFileBytes;
52
+ retentionMs;
53
+ cleanupIntervalMs;
48
54
  fileMeta = new Map();
49
55
  firstWriteAnnounced = false;
50
- constructor(rootDir, log, maxFileBytes) {
56
+ lastCleanupAt = 0;
57
+ constructor(rootDir, log, maxFileBytes, retentionMs, cleanupIntervalMs) {
51
58
  this.rootDir = rootDir;
52
59
  this.log = log;
53
60
  this.maxFileBytes = maxFileBytes;
61
+ this.retentionMs = retentionMs;
62
+ this.cleanupIntervalMs = cleanupIntervalMs;
54
63
  }
55
64
  write(rec) {
56
65
  try {
66
+ this.cleanupOldFiles();
57
67
  const file = transcriptFilePath(this.rootDir, rec.agentId, rec.roomId, rec.topicId);
58
68
  const dir = path.dirname(file);
59
69
  try {
@@ -118,14 +128,29 @@ class FsTranscriptWriter {
118
128
  });
119
129
  }
120
130
  }
131
+ cleanupOldFiles() {
132
+ const now = Date.now();
133
+ if (now - this.lastCleanupAt < this.cleanupIntervalMs)
134
+ return;
135
+ this.lastCleanupAt = now;
136
+ const cutoff = now - this.retentionMs;
137
+ const removed = cleanupTranscriptFiles(this.rootDir, cutoff);
138
+ if (removed > 0) {
139
+ this.log.info("transcript cleanup removed old files", {
140
+ dir: this.rootDir,
141
+ removed,
142
+ retentionMs: this.retentionMs,
143
+ });
144
+ }
145
+ }
121
146
  }
122
147
  export function createTranscriptWriter(opts) {
123
148
  const rootDir = opts.rootDir ?? defaultTranscriptRoot();
124
- const enabled = opts.enabled ?? false;
149
+ const enabled = opts.enabled ?? true;
125
150
  if (!enabled)
126
151
  return new NoopTranscriptWriter(rootDir);
127
152
  const maxBytes = opts.maxFileBytes ?? TRANSCRIPT_FILE_LIMIT;
128
- return new FsTranscriptWriter(rootDir, opts.log, maxBytes);
153
+ return new FsTranscriptWriter(rootDir, opts.log, maxBytes, opts.retentionMs ?? TRANSCRIPT_RETENTION_MS, opts.cleanupIntervalMs ?? TRANSCRIPT_CLEANUP_INTERVAL_MS);
129
154
  }
130
155
  /**
131
156
  * Resolve the tri-state enable flag (env wins; otherwise config). See design §5.
@@ -138,7 +163,43 @@ export function resolveTranscriptEnabled(envVal, configEnabled) {
138
163
  return true;
139
164
  if (envVal === "0")
140
165
  return false;
141
- return configEnabled;
166
+ return configEnabled ?? true;
167
+ }
168
+ export function cleanupTranscriptFiles(rootDir, cutoffMs) {
169
+ let removed = 0;
170
+ const visit = (dir, depth) => {
171
+ if (depth < 0)
172
+ return;
173
+ let entries;
174
+ try {
175
+ entries = readdirSync(dir);
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ for (const entry of entries) {
181
+ const file = path.join(dir, entry);
182
+ try {
183
+ const st = statSync(file);
184
+ if (st.isDirectory()) {
185
+ visit(file, depth - 1);
186
+ continue;
187
+ }
188
+ if (st.isFile() &&
189
+ entry.endsWith(".jsonl") &&
190
+ file.includes(`${path.sep}transcripts${path.sep}`) &&
191
+ st.mtimeMs < cutoffMs) {
192
+ rmSync(file, { force: true });
193
+ removed += 1;
194
+ }
195
+ }
196
+ catch {
197
+ // ignore disappearing files and permission errors
198
+ }
199
+ }
200
+ };
201
+ visit(rootDir, 6);
202
+ return removed;
142
203
  }
143
204
  function formatStamp(d) {
144
205
  const pad = (n) => n.toString().padStart(2, "0");
package/dist/index.js CHANGED
@@ -770,16 +770,18 @@ function cmdTranscriptStatus() {
770
770
  if (e.code !== CONFIG_MISSING)
771
771
  throw err;
772
772
  }
773
- const configEnabled = cfg?.transcript?.enabled === true;
773
+ const configEnabled = cfg?.transcript?.enabled;
774
774
  const env = process.env.BOTCORD_TRANSCRIPT;
775
775
  const effective = resolveTranscriptEnabled(env, configEnabled);
776
776
  let source;
777
777
  if (env === "1" || env === "0")
778
778
  source = `env BOTCORD_TRANSCRIPT=${env}`;
779
- else if (configEnabled)
779
+ else if (configEnabled === true)
780
780
  source = "config (transcript.enabled=true)";
781
+ else if (configEnabled === false)
782
+ source = "config (transcript.enabled=false)";
781
783
  else
782
- source = "default-off";
784
+ source = "default-on";
783
785
  console.log(`enabled: ${effective}`);
784
786
  console.log(`source: ${source}`);
785
787
  console.log(`root: ${defaultTranscriptRoot()}`);
@@ -826,9 +828,9 @@ function cmdTranscriptTail(args) {
826
828
  catch {
827
829
  // ignore — config may simply not exist yet
828
830
  }
829
- const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled === true);
831
+ const enabled = resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, cfg?.transcript?.enabled);
830
832
  if (!enabled) {
831
- console.error("hint: transcripts are disabled (default-off). Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.");
833
+ console.error("hint: transcripts are disabled. Run `botcord-daemon transcript enable` and restart the daemon, then send a new message.");
832
834
  }
833
835
  process.exit(1);
834
836
  }
package/dist/turn-text.js CHANGED
@@ -33,7 +33,8 @@ function readEnvelopeType(raw) {
33
33
  }
34
34
  function isThirdPartyConversation(conversationId) {
35
35
  return (conversationId.startsWith("telegram:") ||
36
- conversationId.startsWith("wechat:"));
36
+ conversationId.startsWith("wechat:") ||
37
+ conversationId.startsWith("feishu:"));
37
38
  }
38
39
  function replyDeliveryHint(msg) {
39
40
  return isThirdPartyConversation(msg.conversation.id)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -322,6 +322,43 @@ describe("ControlChannel — REVOKE frame (plan §6.3)", () => {
322
322
  });
323
323
  });
324
324
 
325
+ describe("ControlChannel — reconnect scheduling", () => {
326
+ beforeEach(() => {
327
+ FakeWebSocket.instances.length = 0;
328
+ });
329
+
330
+ it("adds jitter and coalesces duplicate close events into one reconnect", async () => {
331
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(1);
332
+ const auth = new UserAuthManager({
333
+ record: makeAuthRecord(),
334
+ file: "/tmp/never-written-user-auth.json",
335
+ });
336
+ const ctor = makeFakeCtor();
337
+ const ch = new ControlChannel({
338
+ auth,
339
+ handle: () => ({ ok: true }),
340
+ webSocketCtor: ctor as unknown as typeof import("ws").default,
341
+ hubPublicKey: null,
342
+ backoffMs: [25],
343
+ });
344
+ await ch.start();
345
+ const ws = FakeWebSocket.instances[0];
346
+
347
+ ws.emit("close", 1012, Buffer.from(""));
348
+ ws.emit("close", 1012, Buffer.from(""));
349
+
350
+ // Base delay is 25ms; with random=1 and 25% jitter the actual delay is
351
+ // 31ms. Duplicate close events should still leave only one timer queued.
352
+ await new Promise((r) => setTimeout(r, 20));
353
+ expect(FakeWebSocket.instances).toHaveLength(1);
354
+ await new Promise((r) => setTimeout(r, 20));
355
+ expect(FakeWebSocket.instances).toHaveLength(2);
356
+
357
+ randomSpy.mockRestore();
358
+ await ch.stop();
359
+ });
360
+ });
361
+
325
362
  afterEach(() => {
326
363
  FakeWebSocket.instances.length = 0;
327
364
  });
@@ -134,6 +134,22 @@ describe("composeBotCordUserTurn", () => {
134
134
  expect(out).not.toContain("botcord_send");
135
135
  });
136
136
 
137
+ it("does not tell Feishu chats to use botcord_send", () => {
138
+ const out = composeBotCordUserTurn(
139
+ makeMessage({
140
+ channel: "gw_feishu_123",
141
+ conversation: { id: "feishu:user:oc_alice", kind: "direct" },
142
+ sender: { id: "feishu:user:ou_alice", name: "Alice", kind: "user" },
143
+ }),
144
+ );
145
+ expect(out).toContain("third-party gateway chat");
146
+ expect(out).toContain("Reply normally in your final assistant message");
147
+ expect(out).toContain("conversation_id: feishu:user:oc_alice");
148
+ expect(out).toContain("channel: gw_feishu_123");
149
+ expect(out).not.toContain("Plain text output WILL NOT be sent");
150
+ expect(out).not.toContain("botcord_send");
151
+ });
152
+
137
153
  it("passes owner-chat messages through verbatim (no wrapper, no hint)", () => {
138
154
  const out = composeBotCordUserTurn(
139
155
  makeMessage({
package/src/acp-logs.ts CHANGED
@@ -20,6 +20,7 @@ export type AcpTraceStream =
20
20
  | "child_error"
21
21
  | "stderr"
22
22
  | "stdout_non_json"
23
+ | "turn_context"
23
24
  | "rpc_in"
24
25
  | "rpc_out";
25
26
 
@@ -37,6 +38,10 @@ export interface AcpTraceMeta {
37
38
 
38
39
  export interface AcpTraceEvent {
39
40
  stream: AcpTraceStream;
41
+ turnId?: string;
42
+ messageId?: string;
43
+ roomId?: string;
44
+ topicId?: string | null;
40
45
  direction?: "in" | "out";
41
46
  pid?: number;
42
47
  id?: number | string;
@@ -132,9 +137,10 @@ function writeAcpTrace(
132
137
  ts: new Date().toISOString(),
133
138
  runtime: meta.runtime,
134
139
  accountId: meta.accountId,
135
- turnId: meta.turnId,
136
- roomId: meta.roomId,
137
- topicId: meta.topicId ?? undefined,
140
+ turnId: event.turnId ?? meta.turnId,
141
+ messageId: event.messageId,
142
+ roomId: event.roomId ?? meta.roomId,
143
+ topicId: event.topicId ?? meta.topicId ?? undefined,
138
144
  gatewayName: meta.gatewayName,
139
145
  gatewayUrl: meta.gatewayUrl,
140
146
  hermesProfile: meta.hermesProfile,
package/src/config.ts CHANGED
@@ -159,7 +159,7 @@ export interface DaemonConfig {
159
159
  streamBlocks: boolean;
160
160
  /**
161
161
  * Persistent transcript-logging settings (design §3 / §6). Defaults to
162
- * disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
162
+ * enabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
163
163
  */
164
164
  transcript?: TranscriptConfig;
165
165
 
@@ -186,8 +186,8 @@ export interface DaemonConfig {
186
186
  }
187
187
 
188
188
  /**
189
- * Persistent transcript settings (design §6). Default-off — `botcord-daemon
190
- * transcript enable` flips `enabled` and `transcript disable` flips it back.
189
+ * Persistent transcript settings (design §6). Default-on — `botcord-daemon
190
+ * transcript disable` sets `enabled=false`, and `transcript enable` flips it back.
191
191
  * The env var `BOTCORD_TRANSCRIPT` can override at boot.
192
192
  */
193
193
  export interface TranscriptConfig {
@@ -25,6 +25,7 @@ import {
25
25
 
26
26
  /** Exponential backoff plan for transient disconnects. */
27
27
  const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
28
+ const RECONNECT_JITTER_RATIO = 0.25;
28
29
  /**
29
30
  * Keepalive cadence. Has to stay below the smallest idle-timeout in any
30
31
  * intermediary on the daemon → Hub WS path. Cloudflare and AWS ALB both
@@ -55,6 +56,11 @@ export function controlSigningInput(
55
56
  return jcsCanonicalize(obj) ?? "{}";
56
57
  }
57
58
 
59
+ function withReconnectJitter(delayMs: number): { delayMs: number; jitterMs: number } {
60
+ const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
61
+ return { delayMs: delayMs + jitterMs, jitterMs };
62
+ }
63
+
58
64
  /** Handler invoked for each inbound frame. Return value is the ack payload. */
59
65
  export type ControlFrameHandler = (
60
66
  frame: ControlFrame,
@@ -110,6 +116,7 @@ export class ControlChannel {
110
116
  private readonly seenFrameIds: string[] = [];
111
117
  private connectInflight: Promise<void> | null = null;
112
118
  private connected = false;
119
+ private connectionSeq = 0;
113
120
 
114
121
  constructor(opts: ControlChannelOptions) {
115
122
  this.auth = opts.auth;
@@ -220,6 +227,20 @@ export class ControlChannel {
220
227
  private async connect(): Promise<void> {
221
228
  const record = this.auth.current;
222
229
  if (!record) throw new Error("control-channel: no user-auth");
230
+ const current = this.ws;
231
+ if (
232
+ current &&
233
+ (current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)
234
+ ) {
235
+ daemonLog.debug("control-channel connect skipped (socket already active)", {
236
+ readyState: current.readyState,
237
+ });
238
+ return;
239
+ }
240
+ if (this.reconnectTimer) {
241
+ clearTimeout(this.reconnectTimer);
242
+ this.reconnectTimer = null;
243
+ }
223
244
 
224
245
  const accessToken = await this.auth.ensureAccessToken();
225
246
  const url = buildDaemonWebSocketUrl(
@@ -229,6 +250,7 @@ export class ControlChannel {
229
250
  );
230
251
  daemonLog.info("control-channel connecting", { url });
231
252
 
253
+ const connectionId = ++this.connectionSeq;
232
254
  const ws = new this.webSocketCtor(url, {
233
255
  headers: { Authorization: `Bearer ${accessToken}` },
234
256
  });
@@ -237,6 +259,15 @@ export class ControlChannel {
237
259
  await new Promise<void>((resolve, reject) => {
238
260
  const onOpen = (): void => {
239
261
  ws.removeListener("error", onError);
262
+ if (this.stopRequested || this.ws !== ws || connectionId !== this.connectionSeq) {
263
+ try {
264
+ ws.close(1000, "stale control-channel connection");
265
+ } catch {
266
+ // ignore
267
+ }
268
+ resolve();
269
+ return;
270
+ }
240
271
  this.connected = true;
241
272
  this.reconnectAttempts = 0;
242
273
  daemonLog.info("control-channel connected", { url });
@@ -245,14 +276,21 @@ export class ControlChannel {
245
276
  };
246
277
  const onError = (err: Error): void => {
247
278
  ws.removeListener("open", onOpen);
279
+ if (this.ws !== ws || connectionId !== this.connectionSeq) {
280
+ resolve();
281
+ return;
282
+ }
248
283
  reject(err);
249
284
  };
250
285
  ws.once("open", onOpen);
251
286
  ws.once("error", onError);
252
287
  });
253
288
 
254
- ws.on("message", (data) => this.onMessage(data));
255
- ws.on("close", (code, reason) => this.onClose(code, reason));
289
+ ws.on("message", (data) => {
290
+ if (this.ws !== ws || connectionId !== this.connectionSeq) return;
291
+ void this.onMessage(data);
292
+ });
293
+ ws.on("close", (code, reason) => this.onClose(code, reason, ws, connectionId));
256
294
  ws.on("error", (err) =>
257
295
  daemonLog.warn("control-channel error", {
258
296
  error: err instanceof Error ? err.message : String(err),
@@ -292,8 +330,12 @@ export class ControlChannel {
292
330
  }
293
331
  }
294
332
 
295
- private onClose(code: number, reason: Buffer): void {
333
+ private onClose(code: number, reason: Buffer, ws?: WebSocket, connectionId?: number): void {
296
334
  const reasonText = reason?.toString() || "";
335
+ if (ws && (this.ws !== ws || connectionId !== this.connectionSeq)) {
336
+ daemonLog.debug("control-channel stale close ignored", { code, reason: reasonText });
337
+ return;
338
+ }
297
339
  this.connected = false;
298
340
  this.stopKeepalive();
299
341
  this.ws = null;
@@ -314,6 +356,14 @@ export class ControlChannel {
314
356
 
315
357
  private scheduleReconnect(err?: unknown): void {
316
358
  if (this.stopRequested) return;
359
+ if (this.reconnectTimer) return;
360
+ const current = this.ws;
361
+ if (
362
+ current &&
363
+ (current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)
364
+ ) {
365
+ return;
366
+ }
317
367
  if (err instanceof AuthRefreshRejectedError) {
318
368
  this.stopRequested = true;
319
369
  daemonLog.warn("control-channel: refresh rejected; halting reconnect (re-login required)", {
@@ -323,20 +373,23 @@ export class ControlChannel {
323
373
  }
324
374
  const attempt = this.reconnectAttempts;
325
375
  this.reconnectAttempts = attempt + 1;
326
- const delay = this.backoff[Math.min(attempt, this.backoff.length - 1)];
376
+ const baseDelayMs = this.backoff[Math.min(attempt, this.backoff.length - 1)];
377
+ const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
327
378
  if (err) {
328
379
  daemonLog.warn("control-channel reconnect scheduled", {
329
- delayMs: delay,
380
+ delayMs,
381
+ baseDelayMs,
382
+ jitterMs,
330
383
  error: err instanceof Error ? err.message : String(err),
331
384
  });
332
385
  } else {
333
- daemonLog.info("control-channel reconnect scheduled", { delayMs: delay });
386
+ daemonLog.info("control-channel reconnect scheduled", { delayMs, baseDelayMs, jitterMs });
334
387
  }
335
388
  this.reconnectTimer = setTimeout(() => {
336
389
  this.reconnectTimer = null;
337
390
  if (this.stopRequested) return;
338
391
  this.connect().catch((err) => this.scheduleReconnect(err));
339
- }, delay);
392
+ }, delayMs);
340
393
  }
341
394
 
342
395
  private async onMessage(data: WebSocket.RawData): Promise<void> {
package/src/daemon.ts CHANGED
@@ -518,7 +518,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
518
518
  resolveHubUrl,
519
519
  transcriptEnabled: resolveTranscriptEnabled(
520
520
  process.env.BOTCORD_TRANSCRIPT,
521
- opts.config.transcript?.enabled === true,
521
+ opts.config.transcript?.enabled,
522
522
  ),
523
523
  });
524
524