@botcord/daemon 0.2.63 → 0.2.64
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/acp-logs.d.ts +5 -1
- package/dist/acp-logs.js +4 -3
- package/dist/config.d.ts +3 -3
- package/dist/control-channel.d.ts +1 -0
- package/dist/control-channel.js +58 -7
- package/dist/daemon.js +1 -1
- package/dist/diagnostics.js +62 -1
- package/dist/gateway/channels/botcord.js +54 -11
- package/dist/gateway/dispatcher.js +76 -10
- package/dist/gateway/gateway.d.ts +2 -1
- package/dist/gateway/gateway.js +1 -1
- package/dist/gateway/runtimes/openclaw-acp.js +41 -5
- package/dist/gateway/transcript.d.ts +21 -4
- package/dist/gateway/transcript.js +66 -5
- package/dist/index.js +7 -5
- package/package.json +1 -1
- package/src/__tests__/control-channel.test.ts +37 -0
- package/src/acp-logs.ts +9 -3
- package/src/config.ts +3 -3
- package/src/control-channel.ts +60 -7
- package/src/daemon.ts +1 -1
- package/src/diagnostics.ts +67 -1
- package/src/gateway/__tests__/dispatcher.test.ts +44 -0
- package/src/gateway/__tests__/transcript.test.ts +27 -2
- package/src/gateway/channels/botcord.ts +50 -11
- package/src/gateway/dispatcher.ts +77 -8
- package/src/gateway/gateway.ts +3 -2
- package/src/gateway/runtimes/openclaw-acp.ts +56 -6
- package/src/gateway/transcript.ts +98 -8
- package/src/index.ts +6 -5
package/dist/acp-logs.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LogFileEntry } from "./log.js";
|
|
2
|
-
export type AcpTraceStream = "child_start" | "child_exit" | "child_error" | "stderr" | "stdout_non_json" | "rpc_in" | "rpc_out";
|
|
2
|
+
export type AcpTraceStream = "child_start" | "child_exit" | "child_error" | "stderr" | "stdout_non_json" | "turn_context" | "rpc_in" | "rpc_out";
|
|
3
3
|
export interface AcpTraceMeta {
|
|
4
4
|
runtime: string;
|
|
5
5
|
accountId?: string;
|
|
@@ -13,6 +13,10 @@ export interface AcpTraceMeta {
|
|
|
13
13
|
}
|
|
14
14
|
export interface AcpTraceEvent {
|
|
15
15
|
stream: AcpTraceStream;
|
|
16
|
+
turnId?: string;
|
|
17
|
+
messageId?: string;
|
|
18
|
+
roomId?: string;
|
|
19
|
+
topicId?: string | null;
|
|
16
20
|
direction?: "in" | "out";
|
|
17
21
|
pid?: number;
|
|
18
22
|
id?: number | string;
|
package/dist/acp-logs.js
CHANGED
|
@@ -64,9 +64,10 @@ function writeAcpTrace(file, meta, event, verbose) {
|
|
|
64
64
|
ts: new Date().toISOString(),
|
|
65
65
|
runtime: meta.runtime,
|
|
66
66
|
accountId: meta.accountId,
|
|
67
|
-
turnId: meta.turnId,
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
turnId: event.turnId ?? meta.turnId,
|
|
68
|
+
messageId: event.messageId,
|
|
69
|
+
roomId: event.roomId ?? meta.roomId,
|
|
70
|
+
topicId: event.topicId ?? meta.topicId ?? undefined,
|
|
70
71
|
gatewayName: meta.gatewayName,
|
|
71
72
|
gatewayUrl: meta.gatewayUrl,
|
|
72
73
|
hermesProfile: meta.hermesProfile,
|
package/dist/config.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ export interface DaemonConfig {
|
|
|
139
139
|
streamBlocks: boolean;
|
|
140
140
|
/**
|
|
141
141
|
* Persistent transcript-logging settings (design §3 / §6). Defaults to
|
|
142
|
-
*
|
|
142
|
+
* enabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
|
|
143
143
|
*/
|
|
144
144
|
transcript?: TranscriptConfig;
|
|
145
145
|
/**
|
|
@@ -162,8 +162,8 @@ export interface DaemonConfig {
|
|
|
162
162
|
thirdPartyGateways?: ThirdPartyGatewayProfile[];
|
|
163
163
|
}
|
|
164
164
|
/**
|
|
165
|
-
* Persistent transcript settings (design §6). Default-
|
|
166
|
-
* transcript
|
|
165
|
+
* Persistent transcript settings (design §6). Default-on — `botcord-daemon
|
|
166
|
+
* transcript disable` sets `enabled=false`, and `transcript enable` flips it back.
|
|
167
167
|
* The env var `BOTCORD_TRANSCRIPT` can override at boot.
|
|
168
168
|
*/
|
|
169
169
|
export interface TranscriptConfig {
|
|
@@ -73,6 +73,7 @@ export declare class ControlChannel {
|
|
|
73
73
|
private readonly seenFrameIds;
|
|
74
74
|
private connectInflight;
|
|
75
75
|
private connected;
|
|
76
|
+
private connectionSeq;
|
|
76
77
|
constructor(opts: ControlChannelOptions);
|
|
77
78
|
/** True once the initial WS handshake succeeded. Flipped back on close. */
|
|
78
79
|
get isConnected(): boolean;
|
package/dist/control-channel.js
CHANGED
|
@@ -12,6 +12,7 @@ import { log as daemonLog } from "./log.js";
|
|
|
12
12
|
import { AuthRefreshRejectedError, writeAuthExpiredFlag, } from "./user-auth.js";
|
|
13
13
|
/** Exponential backoff plan for transient disconnects. */
|
|
14
14
|
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
15
|
+
const RECONNECT_JITTER_RATIO = 0.25;
|
|
15
16
|
/**
|
|
16
17
|
* Keepalive cadence. Has to stay below the smallest idle-timeout in any
|
|
17
18
|
* intermediary on the daemon → Hub WS path. Cloudflare and AWS ALB both
|
|
@@ -38,6 +39,10 @@ export function controlSigningInput(frame) {
|
|
|
38
39
|
};
|
|
39
40
|
return jcsCanonicalize(obj) ?? "{}";
|
|
40
41
|
}
|
|
42
|
+
function withReconnectJitter(delayMs) {
|
|
43
|
+
const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
|
|
44
|
+
return { delayMs: delayMs + jitterMs, jitterMs };
|
|
45
|
+
}
|
|
41
46
|
/**
|
|
42
47
|
* Long-lived, self-healing WS connection that carries control frames
|
|
43
48
|
* between the Hub and the local daemon. Owns reconnect/backoff and
|
|
@@ -60,6 +65,7 @@ export class ControlChannel {
|
|
|
60
65
|
seenFrameIds = [];
|
|
61
66
|
connectInflight = null;
|
|
62
67
|
connected = false;
|
|
68
|
+
connectionSeq = 0;
|
|
63
69
|
constructor(opts) {
|
|
64
70
|
this.auth = opts.auth;
|
|
65
71
|
this.handle = opts.handle;
|
|
@@ -170,9 +176,22 @@ export class ControlChannel {
|
|
|
170
176
|
const record = this.auth.current;
|
|
171
177
|
if (!record)
|
|
172
178
|
throw new Error("control-channel: no user-auth");
|
|
179
|
+
const current = this.ws;
|
|
180
|
+
if (current &&
|
|
181
|
+
(current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)) {
|
|
182
|
+
daemonLog.debug("control-channel connect skipped (socket already active)", {
|
|
183
|
+
readyState: current.readyState,
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (this.reconnectTimer) {
|
|
188
|
+
clearTimeout(this.reconnectTimer);
|
|
189
|
+
this.reconnectTimer = null;
|
|
190
|
+
}
|
|
173
191
|
const accessToken = await this.auth.ensureAccessToken();
|
|
174
192
|
const url = buildDaemonWebSocketUrl(record.hubUrl, this.path, this.label ? { label: this.label } : undefined);
|
|
175
193
|
daemonLog.info("control-channel connecting", { url });
|
|
194
|
+
const connectionId = ++this.connectionSeq;
|
|
176
195
|
const ws = new this.webSocketCtor(url, {
|
|
177
196
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
178
197
|
});
|
|
@@ -180,6 +199,16 @@ export class ControlChannel {
|
|
|
180
199
|
await new Promise((resolve, reject) => {
|
|
181
200
|
const onOpen = () => {
|
|
182
201
|
ws.removeListener("error", onError);
|
|
202
|
+
if (this.stopRequested || this.ws !== ws || connectionId !== this.connectionSeq) {
|
|
203
|
+
try {
|
|
204
|
+
ws.close(1000, "stale control-channel connection");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// ignore
|
|
208
|
+
}
|
|
209
|
+
resolve();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
183
212
|
this.connected = true;
|
|
184
213
|
this.reconnectAttempts = 0;
|
|
185
214
|
daemonLog.info("control-channel connected", { url });
|
|
@@ -188,13 +217,21 @@ export class ControlChannel {
|
|
|
188
217
|
};
|
|
189
218
|
const onError = (err) => {
|
|
190
219
|
ws.removeListener("open", onOpen);
|
|
220
|
+
if (this.ws !== ws || connectionId !== this.connectionSeq) {
|
|
221
|
+
resolve();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
191
224
|
reject(err);
|
|
192
225
|
};
|
|
193
226
|
ws.once("open", onOpen);
|
|
194
227
|
ws.once("error", onError);
|
|
195
228
|
});
|
|
196
|
-
ws.on("message", (data) =>
|
|
197
|
-
|
|
229
|
+
ws.on("message", (data) => {
|
|
230
|
+
if (this.ws !== ws || connectionId !== this.connectionSeq)
|
|
231
|
+
return;
|
|
232
|
+
void this.onMessage(data);
|
|
233
|
+
});
|
|
234
|
+
ws.on("close", (code, reason) => this.onClose(code, reason, ws, connectionId));
|
|
198
235
|
ws.on("error", (err) => daemonLog.warn("control-channel error", {
|
|
199
236
|
error: err instanceof Error ? err.message : String(err),
|
|
200
237
|
}));
|
|
@@ -231,8 +268,12 @@ export class ControlChannel {
|
|
|
231
268
|
this.keepaliveTimer = null;
|
|
232
269
|
}
|
|
233
270
|
}
|
|
234
|
-
onClose(code, reason) {
|
|
271
|
+
onClose(code, reason, ws, connectionId) {
|
|
235
272
|
const reasonText = reason?.toString() || "";
|
|
273
|
+
if (ws && (this.ws !== ws || connectionId !== this.connectionSeq)) {
|
|
274
|
+
daemonLog.debug("control-channel stale close ignored", { code, reason: reasonText });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
236
277
|
this.connected = false;
|
|
237
278
|
this.stopKeepalive();
|
|
238
279
|
this.ws = null;
|
|
@@ -252,6 +293,13 @@ export class ControlChannel {
|
|
|
252
293
|
scheduleReconnect(err) {
|
|
253
294
|
if (this.stopRequested)
|
|
254
295
|
return;
|
|
296
|
+
if (this.reconnectTimer)
|
|
297
|
+
return;
|
|
298
|
+
const current = this.ws;
|
|
299
|
+
if (current &&
|
|
300
|
+
(current.readyState === WebSocket.CONNECTING || current.readyState === WebSocket.OPEN)) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
255
303
|
if (err instanceof AuthRefreshRejectedError) {
|
|
256
304
|
this.stopRequested = true;
|
|
257
305
|
daemonLog.warn("control-channel: refresh rejected; halting reconnect (re-login required)", {
|
|
@@ -261,22 +309,25 @@ export class ControlChannel {
|
|
|
261
309
|
}
|
|
262
310
|
const attempt = this.reconnectAttempts;
|
|
263
311
|
this.reconnectAttempts = attempt + 1;
|
|
264
|
-
const
|
|
312
|
+
const baseDelayMs = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
|
313
|
+
const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
|
|
265
314
|
if (err) {
|
|
266
315
|
daemonLog.warn("control-channel reconnect scheduled", {
|
|
267
|
-
delayMs
|
|
316
|
+
delayMs,
|
|
317
|
+
baseDelayMs,
|
|
318
|
+
jitterMs,
|
|
268
319
|
error: err instanceof Error ? err.message : String(err),
|
|
269
320
|
});
|
|
270
321
|
}
|
|
271
322
|
else {
|
|
272
|
-
daemonLog.info("control-channel reconnect scheduled", { delayMs
|
|
323
|
+
daemonLog.info("control-channel reconnect scheduled", { delayMs, baseDelayMs, jitterMs });
|
|
273
324
|
}
|
|
274
325
|
this.reconnectTimer = setTimeout(() => {
|
|
275
326
|
this.reconnectTimer = null;
|
|
276
327
|
if (this.stopRequested)
|
|
277
328
|
return;
|
|
278
329
|
this.connect().catch((err) => this.scheduleReconnect(err));
|
|
279
|
-
},
|
|
330
|
+
}, delayMs);
|
|
280
331
|
}
|
|
281
332
|
async onMessage(data) {
|
|
282
333
|
let frame;
|
package/dist/daemon.js
CHANGED
|
@@ -367,7 +367,7 @@ export async function startDaemon(opts) {
|
|
|
367
367
|
composeUserTurn: composeBotCordUserTurn,
|
|
368
368
|
attentionGate,
|
|
369
369
|
resolveHubUrl,
|
|
370
|
-
transcriptEnabled: resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, opts.config.transcript?.enabled
|
|
370
|
+
transcriptEnabled: resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, opts.config.transcript?.enabled),
|
|
371
371
|
});
|
|
372
372
|
logger.info("daemon starting", {
|
|
373
373
|
agents: agentIds,
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, hostname, platform, release, arch } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
@@ -9,6 +9,7 @@ import { AUTH_EXPIRED_FLAG_PATH, USER_AUTH_PATH, } from "./user-auth.js";
|
|
|
9
9
|
import { CONFIG_FILE_PATH, PID_PATH, SESSIONS_PATH, SNAPSHOT_PATH, loadConfig, saveConfig, } from "./config.js";
|
|
10
10
|
import { listDaemonLogFiles, LOG_FILE_PATH } from "./log.js";
|
|
11
11
|
import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
|
|
12
|
+
import { defaultTranscriptRoot } from "./gateway/transcript.js";
|
|
12
13
|
import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
|
|
13
14
|
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
14
15
|
import { log as daemonLog } from "./log.js";
|
|
@@ -34,6 +35,9 @@ const ENV_ALLOWLIST = new Set([
|
|
|
34
35
|
"BOTCORD_KIMI_CLI_BIN",
|
|
35
36
|
"OPENCLAW_ACP_URL",
|
|
36
37
|
]);
|
|
38
|
+
const TRANSCRIPT_LOG_DIAGNOSTICS_DEFAULT = 10;
|
|
39
|
+
const TRANSCRIPT_LOG_DIAGNOSTICS_ALL = 50;
|
|
40
|
+
const TRANSCRIPT_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
37
41
|
const SECRET_PATTERNS = [
|
|
38
42
|
[/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]"],
|
|
39
43
|
[/("?(?:accessToken|access_token|refreshToken|refresh_token|token|privateKey|private_key|secret)"?\s*:\s*")[^"]+(")/gi, "$1[REDACTED]$2"],
|
|
@@ -348,6 +352,50 @@ function bundledLogs(logFile, includeAllLogs) {
|
|
|
348
352
|
...(includeAllLogs ? rotated : rotated.slice(0, DEFAULT_ROTATED_LOGS_IN_BUNDLE)),
|
|
349
353
|
];
|
|
350
354
|
}
|
|
355
|
+
function listTranscriptLogFiles(includeAll) {
|
|
356
|
+
const root = defaultTranscriptRoot();
|
|
357
|
+
const out = [];
|
|
358
|
+
collectTranscriptFiles(root, root, out, 5);
|
|
359
|
+
const limit = includeAll ? TRANSCRIPT_LOG_DIAGNOSTICS_ALL : TRANSCRIPT_LOG_DIAGNOSTICS_DEFAULT;
|
|
360
|
+
return out
|
|
361
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
362
|
+
.slice(0, limit);
|
|
363
|
+
}
|
|
364
|
+
function collectTranscriptFiles(root, dir, out, maxDepth) {
|
|
365
|
+
if (maxDepth < 0)
|
|
366
|
+
return;
|
|
367
|
+
let names;
|
|
368
|
+
try {
|
|
369
|
+
names = readdirSync(dir);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
for (const name of names) {
|
|
375
|
+
const file = path.join(dir, name);
|
|
376
|
+
try {
|
|
377
|
+
const st = statSync(file);
|
|
378
|
+
if (st.isDirectory()) {
|
|
379
|
+
collectTranscriptFiles(root, file, out, maxDepth - 1);
|
|
380
|
+
}
|
|
381
|
+
else if (st.isFile() &&
|
|
382
|
+
name.endsWith(".jsonl") &&
|
|
383
|
+
file.includes(`${path.sep}transcripts${path.sep}`) &&
|
|
384
|
+
st.size <= TRANSCRIPT_LOG_MAX_FILE_BYTES) {
|
|
385
|
+
out.push({
|
|
386
|
+
path: file,
|
|
387
|
+
name: path.relative(root, file) || name,
|
|
388
|
+
sizeBytes: st.size,
|
|
389
|
+
mtimeMs: st.mtimeMs,
|
|
390
|
+
active: true,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// ignore files that disappear while collecting diagnostics
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
351
399
|
export async function createDiagnosticBundle(opts = {}) {
|
|
352
400
|
const createdAt = new Date();
|
|
353
401
|
const stamp = createdAt.toISOString().replace(/[:.]/g, "-");
|
|
@@ -361,6 +409,7 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
361
409
|
const logs = bundledLogs(logFile, includeAllLogs);
|
|
362
410
|
const acpLogs = listAcpTraceLogFiles(includeAllLogs);
|
|
363
411
|
const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
|
|
412
|
+
const transcriptLogs = listTranscriptLogFiles(includeAllLogs);
|
|
364
413
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
365
414
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
366
415
|
const status = {
|
|
@@ -394,6 +443,11 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
394
443
|
path: entry.path,
|
|
395
444
|
sizeBytes: entry.sizeBytes,
|
|
396
445
|
})),
|
|
446
|
+
transcriptLogsBundled: transcriptLogs.map((entry) => ({
|
|
447
|
+
name: entry.name,
|
|
448
|
+
path: entry.path,
|
|
449
|
+
sizeBytes: entry.sizeBytes,
|
|
450
|
+
})),
|
|
397
451
|
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
398
452
|
diagnosticsDir,
|
|
399
453
|
userAuth: readUserAuthSummary(),
|
|
@@ -433,6 +487,13 @@ export async function createDiagnosticBundle(opts = {}) {
|
|
|
433
487
|
data: log ?? `no runtime log file at ${entry.path}\n`,
|
|
434
488
|
});
|
|
435
489
|
}
|
|
490
|
+
for (const entry of transcriptLogs) {
|
|
491
|
+
const log = safeReadText(entry.path);
|
|
492
|
+
entries.push({
|
|
493
|
+
name: `transcripts/${entry.name.split(path.sep).join("/")}`,
|
|
494
|
+
data: log ?? `no transcript log file at ${entry.path}\n`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
436
497
|
const config = safeReadText(configFile);
|
|
437
498
|
entries.push({
|
|
438
499
|
name: "config.json.redacted",
|
|
@@ -3,6 +3,7 @@ import { BotCordClient, buildHubWebSocketUrl, defaultCredentialsFile, loadStored
|
|
|
3
3
|
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
4
4
|
import { revokeAgent } from "../../provision.js";
|
|
5
5
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
6
|
+
const RECONNECT_JITTER_RATIO = 0.25;
|
|
6
7
|
const KEEPALIVE_INTERVAL = 20_000;
|
|
7
8
|
const MAX_AUTH_FAILURES = 5;
|
|
8
9
|
const SEEN_MESSAGES_CAP = 500;
|
|
@@ -10,6 +11,10 @@ const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
|
10
11
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
11
12
|
const INBOX_POLL_LIMIT = 50;
|
|
12
13
|
const CHANNEL_PERMANENT_STOP = "channel_permanent_stop";
|
|
14
|
+
function withReconnectJitter(delayMs) {
|
|
15
|
+
const jitterMs = Math.floor(Math.random() * delayMs * RECONNECT_JITTER_RATIO);
|
|
16
|
+
return { delayMs: delayMs + jitterMs, jitterMs };
|
|
17
|
+
}
|
|
13
18
|
function isUnclaimedAgentError(err) {
|
|
14
19
|
const status = err?.status;
|
|
15
20
|
if (status !== 403)
|
|
@@ -344,6 +349,7 @@ export function createBotCordChannel(options) {
|
|
|
344
349
|
let reconnectTimer = null;
|
|
345
350
|
let keepaliveTimer = null;
|
|
346
351
|
let reconnectAttempt = 0;
|
|
352
|
+
let connectionSeq = 0;
|
|
347
353
|
let consecutiveAuthFailures = 0;
|
|
348
354
|
let running = true;
|
|
349
355
|
let permanentStopping = false;
|
|
@@ -465,22 +471,36 @@ export function createBotCordChannel(options) {
|
|
|
465
471
|
function scheduleReconnect() {
|
|
466
472
|
if (!running)
|
|
467
473
|
return;
|
|
468
|
-
|
|
474
|
+
if (reconnectTimer)
|
|
475
|
+
return;
|
|
476
|
+
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const baseDelayMs = RECONNECT_BACKOFF[Math.min(reconnectAttempt, RECONNECT_BACKOFF.length - 1)];
|
|
480
|
+
const { delayMs, jitterMs } = withReconnectJitter(baseDelayMs);
|
|
469
481
|
reconnectAttempt += 1;
|
|
470
482
|
markStatus({
|
|
471
483
|
connected: false,
|
|
472
484
|
restartPending: true,
|
|
473
485
|
reconnectAttempts: reconnectAttempt,
|
|
474
486
|
});
|
|
475
|
-
log.info("botcord ws reconnect scheduled", {
|
|
487
|
+
log.info("botcord ws reconnect scheduled", {
|
|
488
|
+
delayMs,
|
|
489
|
+
baseDelayMs,
|
|
490
|
+
jitterMs,
|
|
491
|
+
attempt: reconnectAttempt,
|
|
492
|
+
});
|
|
476
493
|
reconnectTimer = setTimeout(() => {
|
|
477
494
|
reconnectTimer = null;
|
|
478
495
|
void connect();
|
|
479
|
-
},
|
|
496
|
+
}, delayMs);
|
|
480
497
|
}
|
|
481
498
|
async function connect() {
|
|
482
499
|
if (!running)
|
|
483
500
|
return;
|
|
501
|
+
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
484
504
|
const agentId = options.agentId;
|
|
485
505
|
markStatus({ connected: false, restartPending: false });
|
|
486
506
|
if (pendingRefresh) {
|
|
@@ -506,8 +526,11 @@ export function createBotCordChannel(options) {
|
|
|
506
526
|
}
|
|
507
527
|
const url = buildHubWebSocketUrl(hubUrl);
|
|
508
528
|
log.info("botcord ws connecting", { url, agentId });
|
|
529
|
+
const connectionId = ++connectionSeq;
|
|
530
|
+
let socket;
|
|
509
531
|
try {
|
|
510
|
-
|
|
532
|
+
socket = new wsCtor(url);
|
|
533
|
+
ws = socket;
|
|
511
534
|
}
|
|
512
535
|
catch (err) {
|
|
513
536
|
log.error("botcord ws construct failed", { agentId, err: String(err) });
|
|
@@ -515,10 +538,21 @@ export function createBotCordChannel(options) {
|
|
|
515
538
|
scheduleReconnect();
|
|
516
539
|
return;
|
|
517
540
|
}
|
|
518
|
-
|
|
519
|
-
|
|
541
|
+
socket.on("open", () => {
|
|
542
|
+
if (!running || ws !== socket || connectionId !== connectionSeq) {
|
|
543
|
+
try {
|
|
544
|
+
socket.close();
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// ignore
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
socket.send(JSON.stringify({ type: "auth", token }));
|
|
520
552
|
});
|
|
521
|
-
|
|
553
|
+
socket.on("message", (data) => {
|
|
554
|
+
if (ws !== socket || connectionId !== connectionSeq)
|
|
555
|
+
return;
|
|
522
556
|
let msg = null;
|
|
523
557
|
try {
|
|
524
558
|
msg = JSON.parse(String(data));
|
|
@@ -540,10 +574,12 @@ export function createBotCordChannel(options) {
|
|
|
540
574
|
});
|
|
541
575
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
542
576
|
void fireInbox("ws_auth_ok");
|
|
577
|
+
if (keepaliveTimer)
|
|
578
|
+
clearInterval(keepaliveTimer);
|
|
543
579
|
keepaliveTimer = setInterval(() => {
|
|
544
|
-
if (ws &&
|
|
580
|
+
if (ws === socket && socket.readyState === WebSocket.OPEN) {
|
|
545
581
|
try {
|
|
546
|
-
|
|
582
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
547
583
|
}
|
|
548
584
|
catch {
|
|
549
585
|
// ignore
|
|
@@ -562,10 +598,15 @@ export function createBotCordChannel(options) {
|
|
|
562
598
|
log.warn("botcord ws server error", { agentId, msg });
|
|
563
599
|
}
|
|
564
600
|
});
|
|
565
|
-
|
|
601
|
+
socket.on("close", (code, reason) => {
|
|
566
602
|
const reasonStr = reason?.toString() || "";
|
|
603
|
+
if (ws !== socket || connectionId !== connectionSeq) {
|
|
604
|
+
log.debug("botcord ws stale close ignored", { agentId, code, reason: reasonStr });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
567
607
|
log.info("botcord ws closed", { agentId, code, reason: reasonStr });
|
|
568
608
|
clearTimers();
|
|
609
|
+
ws = null;
|
|
569
610
|
markStatus({ connected: false });
|
|
570
611
|
if (!running) {
|
|
571
612
|
if (permanentStopping)
|
|
@@ -606,7 +647,9 @@ export function createBotCordChannel(options) {
|
|
|
606
647
|
}
|
|
607
648
|
scheduleReconnect();
|
|
608
649
|
});
|
|
609
|
-
|
|
650
|
+
socket.on("error", (err) => {
|
|
651
|
+
if (ws !== socket || connectionId !== connectionSeq)
|
|
652
|
+
return;
|
|
610
653
|
log.warn("botcord ws error", { agentId, err: String(err) });
|
|
611
654
|
markStatus({ lastError: String(err) });
|
|
612
655
|
});
|
|
@@ -10,6 +10,8 @@ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
10
10
|
* (or `botcord send` CLI via Bash) to actually deliver replies.
|
|
11
11
|
*/
|
|
12
12
|
const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
|
|
13
|
+
const TRANSCRIPT_BLOCK_RAW_LIMIT = 16 * 1024;
|
|
14
|
+
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
13
15
|
/** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
|
|
14
16
|
const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
15
17
|
/**
|
|
@@ -33,6 +35,60 @@ const TYPING_DEBOUNCE_MS = 2000;
|
|
|
33
35
|
const TYPING_REFRESH_MS = 4000;
|
|
34
36
|
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
35
37
|
const TYPING_RECENCY_CAP = 1024;
|
|
38
|
+
function transcriptBlocksVerbose() {
|
|
39
|
+
return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
|
|
40
|
+
process.env.BOTCORD_TRACE_VERBOSE === "1";
|
|
41
|
+
}
|
|
42
|
+
function summarizeStreamBlock(block) {
|
|
43
|
+
const summary = { type: block.kind };
|
|
44
|
+
const raw = block.raw;
|
|
45
|
+
if (raw && typeof raw === "object") {
|
|
46
|
+
if (typeof raw.text === "string")
|
|
47
|
+
summary.chars = raw.text.length;
|
|
48
|
+
if (typeof raw.name === "string")
|
|
49
|
+
summary.name = raw.name;
|
|
50
|
+
const update = raw.params?.update ?? raw.update;
|
|
51
|
+
if (update && typeof update === "object") {
|
|
52
|
+
const u = update;
|
|
53
|
+
if (typeof u.sessionUpdate === "string" && !summary.name)
|
|
54
|
+
summary.name = u.sessionUpdate;
|
|
55
|
+
const toolCall = u.toolCall;
|
|
56
|
+
if (toolCall && typeof toolCall === "object") {
|
|
57
|
+
const toolName = toolCall.name;
|
|
58
|
+
if (typeof toolName === "string")
|
|
59
|
+
summary.name = toolName;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return summary;
|
|
64
|
+
}
|
|
65
|
+
function redactAndCap(value, budget = TRANSCRIPT_BLOCK_RAW_LIMIT) {
|
|
66
|
+
const seen = new WeakSet();
|
|
67
|
+
const walk = (v) => {
|
|
68
|
+
if (typeof v === "string") {
|
|
69
|
+
return redactSecretString(v.length > budget ? `${v.slice(0, budget)}…` : v);
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(v))
|
|
72
|
+
return v.slice(0, 50).map(walk);
|
|
73
|
+
if (!v || typeof v !== "object")
|
|
74
|
+
return v;
|
|
75
|
+
if (seen.has(v))
|
|
76
|
+
return "[Circular]";
|
|
77
|
+
seen.add(v);
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [key, child] of Object.entries(v).slice(0, 80)) {
|
|
80
|
+
out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : walk(child);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
return walk(value);
|
|
85
|
+
}
|
|
86
|
+
function redactSecretString(value) {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
|
|
89
|
+
.replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
|
|
90
|
+
.replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
|
|
91
|
+
}
|
|
36
92
|
/**
|
|
37
93
|
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
38
94
|
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
@@ -701,15 +757,23 @@ export class Dispatcher {
|
|
|
701
757
|
(streamable || !isBotCordChannel(channel));
|
|
702
758
|
const canStream = streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
703
759
|
const recordBlock = (block) => {
|
|
704
|
-
const summary =
|
|
705
|
-
const raw = block.raw;
|
|
706
|
-
if (raw && typeof raw === "object") {
|
|
707
|
-
if (typeof raw.text === "string")
|
|
708
|
-
summary.chars = raw.text.length;
|
|
709
|
-
if (typeof raw.name === "string")
|
|
710
|
-
summary.name = raw.name;
|
|
711
|
-
}
|
|
760
|
+
const summary = summarizeStreamBlock(block);
|
|
712
761
|
slot.blocks.push(summary);
|
|
762
|
+
if (this.transcript.enabled) {
|
|
763
|
+
this.transcript.write({
|
|
764
|
+
ts: new Date().toISOString(),
|
|
765
|
+
kind: "block",
|
|
766
|
+
turnId,
|
|
767
|
+
agentId: msg.accountId,
|
|
768
|
+
roomId: msg.conversation.id,
|
|
769
|
+
topicId: msg.conversation.threadId ?? null,
|
|
770
|
+
runtime: route.runtime,
|
|
771
|
+
seq: block.seq,
|
|
772
|
+
blockType: block.kind,
|
|
773
|
+
summary,
|
|
774
|
+
...(transcriptBlocksVerbose() ? { raw: redactAndCap(block.raw) } : {}),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
713
777
|
};
|
|
714
778
|
// Owner-chat lifecycle state for typing/thinking. The dispatcher is the
|
|
715
779
|
// only component that sees turn boundaries + channel capabilities + trace
|
|
@@ -877,7 +941,7 @@ export class Dispatcher {
|
|
|
877
941
|
sendThinkingMarker(event.phase, event.label, "runtime");
|
|
878
942
|
}
|
|
879
943
|
: undefined;
|
|
880
|
-
const onBlock = canStream
|
|
944
|
+
const onBlock = (canStream || this.transcript.enabled)
|
|
881
945
|
? (block) => {
|
|
882
946
|
// Always record adapter-emitted blocks for transcript fidelity, even
|
|
883
947
|
// after abort — the transcript reflects what the runtime emitted,
|
|
@@ -885,6 +949,8 @@ export class Dispatcher {
|
|
|
885
949
|
recordBlock(block);
|
|
886
950
|
if (controller.signal.aborted)
|
|
887
951
|
return;
|
|
952
|
+
if (!canStream)
|
|
953
|
+
return;
|
|
888
954
|
// Synthesize thinking.started before non-assistant blocks. After
|
|
889
955
|
// we've seen any assistant_text, only `tool_use` may re-enter
|
|
890
956
|
// thinking — terminal markers like `system`/`other` (codex
|
|
@@ -904,7 +970,7 @@ export class Dispatcher {
|
|
|
904
970
|
thinkingActive = false;
|
|
905
971
|
sawAssistantText = true;
|
|
906
972
|
}
|
|
907
|
-
forwardBlockToChannel(block);
|
|
973
|
+
forwardBlockToChannel?.(block);
|
|
908
974
|
}
|
|
909
975
|
: undefined;
|
|
910
976
|
// Helper used by terminal paths (success / timeout / error) to ensure
|
|
@@ -58,7 +58,8 @@ export interface GatewayBootOptions {
|
|
|
58
58
|
* Tri-state convenience: if `transcript` is not provided, the gateway
|
|
59
59
|
* constructs a writer using this flag plus `transcriptRootDir`. Use
|
|
60
60
|
* {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
|
|
61
|
-
* the persistent daemon-config flag.
|
|
61
|
+
* the persistent daemon-config flag. When omitted, transcripts are enabled
|
|
62
|
+
* by default.
|
|
62
63
|
*/
|
|
63
64
|
transcriptEnabled?: boolean;
|
|
64
65
|
/** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -57,7 +57,7 @@ export class Gateway {
|
|
|
57
57
|
const transcript = opts.transcript
|
|
58
58
|
?? createTranscriptWriter({
|
|
59
59
|
log: this.log,
|
|
60
|
-
enabled: opts.transcriptEnabled
|
|
60
|
+
enabled: opts.transcriptEnabled,
|
|
61
61
|
rootDir: opts.transcriptRootDir,
|
|
62
62
|
});
|
|
63
63
|
this.dispatcher = new Dispatcher({
|