@botcord/daemon 0.1.1
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/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon ↔ Hub control-plane WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* One long-lived connection, carrying JSON {@link ControlFrame} messages.
|
|
5
|
+
* Independent from the agent data-plane WS: different auth (user access
|
|
6
|
+
* token vs agent JWT), different endpoint (`/daemon/ws`), different
|
|
7
|
+
* lifecycle (alive even when zero agents are bound).
|
|
8
|
+
*
|
|
9
|
+
* See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
|
|
10
|
+
*/
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import {
|
|
13
|
+
buildDaemonWebSocketUrl,
|
|
14
|
+
CONTROL_FRAME_TYPES,
|
|
15
|
+
jcsCanonicalize,
|
|
16
|
+
resolveHubControlPublicKey,
|
|
17
|
+
verifyEd25519,
|
|
18
|
+
type ControlAck,
|
|
19
|
+
type ControlFrame,
|
|
20
|
+
} from "@botcord/protocol-core";
|
|
21
|
+
import { log as daemonLog } from "./log.js";
|
|
22
|
+
import {
|
|
23
|
+
writeAuthExpiredFlag,
|
|
24
|
+
type UserAuthManager,
|
|
25
|
+
} from "./user-auth.js";
|
|
26
|
+
|
|
27
|
+
/** Exponential backoff plan for transient disconnects. */
|
|
28
|
+
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
29
|
+
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
30
|
+
const REPLAY_DEDUPE_CAP = 256;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the canonical signing input for a control frame: RFC 8785 (JCS)
|
|
34
|
+
* canonicalization of `{id, type, params, ts}`. Per
|
|
35
|
+
* `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
|
|
36
|
+
* `jcs.canonicalize` over the same object before signing.
|
|
37
|
+
*
|
|
38
|
+
* Excludes `sig` by definition. `params` defaults to `{}` (empty object)
|
|
39
|
+
* to match the Hub-side default for paramless types like `ping`.
|
|
40
|
+
*/
|
|
41
|
+
export function controlSigningInput(
|
|
42
|
+
frame: { id: string; type: string; ts?: number; params?: unknown },
|
|
43
|
+
): string {
|
|
44
|
+
const obj = {
|
|
45
|
+
id: frame.id,
|
|
46
|
+
type: frame.type,
|
|
47
|
+
params: (frame.params ?? {}) as Record<string, unknown>,
|
|
48
|
+
ts: typeof frame.ts === "number" ? frame.ts : 0,
|
|
49
|
+
};
|
|
50
|
+
return jcsCanonicalize(obj) ?? "{}";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Handler invoked for each inbound frame. Return value is the ack payload. */
|
|
54
|
+
export type ControlFrameHandler = (
|
|
55
|
+
frame: ControlFrame,
|
|
56
|
+
) => Promise<Omit<ControlAck, "id"> | void> | Omit<ControlAck, "id"> | void;
|
|
57
|
+
|
|
58
|
+
/** Options accepted by {@link ControlChannel}. */
|
|
59
|
+
export interface ControlChannelOptions {
|
|
60
|
+
/** User-auth manager driving the access token. */
|
|
61
|
+
auth: UserAuthManager;
|
|
62
|
+
/** Dispatcher for inbound frames. Unknown types should return an error ack. */
|
|
63
|
+
handle: ControlFrameHandler;
|
|
64
|
+
/** Override the WS endpoint path; defaults to `/daemon/ws`. */
|
|
65
|
+
path?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Optional human label sent to Hub on connect (`?label=...`). Hub uses it
|
|
68
|
+
* to populate `daemon_instances.label` for the dashboard listing. Plan §11.3.
|
|
69
|
+
*/
|
|
70
|
+
label?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Override the embedded Hub control-plane public key (raw 32-byte, base64).
|
|
73
|
+
* When omitted the channel falls back to {@link resolveHubControlPublicKey},
|
|
74
|
+
* which honors `BOTCORD_HUB_CONTROL_PUBLIC_KEY`.
|
|
75
|
+
*/
|
|
76
|
+
hubPublicKey?: string | null;
|
|
77
|
+
/** Test hook — inject a WebSocket constructor. */
|
|
78
|
+
webSocketCtor?: typeof WebSocket;
|
|
79
|
+
/** Test hook — override the backoff schedule. */
|
|
80
|
+
backoffMs?: number[];
|
|
81
|
+
/** Test hook — override the keepalive interval. */
|
|
82
|
+
keepaliveIntervalMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Long-lived, self-healing WS connection that carries control frames
|
|
87
|
+
* between the Hub and the local daemon. Owns reconnect/backoff and
|
|
88
|
+
* dedupe; delegates frame semantics to a caller-supplied handler.
|
|
89
|
+
*/
|
|
90
|
+
export class ControlChannel {
|
|
91
|
+
private readonly auth: UserAuthManager;
|
|
92
|
+
private readonly handle: ControlFrameHandler;
|
|
93
|
+
private readonly path: string;
|
|
94
|
+
private readonly label: string | undefined;
|
|
95
|
+
private readonly hubPublicKey: string | null;
|
|
96
|
+
private readonly webSocketCtor: typeof WebSocket;
|
|
97
|
+
private readonly backoff: number[];
|
|
98
|
+
private readonly keepaliveMs: number;
|
|
99
|
+
|
|
100
|
+
private ws: WebSocket | null = null;
|
|
101
|
+
private stopRequested = false;
|
|
102
|
+
private reconnectAttempts = 0;
|
|
103
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
105
|
+
private readonly seenFrameIds: string[] = [];
|
|
106
|
+
private connectInflight: Promise<void> | null = null;
|
|
107
|
+
private connected = false;
|
|
108
|
+
|
|
109
|
+
constructor(opts: ControlChannelOptions) {
|
|
110
|
+
this.auth = opts.auth;
|
|
111
|
+
this.handle = opts.handle;
|
|
112
|
+
this.path = opts.path ?? "/daemon/ws";
|
|
113
|
+
// Prefer an explicit `label` from start-time; fall back to whatever
|
|
114
|
+
// was persisted on the user-auth record at login.
|
|
115
|
+
this.label = opts.label ?? opts.auth.current?.label;
|
|
116
|
+
this.hubPublicKey =
|
|
117
|
+
opts.hubPublicKey === undefined ? resolveHubControlPublicKey() : opts.hubPublicKey;
|
|
118
|
+
this.webSocketCtor = opts.webSocketCtor ?? WebSocket;
|
|
119
|
+
this.backoff = opts.backoffMs ?? RECONNECT_BACKOFF_MS;
|
|
120
|
+
this.keepaliveMs = opts.keepaliveIntervalMs ?? KEEPALIVE_INTERVAL_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True once the initial WS handshake succeeded. Flipped back on close. */
|
|
124
|
+
get isConnected(): boolean {
|
|
125
|
+
return this.connected;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Open the WS. Resolves after the first `open` event — transient
|
|
130
|
+
* reconnects after that run in the background until `stop()` is
|
|
131
|
+
* called. Throws immediately if no user-auth record is loaded.
|
|
132
|
+
*/
|
|
133
|
+
async start(): Promise<void> {
|
|
134
|
+
if (!this.auth.current) {
|
|
135
|
+
throw new Error("control-channel requires user-auth; run `botcord-daemon start`");
|
|
136
|
+
}
|
|
137
|
+
if (this.connectInflight) return this.connectInflight;
|
|
138
|
+
this.stopRequested = false;
|
|
139
|
+
daemonLog.info("control-channel starting", {
|
|
140
|
+
userId: this.auth.current.userId,
|
|
141
|
+
hubUrl: this.auth.current.hubUrl,
|
|
142
|
+
path: this.path,
|
|
143
|
+
label: this.label ?? null,
|
|
144
|
+
hubKeyConfigured: !!this.hubPublicKey,
|
|
145
|
+
});
|
|
146
|
+
this.connectInflight = this.connect().catch((err) => {
|
|
147
|
+
// Initial connect failure surfaces to the caller; subsequent
|
|
148
|
+
// reconnects are handled opaquely inside onClose.
|
|
149
|
+
this.scheduleReconnect(err);
|
|
150
|
+
throw err;
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
await this.connectInflight;
|
|
154
|
+
} finally {
|
|
155
|
+
this.connectInflight = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Close the WS and stop reconnecting. Idempotent. */
|
|
160
|
+
async stop(): Promise<void> {
|
|
161
|
+
if (!this.stopRequested) {
|
|
162
|
+
daemonLog.info("control-channel stopping", { wasConnected: this.connected });
|
|
163
|
+
}
|
|
164
|
+
this.stopRequested = true;
|
|
165
|
+
if (this.reconnectTimer) {
|
|
166
|
+
clearTimeout(this.reconnectTimer);
|
|
167
|
+
this.reconnectTimer = null;
|
|
168
|
+
}
|
|
169
|
+
this.stopKeepalive();
|
|
170
|
+
const ws = this.ws;
|
|
171
|
+
this.ws = null;
|
|
172
|
+
this.connected = false;
|
|
173
|
+
if (ws) {
|
|
174
|
+
try {
|
|
175
|
+
ws.close(1000, "daemon stopping");
|
|
176
|
+
} catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Actively send a frame (used for event reports like `agent_provisioned`). */
|
|
183
|
+
send(frame: ControlFrame): boolean {
|
|
184
|
+
const ws = this.ws;
|
|
185
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
186
|
+
daemonLog.debug("control-channel.send skipped (not open)", {
|
|
187
|
+
type: frame.type,
|
|
188
|
+
id: frame.id,
|
|
189
|
+
readyState: ws?.readyState ?? null,
|
|
190
|
+
});
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
ws.send(JSON.stringify(frame));
|
|
195
|
+
daemonLog.debug("control-channel.send", { type: frame.type, id: frame.id });
|
|
196
|
+
return true;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
daemonLog.warn("control-channel.send failed", {
|
|
199
|
+
type: frame.type,
|
|
200
|
+
error: err instanceof Error ? err.message : String(err),
|
|
201
|
+
});
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async connect(): Promise<void> {
|
|
207
|
+
const record = this.auth.current;
|
|
208
|
+
if (!record) throw new Error("control-channel: no user-auth");
|
|
209
|
+
|
|
210
|
+
const accessToken = await this.auth.ensureAccessToken();
|
|
211
|
+
const url = buildDaemonWebSocketUrl(
|
|
212
|
+
record.hubUrl,
|
|
213
|
+
this.path,
|
|
214
|
+
this.label ? { label: this.label } : undefined,
|
|
215
|
+
);
|
|
216
|
+
daemonLog.info("control-channel connecting", { url });
|
|
217
|
+
|
|
218
|
+
const ws = new this.webSocketCtor(url, {
|
|
219
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
220
|
+
});
|
|
221
|
+
this.ws = ws;
|
|
222
|
+
|
|
223
|
+
await new Promise<void>((resolve, reject) => {
|
|
224
|
+
const onOpen = (): void => {
|
|
225
|
+
ws.removeListener("error", onError);
|
|
226
|
+
this.connected = true;
|
|
227
|
+
this.reconnectAttempts = 0;
|
|
228
|
+
daemonLog.info("control-channel connected", { url });
|
|
229
|
+
this.startKeepalive();
|
|
230
|
+
resolve();
|
|
231
|
+
};
|
|
232
|
+
const onError = (err: Error): void => {
|
|
233
|
+
ws.removeListener("open", onOpen);
|
|
234
|
+
reject(err);
|
|
235
|
+
};
|
|
236
|
+
ws.once("open", onOpen);
|
|
237
|
+
ws.once("error", onError);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
ws.on("message", (data) => this.onMessage(data));
|
|
241
|
+
ws.on("close", (code, reason) => this.onClose(code, reason));
|
|
242
|
+
ws.on("error", (err) =>
|
|
243
|
+
daemonLog.warn("control-channel error", {
|
|
244
|
+
error: err instanceof Error ? err.message : String(err),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private startKeepalive(): void {
|
|
250
|
+
this.stopKeepalive();
|
|
251
|
+
this.keepaliveTimer = setInterval(() => {
|
|
252
|
+
const ws = this.ws;
|
|
253
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
254
|
+
try {
|
|
255
|
+
ws.ping();
|
|
256
|
+
} catch {
|
|
257
|
+
// ignore — next failed send will trigger close
|
|
258
|
+
}
|
|
259
|
+
}, this.keepaliveMs);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private stopKeepalive(): void {
|
|
263
|
+
if (this.keepaliveTimer) {
|
|
264
|
+
clearInterval(this.keepaliveTimer);
|
|
265
|
+
this.keepaliveTimer = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private onClose(code: number, reason: Buffer): void {
|
|
270
|
+
const reasonText = reason?.toString() || "";
|
|
271
|
+
this.connected = false;
|
|
272
|
+
this.stopKeepalive();
|
|
273
|
+
this.ws = null;
|
|
274
|
+
daemonLog.info("control-channel closed", { code, reason: reasonText });
|
|
275
|
+
|
|
276
|
+
// 4401 / 4403 = auth problem per the plan. Surface via the flag file
|
|
277
|
+
// and stop reconnecting; the daemon is now in a "needs re-login" state.
|
|
278
|
+
if (code === 4401 || code === 4403 || code === 1008) {
|
|
279
|
+
daemonLog.warn("control-channel auth rejected; marking auth expired", { code });
|
|
280
|
+
writeAuthExpiredFlag();
|
|
281
|
+
this.stopRequested = true;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (this.stopRequested) return;
|
|
286
|
+
this.scheduleReconnect();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private scheduleReconnect(err?: unknown): void {
|
|
290
|
+
if (this.stopRequested) return;
|
|
291
|
+
const attempt = this.reconnectAttempts;
|
|
292
|
+
this.reconnectAttempts = attempt + 1;
|
|
293
|
+
const delay = this.backoff[Math.min(attempt, this.backoff.length - 1)];
|
|
294
|
+
if (err) {
|
|
295
|
+
daemonLog.warn("control-channel reconnect scheduled", {
|
|
296
|
+
delayMs: delay,
|
|
297
|
+
error: err instanceof Error ? err.message : String(err),
|
|
298
|
+
});
|
|
299
|
+
} else {
|
|
300
|
+
daemonLog.info("control-channel reconnect scheduled", { delayMs: delay });
|
|
301
|
+
}
|
|
302
|
+
this.reconnectTimer = setTimeout(() => {
|
|
303
|
+
this.reconnectTimer = null;
|
|
304
|
+
if (this.stopRequested) return;
|
|
305
|
+
this.connect().catch((err) => this.scheduleReconnect(err));
|
|
306
|
+
}, delay);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async onMessage(data: WebSocket.RawData): Promise<void> {
|
|
310
|
+
let frame: ControlFrame;
|
|
311
|
+
try {
|
|
312
|
+
frame = JSON.parse(data.toString()) as ControlFrame;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
daemonLog.warn("control-channel: non-JSON frame", {
|
|
315
|
+
error: err instanceof Error ? err.message : String(err),
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!frame || typeof frame.id !== "string" || typeof frame.type !== "string") {
|
|
320
|
+
daemonLog.warn("control-channel: malformed frame", { frame });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Replay-window check: Hub-signed frames carry `ts`; absent on pong
|
|
325
|
+
// responses, so skip the check when not present.
|
|
326
|
+
if (typeof frame.ts === "number") {
|
|
327
|
+
const skewMs = Math.abs(Date.now() - frame.ts);
|
|
328
|
+
if (skewMs > 5 * 60 * 1000) {
|
|
329
|
+
daemonLog.warn("control-channel: rejecting frame with stale ts", {
|
|
330
|
+
type: frame.type,
|
|
331
|
+
id: frame.id,
|
|
332
|
+
skewMs,
|
|
333
|
+
});
|
|
334
|
+
this.sendAck({
|
|
335
|
+
id: frame.id,
|
|
336
|
+
ok: false,
|
|
337
|
+
error: { code: "stale_ts", message: `timestamp skew ${skewMs}ms exceeds window` },
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Hub→daemon signature check. Plan §8.3 mandates rejection of unsigned
|
|
344
|
+
// frames once a Hub key is configured. When no key is available (P1
|
|
345
|
+
// dev / placeholder constant), we log a warning per frame and accept,
|
|
346
|
+
// so the daemon can still bring up the control plane against a Hub
|
|
347
|
+
// that hasn't published its key yet.
|
|
348
|
+
if (this.hubPublicKey) {
|
|
349
|
+
if (typeof frame.sig !== "string" || frame.sig.length === 0) {
|
|
350
|
+
daemonLog.warn("control-channel: rejecting unsigned frame", {
|
|
351
|
+
type: frame.type,
|
|
352
|
+
id: frame.id,
|
|
353
|
+
});
|
|
354
|
+
this.sendAck({
|
|
355
|
+
id: frame.id,
|
|
356
|
+
ok: false,
|
|
357
|
+
error: { code: "unsigned", message: "hub signature required" },
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!verifyEd25519(this.hubPublicKey, controlSigningInput(frame), frame.sig)) {
|
|
362
|
+
daemonLog.warn("control-channel: rejecting frame with bad signature", {
|
|
363
|
+
type: frame.type,
|
|
364
|
+
id: frame.id,
|
|
365
|
+
});
|
|
366
|
+
this.sendAck({
|
|
367
|
+
id: frame.id,
|
|
368
|
+
ok: false,
|
|
369
|
+
error: { code: "bad_signature", message: "hub signature did not verify" },
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
} else if (typeof frame.sig === "string") {
|
|
374
|
+
// Key not configured yet; skip verification but warn loudly.
|
|
375
|
+
daemonLog.warn(
|
|
376
|
+
"control-channel: skipping signature verification (no Hub public key configured)",
|
|
377
|
+
{ type: frame.type, id: frame.id },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Idempotent dedupe: replay the cached result would require storing
|
|
382
|
+
// past results — for P0 we just ack the dup with a no-op ok. The
|
|
383
|
+
// provisioner itself is the authoritative dedupe boundary for
|
|
384
|
+
// stateful operations.
|
|
385
|
+
if (this.seenFrameIds.includes(frame.id)) {
|
|
386
|
+
daemonLog.debug("control-channel: duplicate frame, acking as no-op", {
|
|
387
|
+
type: frame.type,
|
|
388
|
+
id: frame.id,
|
|
389
|
+
});
|
|
390
|
+
this.sendAck({ id: frame.id, ok: true, result: { duplicate: true } });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.seenFrameIds.push(frame.id);
|
|
394
|
+
if (this.seenFrameIds.length > REPLAY_DEDUPE_CAP) {
|
|
395
|
+
this.seenFrameIds.splice(0, this.seenFrameIds.length - REPLAY_DEDUPE_CAP);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
daemonLog.debug("control-channel frame received", {
|
|
399
|
+
type: frame.type,
|
|
400
|
+
id: frame.id,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Plan §6.3 — instance-level revoke: write the expired flag, ack, and
|
|
404
|
+
// tear down the control plane. Agent gateway stays up so existing
|
|
405
|
+
// agent tokens keep working until the operator re-authorizes.
|
|
406
|
+
if (frame.type === CONTROL_FRAME_TYPES.REVOKE) {
|
|
407
|
+
writeAuthExpiredFlag();
|
|
408
|
+
const reason =
|
|
409
|
+
frame.params && typeof (frame.params as { reason?: unknown }).reason === "string"
|
|
410
|
+
? ((frame.params as { reason?: string }).reason as string)
|
|
411
|
+
: "revoked_by_hub";
|
|
412
|
+
daemonLog.warn("control-channel: instance revoked by hub", { reason });
|
|
413
|
+
this.sendAck({ id: frame.id, ok: true, result: { acknowledged: true } });
|
|
414
|
+
void this.stop();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const result = await this.handle(frame);
|
|
420
|
+
const ack: ControlAck = result
|
|
421
|
+
? { id: frame.id, ...result }
|
|
422
|
+
: { id: frame.id, ok: true };
|
|
423
|
+
daemonLog.debug("control-channel handler done", {
|
|
424
|
+
type: frame.type,
|
|
425
|
+
id: frame.id,
|
|
426
|
+
ok: ack.ok !== false,
|
|
427
|
+
});
|
|
428
|
+
this.sendAck(ack);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
+
daemonLog.error("control-channel handler failed", {
|
|
432
|
+
type: frame.type,
|
|
433
|
+
error: message,
|
|
434
|
+
});
|
|
435
|
+
this.sendAck({
|
|
436
|
+
id: frame.id,
|
|
437
|
+
ok: false,
|
|
438
|
+
error: { code: "handler_error", message },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private sendAck(ack: ControlAck): void {
|
|
444
|
+
const ws = this.ws;
|
|
445
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
446
|
+
try {
|
|
447
|
+
ws.send(JSON.stringify(ack));
|
|
448
|
+
} catch (err) {
|
|
449
|
+
daemonLog.warn("control-channel.ack failed", {
|
|
450
|
+
id: ack.id,
|
|
451
|
+
error: err instanceof Error ? err.message : String(err),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-room digest — a short block listing other rooms the agent is
|
|
3
|
+
* actively talking in, so a single turn isn't blind to parallel
|
|
4
|
+
* conversations.
|
|
5
|
+
*
|
|
6
|
+
* Unlike the plugin version (which reads OpenClaw's per-session message
|
|
7
|
+
* history via `runtime.subagent.getSessionMessages()`), the daemon doesn't
|
|
8
|
+
* have access to the underlying CLI's transcript. We synthesize the digest
|
|
9
|
+
* from the ActivityTracker's last-inbound preview instead — lower fidelity,
|
|
10
|
+
* but works uniformly across Claude Code / Codex / Gemini.
|
|
11
|
+
*/
|
|
12
|
+
import type { ActivityEntry, ActivityTracker } from "./activity-tracker.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 5;
|
|
15
|
+
const DEFAULT_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export interface DigestOptions {
|
|
18
|
+
tracker: ActivityTracker;
|
|
19
|
+
agentId: string;
|
|
20
|
+
currentRoomId: string;
|
|
21
|
+
currentTopic?: string | null;
|
|
22
|
+
/** Time horizon for "active". Default 2 hours. */
|
|
23
|
+
windowMs?: number;
|
|
24
|
+
/** Cap on how many rooms appear in the digest. Default 5. */
|
|
25
|
+
maxEntries?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildCrossRoomDigest(opts: DigestOptions): string | null {
|
|
29
|
+
const {
|
|
30
|
+
tracker,
|
|
31
|
+
agentId,
|
|
32
|
+
currentRoomId,
|
|
33
|
+
currentTopic = null,
|
|
34
|
+
windowMs = DEFAULT_WINDOW_MS,
|
|
35
|
+
maxEntries = DEFAULT_MAX_ENTRIES,
|
|
36
|
+
} = opts;
|
|
37
|
+
|
|
38
|
+
const excludeKey = tracker.keyFor(agentId, currentRoomId, currentTopic ?? null);
|
|
39
|
+
const entries = tracker.listActive({ agentId, windowMs, excludeKey });
|
|
40
|
+
if (entries.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const slice = entries.slice(0, maxEntries);
|
|
43
|
+
const total = entries.length + 1; // +1 for the current turn's room
|
|
44
|
+
|
|
45
|
+
const lines: string[] = [
|
|
46
|
+
"[BotCord Cross-Room Awareness]",
|
|
47
|
+
`You are currently active in ${total} BotCord sessions. Recent activity from other rooms:`,
|
|
48
|
+
];
|
|
49
|
+
for (const e of slice) {
|
|
50
|
+
lines.push(formatEntry(e));
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatEntry(e: ActivityEntry): string {
|
|
56
|
+
const ago = formatTimeAgo(e.lastActivityAt);
|
|
57
|
+
const senderLabel = describeSender(e);
|
|
58
|
+
const roomLabel = e.roomName ? `${e.roomName} (${e.roomId})` : e.roomId;
|
|
59
|
+
const topicLabel = e.topic ? ` / topic ${e.topic}` : "";
|
|
60
|
+
const preview = e.lastInboundPreview.trim();
|
|
61
|
+
if (!preview) {
|
|
62
|
+
return `- ${roomLabel}${topicLabel} — last activity ${ago}, no preview`;
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
`- ${roomLabel}${topicLabel} — last ${ago}`,
|
|
66
|
+
` ${senderLabel}: ${preview}`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function describeSender(e: ActivityEntry): string {
|
|
71
|
+
switch (e.lastSenderKind) {
|
|
72
|
+
case "human":
|
|
73
|
+
return `human ${e.lastSender}`;
|
|
74
|
+
case "owner":
|
|
75
|
+
return `owner`;
|
|
76
|
+
default:
|
|
77
|
+
return `agent ${e.lastSender}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatTimeAgo(ts: number): string {
|
|
82
|
+
const diffMs = Date.now() - ts;
|
|
83
|
+
if (diffMs < 0) return "just now";
|
|
84
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
85
|
+
if (diffMin < 1) return "just now";
|
|
86
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
87
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
88
|
+
return `${diffHr}h ago`;
|
|
89
|
+
}
|