@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,431 @@
|
|
|
1
|
+
import { resolveRoute } from "./router.js";
|
|
2
|
+
import { sessionKey } from "./session-store.js";
|
|
3
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
6
|
+
* turn per message, respecting queue mode, trust level, streaming, and
|
|
7
|
+
* session persistence rules from the plan (§7/§9/§10/§11/§12/§13).
|
|
8
|
+
*
|
|
9
|
+
* Deliberate deviation from daemon: this core does NOT wrap inbound text in
|
|
10
|
+
* BotCord-style XML envelopes for untrusted content. The channel adapter is
|
|
11
|
+
* responsible for any channel-specific sanitization; the dispatcher passes
|
|
12
|
+
* `message.text` through to the runtime as-is (plan §15).
|
|
13
|
+
*/
|
|
14
|
+
export class Dispatcher {
|
|
15
|
+
config;
|
|
16
|
+
channels;
|
|
17
|
+
runtimeFactory;
|
|
18
|
+
sessionStore;
|
|
19
|
+
log;
|
|
20
|
+
turnTimeoutMs;
|
|
21
|
+
buildSystemContext;
|
|
22
|
+
onInbound;
|
|
23
|
+
composeUserTurn;
|
|
24
|
+
managedRoutes;
|
|
25
|
+
queues = new Map();
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
this.config = opts.config;
|
|
28
|
+
this.channels = opts.channels;
|
|
29
|
+
this.runtimeFactory = opts.runtime;
|
|
30
|
+
this.sessionStore = opts.sessionStore;
|
|
31
|
+
this.log = opts.log;
|
|
32
|
+
this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
33
|
+
this.buildSystemContext = opts.buildSystemContext;
|
|
34
|
+
this.onInbound = opts.onInbound;
|
|
35
|
+
this.composeUserTurn = opts.composeUserTurn;
|
|
36
|
+
this.managedRoutes = opts.managedRoutes;
|
|
37
|
+
}
|
|
38
|
+
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
39
|
+
async handle(envelope) {
|
|
40
|
+
const msg = envelope.message;
|
|
41
|
+
// Skip rule: empty/whitespace text.
|
|
42
|
+
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
43
|
+
if (!rawText) {
|
|
44
|
+
this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
|
|
45
|
+
await this.safeAck(envelope);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Skip rule: echo from the agent itself (own agent output looped back).
|
|
49
|
+
// Owner/human messages in dashboard rooms share the agent's id as sender.id
|
|
50
|
+
// but carry sender.kind === "user", so we only skip when kind === "agent".
|
|
51
|
+
if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
|
|
52
|
+
this.log.debug("dispatcher skip: own message", { messageId: msg.id });
|
|
53
|
+
await this.safeAck(envelope);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Compose the final user-turn text. The composer can enrich the raw
|
|
57
|
+
// message with sender label, room header, NO_REPLY hint, etc. — anything
|
|
58
|
+
// that should land in the runtime transcript. Failures fall back to the
|
|
59
|
+
// raw trimmed text so a buggy composer cannot drop turns.
|
|
60
|
+
let text = rawText;
|
|
61
|
+
if (this.composeUserTurn) {
|
|
62
|
+
try {
|
|
63
|
+
const composed = this.composeUserTurn(msg);
|
|
64
|
+
if (typeof composed === "string" && composed.length > 0) {
|
|
65
|
+
text = composed;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
70
|
+
messageId: msg.id,
|
|
71
|
+
error: err instanceof Error ? err.message : String(err),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
76
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
77
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
78
|
+
const queueKey = buildQueueKey(msg);
|
|
79
|
+
// Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
|
|
80
|
+
await this.safeAck(envelope);
|
|
81
|
+
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
82
|
+
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
83
|
+
if (this.onInbound) {
|
|
84
|
+
try {
|
|
85
|
+
await this.onInbound(msg);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
this.log.warn("dispatcher: onInbound threw — continuing", {
|
|
89
|
+
messageId: msg.id,
|
|
90
|
+
error: err instanceof Error ? err.message : String(err),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const channel = this.channels.get(msg.channel);
|
|
95
|
+
if (!channel) {
|
|
96
|
+
this.log.warn("dispatcher: unknown channel for outbound reply", {
|
|
97
|
+
channel: msg.channel,
|
|
98
|
+
messageId: msg.id,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (mode === "cancel-previous") {
|
|
103
|
+
await this.runCancelPrevious(queueKey, route, text, msg, channel);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await this.runSerial(queueKey, route, text, msg, channel);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Snapshot of currently running turns keyed by queue key. */
|
|
110
|
+
turns() {
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [key, q] of this.queues) {
|
|
113
|
+
if (q.current)
|
|
114
|
+
out[key] = { ...q.current.snapshot };
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Internals
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
async safeAck(env) {
|
|
122
|
+
const accept = env.ack?.accept;
|
|
123
|
+
if (!accept)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
await accept.call(env.ack);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
this.log.warn("dispatcher: ack.accept failed", {
|
|
130
|
+
messageId: env.message.id,
|
|
131
|
+
error: err instanceof Error ? err.message : String(err),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
getQueue(key) {
|
|
136
|
+
let q = this.queues.get(key);
|
|
137
|
+
if (!q) {
|
|
138
|
+
q = {
|
|
139
|
+
current: null,
|
|
140
|
+
tail: Promise.resolve(),
|
|
141
|
+
cancelGen: 0,
|
|
142
|
+
};
|
|
143
|
+
this.queues.set(key, q);
|
|
144
|
+
}
|
|
145
|
+
return q;
|
|
146
|
+
}
|
|
147
|
+
async runCancelPrevious(queueKey, route, text, msg, channel) {
|
|
148
|
+
const q = this.getQueue(queueKey);
|
|
149
|
+
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
150
|
+
// the prior turn's teardown will observe `myGen !== q.cancelGen` when
|
|
151
|
+
// they resume and drop out, so only the newest message reaches runTurn.
|
|
152
|
+
q.cancelGen += 1;
|
|
153
|
+
const myGen = q.cancelGen;
|
|
154
|
+
const prev = q.current;
|
|
155
|
+
if (prev) {
|
|
156
|
+
this.log.info("dispatcher: cancelling previous turn", { queueKey });
|
|
157
|
+
prev.controller.abort();
|
|
158
|
+
// Wait for it to finish cleanup (it won't reply, won't persist).
|
|
159
|
+
await prev.done.catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
// After the await, a newer cancel-previous may have arrived and either
|
|
162
|
+
// already fired its own abort + runTurn, or be mid-await itself. If so,
|
|
163
|
+
// drop out silently — the newest turn is the only one that should run.
|
|
164
|
+
if (myGen !== q.cancelGen) {
|
|
165
|
+
this.log.info("dispatcher: cancel-previous superseded", { queueKey });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await this.runTurn(queueKey, route, text, msg, channel);
|
|
169
|
+
}
|
|
170
|
+
async runSerial(queueKey, route, text, msg, channel) {
|
|
171
|
+
const q = this.getQueue(queueKey);
|
|
172
|
+
const prev = q.tail;
|
|
173
|
+
const next = prev.then(() => this.runTurn(queueKey, route, text, msg, channel));
|
|
174
|
+
q.tail = next.catch(() => undefined);
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
async runTurn(queueKey, route, text, msg, channel) {
|
|
178
|
+
const q = this.getQueue(queueKey);
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const startedAt = Date.now();
|
|
181
|
+
const snapshot = {
|
|
182
|
+
key: queueKey,
|
|
183
|
+
channel: msg.channel,
|
|
184
|
+
accountId: msg.accountId,
|
|
185
|
+
conversationId: msg.conversation.id,
|
|
186
|
+
runtime: route.runtime,
|
|
187
|
+
cwd: route.cwd,
|
|
188
|
+
startedAt,
|
|
189
|
+
};
|
|
190
|
+
let resolveDone;
|
|
191
|
+
const done = new Promise((res) => {
|
|
192
|
+
resolveDone = res;
|
|
193
|
+
});
|
|
194
|
+
const slot = { controller, timedOut: false, snapshot, done };
|
|
195
|
+
q.current = slot;
|
|
196
|
+
// Hard-cap turn with a timeout.
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
slot.timedOut = true;
|
|
199
|
+
this.log.warn("dispatcher: turn timed out", {
|
|
200
|
+
queueKey,
|
|
201
|
+
timeoutMs: this.turnTimeoutMs,
|
|
202
|
+
});
|
|
203
|
+
controller.abort();
|
|
204
|
+
}, this.turnTimeoutMs);
|
|
205
|
+
if (typeof timer.unref === "function")
|
|
206
|
+
timer.unref();
|
|
207
|
+
const key = sessionKey({
|
|
208
|
+
runtime: route.runtime,
|
|
209
|
+
channel: msg.channel,
|
|
210
|
+
accountId: msg.accountId,
|
|
211
|
+
conversationKind: msg.conversation.kind,
|
|
212
|
+
conversationId: msg.conversation.id,
|
|
213
|
+
threadId: msg.conversation.threadId ?? null,
|
|
214
|
+
});
|
|
215
|
+
const entry = this.sessionStore.get(key);
|
|
216
|
+
const sessionId = entry?.runtimeSessionId ?? null;
|
|
217
|
+
const trustLevel = route.trustLevel ?? "trusted";
|
|
218
|
+
const streamable = msg.trace?.streamable === true;
|
|
219
|
+
const traceId = msg.trace?.id;
|
|
220
|
+
const canStream = streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
221
|
+
const onBlock = canStream
|
|
222
|
+
? (block) => {
|
|
223
|
+
// Fire-and-forget: stream errors must not break the turn.
|
|
224
|
+
channel
|
|
225
|
+
.streamBlock({
|
|
226
|
+
traceId: traceId,
|
|
227
|
+
accountId: msg.accountId,
|
|
228
|
+
conversationId: msg.conversation.id,
|
|
229
|
+
block,
|
|
230
|
+
log: this.log,
|
|
231
|
+
})
|
|
232
|
+
.catch((err) => {
|
|
233
|
+
this.log.warn("dispatcher: streamBlock failed", {
|
|
234
|
+
traceId,
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
: undefined;
|
|
240
|
+
// Compute systemContext right before dispatch. The builder must NOT block
|
|
241
|
+
// the turn on failure — log and continue so a flaky memory read can't
|
|
242
|
+
// silence the agent.
|
|
243
|
+
let systemContext;
|
|
244
|
+
if (this.buildSystemContext) {
|
|
245
|
+
try {
|
|
246
|
+
const result = await this.buildSystemContext(msg);
|
|
247
|
+
if (typeof result === "string" && result.length > 0) {
|
|
248
|
+
systemContext = result;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
this.log.warn("buildSystemContext threw — continuing without systemContext", {
|
|
253
|
+
error: err instanceof Error ? err.message : String(err),
|
|
254
|
+
messageId: msg.id,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
|
|
259
|
+
let result;
|
|
260
|
+
let threw;
|
|
261
|
+
try {
|
|
262
|
+
try {
|
|
263
|
+
result = await runtime.run({
|
|
264
|
+
text,
|
|
265
|
+
sessionId,
|
|
266
|
+
cwd: route.cwd,
|
|
267
|
+
accountId: msg.accountId,
|
|
268
|
+
extraArgs: route.extraArgs,
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
trustLevel,
|
|
271
|
+
systemContext,
|
|
272
|
+
onBlock,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
threw = err;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
}
|
|
281
|
+
// Re-check the abort signal AFTER runtime.run resolves but BEFORE any
|
|
282
|
+
// side effects (session write, reply send). This closes the race where
|
|
283
|
+
// a cancel-previous arrives between runtime.run resolving and the
|
|
284
|
+
// post-runtime block running: keeping `q.current` pointing at this slot
|
|
285
|
+
// until after the reply lets the new arrival trip our abort signal, and
|
|
286
|
+
// this check then drops us silently. Timed-out turns still fall through
|
|
287
|
+
// to send their error reply.
|
|
288
|
+
if (controller.signal.aborted && !slot.timedOut) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (slot.timedOut) {
|
|
292
|
+
await this.sendReply(channel, {
|
|
293
|
+
channel: msg.channel,
|
|
294
|
+
accountId: msg.accountId,
|
|
295
|
+
conversationId: msg.conversation.id,
|
|
296
|
+
threadId: msg.conversation.threadId ?? null,
|
|
297
|
+
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
298
|
+
replyTo: msg.id,
|
|
299
|
+
traceId: msg.trace?.id ?? null,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (threw) {
|
|
304
|
+
this.log.error("dispatcher: runtime threw", {
|
|
305
|
+
queueKey,
|
|
306
|
+
runtime: route.runtime,
|
|
307
|
+
error: threw instanceof Error ? threw.message : String(threw),
|
|
308
|
+
});
|
|
309
|
+
const shortMsg = threw instanceof Error ? threw.message : String(threw);
|
|
310
|
+
await this.sendReply(channel, {
|
|
311
|
+
channel: msg.channel,
|
|
312
|
+
accountId: msg.accountId,
|
|
313
|
+
conversationId: msg.conversation.id,
|
|
314
|
+
threadId: msg.conversation.threadId ?? null,
|
|
315
|
+
text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
|
|
316
|
+
replyTo: msg.id,
|
|
317
|
+
traceId: msg.trace?.id ?? null,
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!result)
|
|
322
|
+
return;
|
|
323
|
+
// Persist session before reply so next turn sees the new id even if send fails.
|
|
324
|
+
//
|
|
325
|
+
// Adapter contract:
|
|
326
|
+
// result.newSessionId truthy → upsert the entry
|
|
327
|
+
// result.newSessionId empty + had-inbound-sessionId + result.error
|
|
328
|
+
// → the prior session is dead (e.g. Claude Code
|
|
329
|
+
// "--resume <missing-uuid>"); delete the entry so
|
|
330
|
+
// we don't keep resuming a stale id every turn
|
|
331
|
+
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
332
|
+
if (result.newSessionId) {
|
|
333
|
+
const session = {
|
|
334
|
+
key,
|
|
335
|
+
runtime: route.runtime,
|
|
336
|
+
runtimeSessionId: result.newSessionId,
|
|
337
|
+
channel: msg.channel,
|
|
338
|
+
accountId: msg.accountId,
|
|
339
|
+
conversationKind: msg.conversation.kind,
|
|
340
|
+
conversationId: msg.conversation.id,
|
|
341
|
+
threadId: msg.conversation.threadId ?? null,
|
|
342
|
+
cwd: route.cwd,
|
|
343
|
+
updatedAt: Date.now(),
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
const prevRuntimeSessionId = sessionId;
|
|
347
|
+
await this.sessionStore.set(session);
|
|
348
|
+
this.log.debug("dispatcher: persisted runtime session", {
|
|
349
|
+
key,
|
|
350
|
+
prevRuntimeSessionId,
|
|
351
|
+
nextRuntimeSessionId: result.newSessionId,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
this.log.warn("dispatcher: session-store.set failed", {
|
|
356
|
+
key,
|
|
357
|
+
error: err instanceof Error ? err.message : String(err),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (sessionId && result.error) {
|
|
362
|
+
try {
|
|
363
|
+
await this.sessionStore.delete(key);
|
|
364
|
+
this.log.info("dispatcher: dropped stale runtime session", {
|
|
365
|
+
key,
|
|
366
|
+
prevRuntimeSessionId: sessionId,
|
|
367
|
+
error: result.error,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
this.log.warn("dispatcher: session-store.delete failed", {
|
|
372
|
+
key,
|
|
373
|
+
error: err instanceof Error ? err.message : String(err),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const replyText = (result.text || "").trim();
|
|
378
|
+
if (!replyText)
|
|
379
|
+
return;
|
|
380
|
+
// One last abort check immediately before the send. Narrows the window
|
|
381
|
+
// in which a cancel-previous arriving during session-store.set could
|
|
382
|
+
// still slip a stale reply past us.
|
|
383
|
+
if (controller.signal.aborted && !slot.timedOut) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await this.sendReply(channel, {
|
|
387
|
+
channel: msg.channel,
|
|
388
|
+
accountId: msg.accountId,
|
|
389
|
+
conversationId: msg.conversation.id,
|
|
390
|
+
threadId: msg.conversation.threadId ?? null,
|
|
391
|
+
text: replyText,
|
|
392
|
+
replyTo: msg.id,
|
|
393
|
+
traceId: msg.trace?.id ?? null,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
398
|
+
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
399
|
+
// is exactly what we want: while we're in the post-runtime window, a
|
|
400
|
+
// newer arrival should find `q.current === slot`, call `abort()`, and
|
|
401
|
+
// let our abort-checks above drop this turn silently.
|
|
402
|
+
if (q.current === slot)
|
|
403
|
+
q.current = null;
|
|
404
|
+
resolveDone();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async sendReply(channel, outbound) {
|
|
408
|
+
try {
|
|
409
|
+
await channel.send({ message: outbound, log: this.log });
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
this.log.warn("dispatcher: channel.send failed", {
|
|
413
|
+
channel: outbound.channel,
|
|
414
|
+
conversationId: outbound.conversationId,
|
|
415
|
+
error: err instanceof Error ? err.message : String(err),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function buildQueueKey(msg) {
|
|
421
|
+
const thread = msg.conversation.threadId ?? "";
|
|
422
|
+
return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
|
|
423
|
+
}
|
|
424
|
+
function resolveQueueMode(route, kind) {
|
|
425
|
+
if (route.queueMode)
|
|
426
|
+
return route.queueMode;
|
|
427
|
+
return kind === "direct" ? "cancel-previous" : "serial";
|
|
428
|
+
}
|
|
429
|
+
function truncate(s, max) {
|
|
430
|
+
return s.length <= max ? s : s.slice(0, max) + "…";
|
|
431
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
2
|
+
import { type RuntimeFactory } from "./dispatcher.js";
|
|
3
|
+
import { type GatewayLogger } from "./log.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
|
+
/** Constructor options for `Gateway`. */
|
|
6
|
+
export interface GatewayBootOptions {
|
|
7
|
+
config: GatewayConfig;
|
|
8
|
+
sessionStorePath: string;
|
|
9
|
+
/** Max age for persisted runtime session entries. Defaults to 30 days. */
|
|
10
|
+
sessionStoreMaxEntryAgeMs?: number;
|
|
11
|
+
createChannel: (cfg: GatewayChannelConfig) => ChannelAdapter;
|
|
12
|
+
createRuntime?: RuntimeFactory;
|
|
13
|
+
log?: GatewayLogger;
|
|
14
|
+
turnTimeoutMs?: number;
|
|
15
|
+
backoffMs?: ChannelBackoffOptions;
|
|
16
|
+
/**
|
|
17
|
+
* Hook that composes per-turn system context (working memory, cross-room
|
|
18
|
+
* digest, etc.). Forwarded to the dispatcher; errors are logged and do not
|
|
19
|
+
* abort the turn.
|
|
20
|
+
*/
|
|
21
|
+
buildSystemContext?: SystemContextBuilder;
|
|
22
|
+
/**
|
|
23
|
+
* Observer called after the dispatcher acks each inbound message. Useful
|
|
24
|
+
* for activity tracking or metrics. Errors are logged and swallowed.
|
|
25
|
+
*/
|
|
26
|
+
onInbound?: InboundObserver;
|
|
27
|
+
/**
|
|
28
|
+
* Optional composer that wraps the user-turn text with channel-specific
|
|
29
|
+
* metadata (sender label, room header, NO_REPLY hint…) before it is handed
|
|
30
|
+
* to the runtime. Forwarded to the dispatcher; see {@link UserTurnBuilder}.
|
|
31
|
+
*/
|
|
32
|
+
composeUserTurn?: UserTurnBuilder;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
|
36
|
+
* `SessionStore` + runtime factory. Channel adapters are constructed from
|
|
37
|
+
* `opts.createChannel` per `config.channels[]` entry and keyed by adapter id.
|
|
38
|
+
*/
|
|
39
|
+
export declare class Gateway {
|
|
40
|
+
private readonly config;
|
|
41
|
+
private readonly log;
|
|
42
|
+
private readonly sessionStore;
|
|
43
|
+
private readonly dispatcher;
|
|
44
|
+
private readonly channelManager;
|
|
45
|
+
private readonly channelMap;
|
|
46
|
+
private readonly createChannelFn;
|
|
47
|
+
private readonly managedRoutes;
|
|
48
|
+
private started;
|
|
49
|
+
private stopped;
|
|
50
|
+
constructor(opts: GatewayBootOptions);
|
|
51
|
+
/** Load persisted sessions and start every configured channel. */
|
|
52
|
+
start(): Promise<void>;
|
|
53
|
+
/** Tear down every channel; idempotent. */
|
|
54
|
+
stop(reason?: string): Promise<void>;
|
|
55
|
+
/** Aggregate status snapshot combining channel and turn state. */
|
|
56
|
+
snapshot(): GatewayRuntimeSnapshot;
|
|
57
|
+
/**
|
|
58
|
+
* Read-only view of the synthesized per-agent routes. Exposed for
|
|
59
|
+
* snapshot/debug callers and tests; matching reads the live internal map.
|
|
60
|
+
*/
|
|
61
|
+
listManagedRoutes(): GatewayRoute[];
|
|
62
|
+
/** Replace all managed routes atomically. Used by `reload_config`. */
|
|
63
|
+
replaceManagedRoutes(routes: Map<string, GatewayRoute>): void;
|
|
64
|
+
/** Add or update one managed route. Used by provision hot-add. */
|
|
65
|
+
upsertManagedRoute(accountId: string, route: GatewayRoute): void;
|
|
66
|
+
/** Drop one managed route. Used by revoke / removeChannel. */
|
|
67
|
+
removeManagedRoute(accountId: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* Hot-plug a new channel without restarting the gateway. The daemon's
|
|
70
|
+
* control plane calls this after a `provision_agent` frame: it has already
|
|
71
|
+
* written the new agent's credentials to disk and updated `config.json`,
|
|
72
|
+
* and now needs the channel to come online without tearing down peers.
|
|
73
|
+
*
|
|
74
|
+
* The caller supplies a fully-constructed `GatewayChannelConfig` entry;
|
|
75
|
+
* `createChannel` (from the original `GatewayBootOptions`) is invoked to
|
|
76
|
+
* build the adapter. The config entry is appended to `config.channels`
|
|
77
|
+
* so status/router lookups see it; it is otherwise a pure in-memory op.
|
|
78
|
+
*/
|
|
79
|
+
addChannel(cfg: GatewayChannelConfig): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Remove a channel registered earlier (either at boot or via
|
|
82
|
+
* `addChannel`). Aborts the running turn loop, awaits the adapter's
|
|
83
|
+
* `stop()`, drops the entry from the channel map and config.channels.
|
|
84
|
+
* No-op on unknown id.
|
|
85
|
+
*/
|
|
86
|
+
removeChannel(id: string, reason?: string): Promise<void>;
|
|
87
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ChannelManager } from "./channel-manager.js";
|
|
2
|
+
import { Dispatcher } from "./dispatcher.js";
|
|
3
|
+
import { consoleLogger } from "./log.js";
|
|
4
|
+
import { createRuntime } from "./runtimes/registry.js";
|
|
5
|
+
import { DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS, SessionStore } from "./session-store.js";
|
|
6
|
+
/** Default runtime factory: delegates to the built-in registry; ignores extraArgs at construction. */
|
|
7
|
+
const defaultRuntimeFactory = (runtimeId) => createRuntime(runtimeId);
|
|
8
|
+
/**
|
|
9
|
+
* Top-level gateway bootstrap. Wires `ChannelManager` → `Dispatcher` →
|
|
10
|
+
* `SessionStore` + runtime factory. Channel adapters are constructed from
|
|
11
|
+
* `opts.createChannel` per `config.channels[]` entry and keyed by adapter id.
|
|
12
|
+
*/
|
|
13
|
+
export class Gateway {
|
|
14
|
+
config;
|
|
15
|
+
log;
|
|
16
|
+
sessionStore;
|
|
17
|
+
dispatcher;
|
|
18
|
+
channelManager;
|
|
19
|
+
channelMap;
|
|
20
|
+
createChannelFn;
|
|
21
|
+
managedRoutes = new Map();
|
|
22
|
+
started = false;
|
|
23
|
+
stopped = false;
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.config = opts.config;
|
|
26
|
+
this.log = opts.log ?? consoleLogger;
|
|
27
|
+
this.createChannelFn = opts.createChannel;
|
|
28
|
+
this.channelMap = new Map();
|
|
29
|
+
const channelList = [];
|
|
30
|
+
for (const cfg of opts.config.channels) {
|
|
31
|
+
const adapter = opts.createChannel(cfg);
|
|
32
|
+
this.channelMap.set(adapter.id, adapter);
|
|
33
|
+
channelList.push(adapter);
|
|
34
|
+
}
|
|
35
|
+
for (const route of opts.config.managedRoutes ?? []) {
|
|
36
|
+
const id = route.match?.accountId;
|
|
37
|
+
if (typeof id === "string") {
|
|
38
|
+
this.managedRoutes.set(id, route);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Defensive: buildManagedRoutes always sets match.accountId, so
|
|
42
|
+
// reaching here means a caller constructed GatewayConfig directly
|
|
43
|
+
// with a malformed entry. Log so it's not silently dropped.
|
|
44
|
+
this.log.warn("gateway: dropping seed managed route with no accountId", {
|
|
45
|
+
runtime: route.runtime,
|
|
46
|
+
cwd: route.cwd,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.sessionStore = new SessionStore({
|
|
51
|
+
path: opts.sessionStorePath,
|
|
52
|
+
log: this.log,
|
|
53
|
+
maxEntryAgeMs: opts.sessionStoreMaxEntryAgeMs ?? DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS,
|
|
54
|
+
});
|
|
55
|
+
const runtimeFactory = opts.createRuntime ?? defaultRuntimeFactory;
|
|
56
|
+
this.dispatcher = new Dispatcher({
|
|
57
|
+
config: this.config,
|
|
58
|
+
channels: this.channelMap,
|
|
59
|
+
runtime: runtimeFactory,
|
|
60
|
+
sessionStore: this.sessionStore,
|
|
61
|
+
log: this.log,
|
|
62
|
+
turnTimeoutMs: opts.turnTimeoutMs,
|
|
63
|
+
buildSystemContext: opts.buildSystemContext,
|
|
64
|
+
onInbound: opts.onInbound,
|
|
65
|
+
composeUserTurn: opts.composeUserTurn,
|
|
66
|
+
managedRoutes: this.managedRoutes,
|
|
67
|
+
});
|
|
68
|
+
this.channelManager = new ChannelManager({
|
|
69
|
+
config: this.config,
|
|
70
|
+
channels: channelList,
|
|
71
|
+
log: this.log,
|
|
72
|
+
emit: (env) => this.dispatcher.handle(env),
|
|
73
|
+
backoffMs: opts.backoffMs,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Load persisted sessions and start every configured channel. */
|
|
77
|
+
async start() {
|
|
78
|
+
if (this.started)
|
|
79
|
+
return;
|
|
80
|
+
this.started = true;
|
|
81
|
+
await this.sessionStore.load();
|
|
82
|
+
await this.channelManager.startAll();
|
|
83
|
+
}
|
|
84
|
+
/** Tear down every channel; idempotent. */
|
|
85
|
+
async stop(reason) {
|
|
86
|
+
if (this.stopped)
|
|
87
|
+
return;
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
await this.channelManager.stopAll(reason);
|
|
90
|
+
}
|
|
91
|
+
/** Aggregate status snapshot combining channel and turn state. */
|
|
92
|
+
snapshot() {
|
|
93
|
+
return {
|
|
94
|
+
channels: this.channelManager.status(),
|
|
95
|
+
turns: this.dispatcher.turns(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read-only view of the synthesized per-agent routes. Exposed for
|
|
100
|
+
* snapshot/debug callers and tests; matching reads the live internal map.
|
|
101
|
+
*/
|
|
102
|
+
listManagedRoutes() {
|
|
103
|
+
return Array.from(this.managedRoutes.values());
|
|
104
|
+
}
|
|
105
|
+
/** Replace all managed routes atomically. Used by `reload_config`. */
|
|
106
|
+
replaceManagedRoutes(routes) {
|
|
107
|
+
this.managedRoutes.clear();
|
|
108
|
+
for (const [id, route] of routes) {
|
|
109
|
+
this.managedRoutes.set(id, route);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Add or update one managed route. Used by provision hot-add. */
|
|
113
|
+
upsertManagedRoute(accountId, route) {
|
|
114
|
+
this.managedRoutes.set(accountId, route);
|
|
115
|
+
}
|
|
116
|
+
/** Drop one managed route. Used by revoke / removeChannel. */
|
|
117
|
+
removeManagedRoute(accountId) {
|
|
118
|
+
this.managedRoutes.delete(accountId);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Hot-plug a new channel without restarting the gateway. The daemon's
|
|
122
|
+
* control plane calls this after a `provision_agent` frame: it has already
|
|
123
|
+
* written the new agent's credentials to disk and updated `config.json`,
|
|
124
|
+
* and now needs the channel to come online without tearing down peers.
|
|
125
|
+
*
|
|
126
|
+
* The caller supplies a fully-constructed `GatewayChannelConfig` entry;
|
|
127
|
+
* `createChannel` (from the original `GatewayBootOptions`) is invoked to
|
|
128
|
+
* build the adapter. The config entry is appended to `config.channels`
|
|
129
|
+
* so status/router lookups see it; it is otherwise a pure in-memory op.
|
|
130
|
+
*/
|
|
131
|
+
async addChannel(cfg) {
|
|
132
|
+
if (this.stopped) {
|
|
133
|
+
throw new Error("gateway already stopped");
|
|
134
|
+
}
|
|
135
|
+
if (this.channelMap.has(cfg.id)) {
|
|
136
|
+
throw new Error(`channel "${cfg.id}" already registered`);
|
|
137
|
+
}
|
|
138
|
+
this.config.channels.push(cfg);
|
|
139
|
+
const adapter = this.createChannelFn(cfg);
|
|
140
|
+
this.channelMap.set(adapter.id, adapter);
|
|
141
|
+
this.channelManager.addOne(adapter);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Remove a channel registered earlier (either at boot or via
|
|
145
|
+
* `addChannel`). Aborts the running turn loop, awaits the adapter's
|
|
146
|
+
* `stop()`, drops the entry from the channel map and config.channels.
|
|
147
|
+
* No-op on unknown id.
|
|
148
|
+
*/
|
|
149
|
+
async removeChannel(id, reason) {
|
|
150
|
+
if (!this.channelMap.has(id))
|
|
151
|
+
return;
|
|
152
|
+
await this.channelManager.removeOne(id, reason);
|
|
153
|
+
this.channelMap.delete(id);
|
|
154
|
+
const idx = this.config.channels.findIndex((c) => c.id === id);
|
|
155
|
+
if (idx >= 0)
|
|
156
|
+
this.config.channels.splice(idx, 1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./log.js";
|
|
3
|
+
export * from "./runtimes/registry.js";
|
|
4
|
+
export * from "./channels/index.js";
|
|
5
|
+
export { sanitizeUntrustedContent, sanitizeSenderName } from "./channels/sanitize.js";
|
|
6
|
+
export { sessionKey, SessionStore, type SessionStoreOptions } from "./session-store.js";
|
|
7
|
+
export { resolveRoute, matchesRoute } from "./router.js";
|
|
8
|
+
export { ChannelManager, type ChannelManagerOptions, type ChannelBackoffOptions } from "./channel-manager.js";
|
|
9
|
+
export { Dispatcher, type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
|
|
10
|
+
export { Gateway, type GatewayBootOptions } from "./gateway.js";
|
|
11
|
+
export { ClaudeCodeAdapter, probeClaude, resolveClaudeCommand, } from "./runtimes/claude-code.js";
|
|
12
|
+
export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
|
|
13
|
+
export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
|
|
14
|
+
export { NdjsonStreamAdapter, type NdjsonEventCtx, type NdjsonRunState, } from "./runtimes/ndjson-stream.js";
|
|
15
|
+
export { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, type ProbeDeps, } from "./runtimes/probe.js";
|