@chenacc/agentbridge 0.0.0-stage → 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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/bin/agentbridge.js +7 -0
- package/docs/RELEASING.md +47 -0
- package/package.json +65 -4
- package/public/adapter-capabilities.js +29 -0
- package/public/app.js +2211 -0
- package/public/background.jpg +0 -0
- package/public/command-id.js +16 -0
- package/public/highlight.js +125 -0
- package/public/index.html +339 -0
- package/public/markdown.js +291 -0
- package/public/styles.css +1073 -0
- package/public/transcript.js +368 -0
- package/public/workspace-navigation.js +241 -0
- package/src/adapter-registry.js +23 -0
- package/src/adapters/capabilities.js +36 -0
- package/src/adapters/claude.js +493 -0
- package/src/adapters/codex.js +529 -0
- package/src/adapters/deepseek-harness.js +413 -0
- package/src/adapters/dsh-desktop.js +322 -0
- package/src/adapters/echo.js +57 -0
- package/src/adapters/process-utils.js +125 -0
- package/src/adapters/unavailable.js +30 -0
- package/src/adapters/zcode-native.js +40 -0
- package/src/attachments.js +187 -0
- package/src/cc-switch-models.js +86 -0
- package/src/cli.js +199 -0
- package/src/config.js +195 -0
- package/src/conversations/conversation-catalog.js +283 -0
- package/src/dsh-desktop-channels.js +460 -0
- package/src/event-store.js +330 -0
- package/src/notifier.js +266 -0
- package/src/pairing.js +167 -0
- package/src/server.js +964 -0
- package/src/session-manager.js +1355 -0
- package/src/workspaces/workspace-catalog.js +196 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Applied sequence numbers are remembered so replaying a batch is a no-op.
|
|
4
|
+
const MAX_APPLIED_SEQ = 20_000;
|
|
5
|
+
|
|
6
|
+
// A paired companion polls this bridge every 800 ms, so "nothing heard for this
|
|
7
|
+
// long" means the desktop side is gone: the app was closed or restarted, or the
|
|
8
|
+
// conversation stopped running /phone. The channel object survives in this
|
|
9
|
+
// process either way, and a command queued into it is never read by anyone.
|
|
10
|
+
// Before this bound existed, a restart of the desktop app left every command
|
|
11
|
+
// silently swallowed while `isOnline` kept answering "connected" — the phone
|
|
12
|
+
// showed 执行中 forever and its stop button could not clear it.
|
|
13
|
+
const CHANNEL_STALE_MS = 45_000;
|
|
14
|
+
|
|
15
|
+
function httpError(message, statusCode) {
|
|
16
|
+
return Object.assign(new Error(message), { statusCode });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tokenDigest(token) {
|
|
20
|
+
return createHash("sha256").update(String(token)).digest();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeAfter(value) {
|
|
24
|
+
const parsed = Number.parseInt(String(value ?? "0"), 10);
|
|
25
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSelection(value) {
|
|
29
|
+
if (!value || typeof value !== "object") return null;
|
|
30
|
+
const provider = String(value.provider ?? "").slice(0, 100);
|
|
31
|
+
const model = String(value.model ?? "").slice(0, 160);
|
|
32
|
+
if (!provider || !model) return null;
|
|
33
|
+
const reasoningEffort = typeof value.reasoningEffort === "string" ? value.reasoningEffort.slice(0, 100) : undefined;
|
|
34
|
+
return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeModelDirectory(value) {
|
|
38
|
+
if (!value || typeof value !== "object") return null;
|
|
39
|
+
const current = normalizeSelection(value.current);
|
|
40
|
+
const groups = Array.isArray(value.groups) ? value.groups.slice(0, 30).flatMap((group) => {
|
|
41
|
+
if (!group || typeof group !== "object") return [];
|
|
42
|
+
const id = String(group.id ?? "").slice(0, 100);
|
|
43
|
+
if (!id) return [];
|
|
44
|
+
const models = Array.isArray(group.models) ? group.models.slice(0, 200).flatMap((entry) => {
|
|
45
|
+
if (!entry || typeof entry !== "object") return [];
|
|
46
|
+
const model = String(entry.id ?? "").slice(0, 160);
|
|
47
|
+
if (!model) return [];
|
|
48
|
+
const reasoning = entry.reasoning && typeof entry.reasoning === "object"
|
|
49
|
+
? { efforts: Array.isArray(entry.reasoning.efforts) ? entry.reasoning.efforts.slice(0, 20).flatMap((effort) => {
|
|
50
|
+
const id = String(effort?.id ?? "").slice(0, 100);
|
|
51
|
+
return id ? [{ id, name: String(effort?.name ?? id).slice(0, 160) }] : [];
|
|
52
|
+
}) : [] }
|
|
53
|
+
: undefined;
|
|
54
|
+
return [{ id: model, name: String(entry.name ?? model).slice(0, 160), ...(reasoning ? { reasoning } : {}) }];
|
|
55
|
+
}) : [];
|
|
56
|
+
return [{ id, name: String(group.name ?? id).slice(0, 160), models }];
|
|
57
|
+
}) : [];
|
|
58
|
+
const failures = Array.isArray(value.failures) ? value.failures.slice(0, 30).flatMap((failure) => {
|
|
59
|
+
const id = String(failure?.id ?? "").slice(0, 100);
|
|
60
|
+
return id ? [{ id, name: String(failure?.name ?? id).slice(0, 160), message: String(failure?.message ?? "Unknown error").slice(0, 500) }] : [];
|
|
61
|
+
}) : [];
|
|
62
|
+
if (!current && groups.length === 0 && failures.length === 0) return null;
|
|
63
|
+
return { current, routable: value.routable === true, groups, failures };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class DshDesktopChannelManager {
|
|
67
|
+
#byProvider = new Map();
|
|
68
|
+
#byChannel = new Map();
|
|
69
|
+
// Pending "create a conversation in the desktop app" requests, keyed by the
|
|
70
|
+
// bridge session id the phone already opened. The desktop companion answers by
|
|
71
|
+
// registering that new conversation, which is what resolves the entry.
|
|
72
|
+
#creates = new Map();
|
|
73
|
+
#createRequests = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Is any DSH Desktop conversation currently paired and actually polling?
|
|
77
|
+
*
|
|
78
|
+
* Creating a conversation from the phone has to happen inside the desktop app,
|
|
79
|
+
* so the capability is only real while a companion is polling.
|
|
80
|
+
*/
|
|
81
|
+
hasOnlineChannel() {
|
|
82
|
+
for (const state of this.#byProvider.values()) {
|
|
83
|
+
if (this.#live(state)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Is *this* conversation's companion connected right now?
|
|
90
|
+
*
|
|
91
|
+
* A DSH Desktop conversation only exists inside the desktop app, so without its
|
|
92
|
+
* channel there is nothing to drive. The phone needs to know that before it offers
|
|
93
|
+
* an input box, rather than after the user has typed into it.
|
|
94
|
+
*/
|
|
95
|
+
isOnline(providerSessionId) {
|
|
96
|
+
if (typeof providerSessionId !== "string" || !providerSessionId) return false;
|
|
97
|
+
return this.#live(this.#byProvider.get(providerSessionId));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A channel is only usable while its companion keeps polling.
|
|
102
|
+
*
|
|
103
|
+
* `lastSeenAt` is refreshed by every poll (and by every other authenticated
|
|
104
|
+
* call), so this is the real heartbeat of the desktop side — not `closed`,
|
|
105
|
+
* which only flips when the bridge itself tears the channel down.
|
|
106
|
+
*/
|
|
107
|
+
#live(state) {
|
|
108
|
+
if (!state || state.closed || !state.channelId) return false;
|
|
109
|
+
return Date.now() - (state.lastSeenAt ?? 0) < CHANNEL_STALE_MS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The one sentence that fixes a dead channel, shared by every refusal below. */
|
|
113
|
+
static staleReason() {
|
|
114
|
+
return "桌面端这个会话没有在响应(DSH Desktop 关过或重启过,或者那个对话没有运行 /phone):请在那台电脑的 DSH Desktop 里打开该会话执行 /phone,手机会自己接回来";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Ask a paired desktop conversation to open a new conversation in `cwd`.
|
|
119
|
+
*
|
|
120
|
+
* Resolves with the new provider session id once the companion registers it,
|
|
121
|
+
* so the caller can bind the bridge session it already created. The request
|
|
122
|
+
* rides an existing channel: the companion owns its own DSH context and can
|
|
123
|
+
* create conversations independently of which one is paired.
|
|
124
|
+
*/
|
|
125
|
+
requestSession({ cwd, title, bridgeSessionId, timeoutMs = 45_000 }) {
|
|
126
|
+
if (typeof bridgeSessionId !== "string" || !bridgeSessionId) {
|
|
127
|
+
throw httpError("A bridge session id is required to create a DSH Desktop conversation", 400);
|
|
128
|
+
}
|
|
129
|
+
let carrier = null;
|
|
130
|
+
for (const state of this.#byProvider.values()) {
|
|
131
|
+
if (!this.#live(state)) continue;
|
|
132
|
+
if (!carrier || state.lastSeenAt > carrier.lastSeenAt) carrier = state;
|
|
133
|
+
}
|
|
134
|
+
if (!carrier) {
|
|
135
|
+
throw httpError("DSH Desktop 桌面端离线:请先在 DSH Desktop 的对话里运行 /phone,再回来新建", 409);
|
|
136
|
+
}
|
|
137
|
+
const requestId = randomUUID();
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
this.#creates.delete(bridgeSessionId);
|
|
141
|
+
this.#createRequests.delete(requestId);
|
|
142
|
+
reject(httpError("DSH Desktop 没能在超时前创建会话", 504));
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
this.#creates.set(bridgeSessionId, { resolve, reject, timer, requestId, cwd });
|
|
145
|
+
this.#createRequests.set(requestId, bridgeSessionId);
|
|
146
|
+
this.enqueue(carrier.providerSessionId, {
|
|
147
|
+
kind: "create-session",
|
|
148
|
+
requestId,
|
|
149
|
+
bridgeSessionId,
|
|
150
|
+
cwd,
|
|
151
|
+
...(title ? { title } : {}),
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The companion registered the conversation it just created. */
|
|
157
|
+
settleCreate(bridgeSessionId, value) {
|
|
158
|
+
const pending = this.#creates.get(bridgeSessionId);
|
|
159
|
+
if (!pending) return false;
|
|
160
|
+
this.#creates.delete(bridgeSessionId);
|
|
161
|
+
this.#createRequests.delete(pending.requestId);
|
|
162
|
+
clearTimeout(pending.timer);
|
|
163
|
+
pending.resolve(value);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The companion gave up: surface its reason instead of waiting for the timeout. */
|
|
168
|
+
failCreate(bridgeSessionId, message) {
|
|
169
|
+
const pending = this.#creates.get(bridgeSessionId);
|
|
170
|
+
if (!pending) return false;
|
|
171
|
+
this.#creates.delete(bridgeSessionId);
|
|
172
|
+
this.#createRequests.delete(pending.requestId);
|
|
173
|
+
clearTimeout(pending.timer);
|
|
174
|
+
pending.reject(httpError(String(message ?? "DSH Desktop 创建会话失败").slice(0, 500), 409));
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A create result arrives on the carrier channel, not on the session it creates. */
|
|
179
|
+
#completeCreate(requestId, result) {
|
|
180
|
+
const bridgeSessionId = this.#createRequests.get(requestId);
|
|
181
|
+
if (!bridgeSessionId) return false;
|
|
182
|
+
if (result?.ok) {
|
|
183
|
+
// Success is completed by the registration of the new conversation, which
|
|
184
|
+
// carries the provider session id the bridge has to bind.
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
this.#createRequests.delete(requestId);
|
|
188
|
+
this.failCreate(bridgeSessionId, result?.error);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
register({ providerSessionId, cwd, title, provider, model, modelDirectory, integrationVersion = 0 }) {
|
|
193
|
+
let state = this.#byProvider.get(providerSessionId);
|
|
194
|
+
if (!state) {
|
|
195
|
+
state = {
|
|
196
|
+
providerSessionId,
|
|
197
|
+
commands: [],
|
|
198
|
+
nextCommandSeq: 0,
|
|
199
|
+
lastDshEventSeq: 0,
|
|
200
|
+
appliedSeq: new Set(),
|
|
201
|
+
ingestChain: Promise.resolve(),
|
|
202
|
+
bridge: null,
|
|
203
|
+
};
|
|
204
|
+
this.#byProvider.set(providerSessionId, state);
|
|
205
|
+
} else if (state.channelId) {
|
|
206
|
+
this.#byChannel.delete(state.channelId);
|
|
207
|
+
for (const pending of state.requests?.values() ?? []) {
|
|
208
|
+
clearTimeout(pending.timer);
|
|
209
|
+
pending.reject(httpError("DSH Desktop /phone connection was replaced", 409));
|
|
210
|
+
}
|
|
211
|
+
state.requests?.clear();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = randomBytes(32).toString("base64url");
|
|
215
|
+
state.channelId = randomUUID();
|
|
216
|
+
state.tokenDigest = tokenDigest(token);
|
|
217
|
+
state.cwd = cwd;
|
|
218
|
+
state.title = title || null;
|
|
219
|
+
state.provider = provider || null;
|
|
220
|
+
state.model = model || null;
|
|
221
|
+
state.modelDirectory = normalizeModelDirectory(modelDirectory);
|
|
222
|
+
state.integrationVersion = Number.isSafeInteger(integrationVersion) ? integrationVersion : 0;
|
|
223
|
+
state.commands = [];
|
|
224
|
+
state.nextCommandSeq = 0;
|
|
225
|
+
state.registeredAt = Date.now();
|
|
226
|
+
state.lastSeenAt = Date.now();
|
|
227
|
+
state.closed = false;
|
|
228
|
+
if (!state.appliedSeq) state.appliedSeq = new Set();
|
|
229
|
+
if (!state.ingestChain) state.ingestChain = Promise.resolve();
|
|
230
|
+
this.#byChannel.set(state.channelId, state);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: state.channelId,
|
|
234
|
+
token,
|
|
235
|
+
providerSessionId,
|
|
236
|
+
registeredAt: state.registeredAt,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
revoke(channelId) {
|
|
241
|
+
const state = this.#byChannel.get(channelId);
|
|
242
|
+
if (!state) return false;
|
|
243
|
+
this.#byChannel.delete(channelId);
|
|
244
|
+
if (state.channelId === channelId) {
|
|
245
|
+
state.closed = true;
|
|
246
|
+
state.channelId = null;
|
|
247
|
+
for (const pending of state.requests?.values() ?? []) {
|
|
248
|
+
clearTimeout(pending.timer);
|
|
249
|
+
pending.reject(httpError("DSH Desktop /phone connection was closed", 409));
|
|
250
|
+
}
|
|
251
|
+
state.requests?.clear();
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
attachBridge(providerSessionId, bridge) {
|
|
257
|
+
const state = this.#byProvider.get(providerSessionId);
|
|
258
|
+
// This is what the phone used to read as "DSH Desktop companion is not registered",
|
|
259
|
+
// which said nothing about what to do. A DSH Desktop conversation is driven through
|
|
260
|
+
// the desktop app, so the one action that fixes it is running /phone there — and the
|
|
261
|
+
// companion reconnects by itself once it is running the current integration.
|
|
262
|
+
if (!this.#live(state)) {
|
|
263
|
+
throw httpError(DshDesktopChannelManager.staleReason(), 409);
|
|
264
|
+
}
|
|
265
|
+
state.bridge = bridge;
|
|
266
|
+
return state;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* What the paired companions reported about themselves.
|
|
270
|
+
*
|
|
271
|
+
* `stale` means a desktop is running an older copy of the integration than this
|
|
272
|
+
* bridge expects — the fix is `npm run install:dsh-phone` plus a DSH restart, which
|
|
273
|
+
* is worth telling the user before they debug a missing feature. Only live
|
|
274
|
+
* channels count: a companion that stopped polling cannot claim anything.
|
|
275
|
+
*/
|
|
276
|
+
integrationStatus({ required = 6 } = {}) {
|
|
277
|
+
const versions = [];
|
|
278
|
+
for (const state of this.#byProvider.values()) {
|
|
279
|
+
if (this.#live(state) && Number.isSafeInteger(state.integrationVersion)) {
|
|
280
|
+
versions.push(state.integrationVersion);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!versions.length) return { paired: false, version: null, required, stale: false };
|
|
284
|
+
const version = Math.min(...versions);
|
|
285
|
+
return { paired: true, version, required, stale: version < required };
|
|
286
|
+
}
|
|
287
|
+
metadata(providerSessionId) {
|
|
288
|
+
const state = this.#byProvider.get(providerSessionId);
|
|
289
|
+
if (!state) return null;
|
|
290
|
+
return {
|
|
291
|
+
cwd: state.cwd,
|
|
292
|
+
title: state.title,
|
|
293
|
+
provider: state.provider,
|
|
294
|
+
model: state.model,
|
|
295
|
+
modelDirectory: state.modelDirectory,
|
|
296
|
+
lastSeenAt: state.lastSeenAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
enqueue(providerSessionId, command) {
|
|
301
|
+
const state = this.#byProvider.get(providerSessionId);
|
|
302
|
+
// A command accepted for a companion that stopped polling is lost without a
|
|
303
|
+
// trace: it sits in `state.commands` until a poll that never comes. Refusing it
|
|
304
|
+
// here is what turns "the phone is stuck in 执行中" into a sentence the person
|
|
305
|
+
// can act on.
|
|
306
|
+
if (!this.#live(state)) {
|
|
307
|
+
throw httpError(DshDesktopChannelManager.staleReason(), 409);
|
|
308
|
+
}
|
|
309
|
+
const queued = {
|
|
310
|
+
seq: ++state.nextCommandSeq,
|
|
311
|
+
...command,
|
|
312
|
+
};
|
|
313
|
+
state.commands.push(queued);
|
|
314
|
+
if (state.commands.length > 500) state.commands.shift();
|
|
315
|
+
return queued;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
request(providerSessionId, command, timeoutMs = 15_000) {
|
|
319
|
+
const state = this.#byProvider.get(providerSessionId);
|
|
320
|
+
if (!this.#live(state)) {
|
|
321
|
+
throw httpError(DshDesktopChannelManager.staleReason(), 409);
|
|
322
|
+
}
|
|
323
|
+
const requestId = randomUUID();
|
|
324
|
+
const queued = this.enqueue(providerSessionId, { ...command, requestId });
|
|
325
|
+
if (!state.requests) state.requests = new Map();
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
state.requests?.delete(requestId);
|
|
329
|
+
const what = command.kind === "select-model" ? "the model change" : "your answer";
|
|
330
|
+
reject(httpError(`DSH Desktop did not confirm ${what} in time`, 504));
|
|
331
|
+
}, timeoutMs);
|
|
332
|
+
state.requests.set(requestId, { resolve, reject, timer, command: queued });
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
complete(channelId, token, requestId, result) {
|
|
337
|
+
const state = this.#authenticate(channelId, token);
|
|
338
|
+
state.lastSeenAt = Date.now();
|
|
339
|
+
if (!state.requests?.has(requestId) && this.#completeCreate(requestId, result)) {
|
|
340
|
+
// A create request is answered on the carrier channel while the session it
|
|
341
|
+
// creates belongs to another one; only a failure is meaningful here.
|
|
342
|
+
return { accepted: true };
|
|
343
|
+
}
|
|
344
|
+
const pending = state.requests?.get(requestId);
|
|
345
|
+
if (!pending) throw httpError("Unknown or expired DSH Desktop request", 409);
|
|
346
|
+
clearTimeout(pending.timer);
|
|
347
|
+
state.requests.delete(requestId);
|
|
348
|
+
if (!result?.ok) {
|
|
349
|
+
pending.reject(httpError(String(result?.error ?? "DSH Desktop rejected the request").slice(0, 500), 409));
|
|
350
|
+
return { accepted: true };
|
|
351
|
+
}
|
|
352
|
+
if (pending.command.kind !== "select-model") {
|
|
353
|
+
// Answers to a question or an approval are confirmed only in the sense
|
|
354
|
+
// that the desktop accepted them; there is no value to fold back in.
|
|
355
|
+
pending.resolve({ accepted: true });
|
|
356
|
+
return { accepted: true };
|
|
357
|
+
}
|
|
358
|
+
const selected = normalizeSelection(result.selected);
|
|
359
|
+
if (!selected) {
|
|
360
|
+
pending.reject(httpError("Invalid DSH Desktop model response", 502));
|
|
361
|
+
return { accepted: true };
|
|
362
|
+
}
|
|
363
|
+
if (state.modelDirectory) state.modelDirectory.current = selected;
|
|
364
|
+
state.provider = selected.provider;
|
|
365
|
+
state.model = selected.model;
|
|
366
|
+
pending.resolve({ selected });
|
|
367
|
+
return { accepted: true };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
poll(channelId, token, after) {
|
|
371
|
+
const state = this.#authenticate(channelId, token);
|
|
372
|
+
const cursor = normalizeAfter(after);
|
|
373
|
+
state.lastSeenAt = Date.now();
|
|
374
|
+
state.commands = state.commands.filter((command) => command.seq > cursor);
|
|
375
|
+
return {
|
|
376
|
+
commands: state.commands.slice(0, 100),
|
|
377
|
+
lastSeenAt: state.lastSeenAt,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async ingest(channelId, token, records) {
|
|
382
|
+
const state = this.#authenticate(channelId, token);
|
|
383
|
+
if (!Array.isArray(records) || records.length > 200) {
|
|
384
|
+
throw httpError("events must be an array containing at most 200 items", 400);
|
|
385
|
+
}
|
|
386
|
+
if (!state.bridge?.onEvent) throw httpError("Bridge session is not ready", 409);
|
|
387
|
+
state.lastSeenAt = Date.now();
|
|
388
|
+
// Batches are applied strictly one at a time. Concurrent flushes from the
|
|
389
|
+
// desktop companion used to interleave, which reordered a turn's streamed
|
|
390
|
+
// text and let each batch's cursor mark another batch's events as seen.
|
|
391
|
+
const run = state.ingestChain.catch(() => undefined).then(() => this.#applyRecords(state, records));
|
|
392
|
+
state.ingestChain = run;
|
|
393
|
+
return run;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Deliver human-interaction frames (questions and approvals) from the desktop
|
|
398
|
+
* companion.
|
|
399
|
+
*
|
|
400
|
+
* These are not session events: DSH mints a fresh rpc id for each pending
|
|
401
|
+
* interaction, and that id is not part of the durable log, so they travel on
|
|
402
|
+
* their own channel instead of sharing the seq-deduplicated event stream.
|
|
403
|
+
*/
|
|
404
|
+
async ingestInteractions(channelId, token, interactions) {
|
|
405
|
+
const state = this.#authenticate(channelId, token);
|
|
406
|
+
if (!Array.isArray(interactions) || interactions.length > 50) {
|
|
407
|
+
throw httpError("interactions must be an array containing at most 50 items", 400);
|
|
408
|
+
}
|
|
409
|
+
if (!state.bridge?.onInteraction) throw httpError("Bridge session is not ready", 409);
|
|
410
|
+
state.lastSeenAt = Date.now();
|
|
411
|
+
let handled = 0;
|
|
412
|
+
for (const interaction of interactions) {
|
|
413
|
+
if (!interaction || typeof interaction.kind !== "string" || typeof interaction.requestId !== "string") continue;
|
|
414
|
+
if (!interaction.requestId) continue;
|
|
415
|
+
await state.bridge.onInteraction(interaction);
|
|
416
|
+
handled += 1;
|
|
417
|
+
}
|
|
418
|
+
return { handled };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async #applyRecords(state, records) {
|
|
422
|
+
const pending = [];
|
|
423
|
+
for (const record of records) {
|
|
424
|
+
const event = record?.event;
|
|
425
|
+
if (!event || !Number.isSafeInteger(event.seq)) continue;
|
|
426
|
+
if (typeof event.type !== "string" || typeof event.time !== "number" || !event.data) {
|
|
427
|
+
throw httpError("Invalid DSH session event", 400);
|
|
428
|
+
}
|
|
429
|
+
// Dedupe by the exact sequence numbers already applied rather than by a
|
|
430
|
+
// high-water mark, so a batch that arrives late or is retried after a
|
|
431
|
+
// newer one is still accepted instead of being silently discarded.
|
|
432
|
+
if (state.appliedSeq.has(event.seq)) continue;
|
|
433
|
+
pending.push({ event, origin: record.origin === "phone" ? "phone" : "desktop" });
|
|
434
|
+
}
|
|
435
|
+
pending.sort((a, b) => a.event.seq - b.event.seq);
|
|
436
|
+
|
|
437
|
+
let accepted = 0;
|
|
438
|
+
for (const record of pending) {
|
|
439
|
+
await state.bridge.onEvent(record, state);
|
|
440
|
+
state.appliedSeq.add(record.event.seq);
|
|
441
|
+
state.lastDshEventSeq = Math.max(state.lastDshEventSeq, record.event.seq);
|
|
442
|
+
accepted += 1;
|
|
443
|
+
}
|
|
444
|
+
if (state.appliedSeq.size > MAX_APPLIED_SEQ) {
|
|
445
|
+
const cutoff = state.lastDshEventSeq - Math.floor(MAX_APPLIED_SEQ / 2);
|
|
446
|
+
for (const seq of state.appliedSeq) if (seq < cutoff) state.appliedSeq.delete(seq);
|
|
447
|
+
}
|
|
448
|
+
return { accepted, lastEventSeq: state.lastDshEventSeq };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#authenticate(channelId, token) {
|
|
452
|
+
const state = this.#byChannel.get(channelId);
|
|
453
|
+
if (!state || state.closed || typeof token !== "string") throw httpError("Invalid DSH Desktop channel", 401);
|
|
454
|
+
const supplied = tokenDigest(token);
|
|
455
|
+
if (supplied.length !== state.tokenDigest.length || !timingSafeEqual(supplied, state.tokenDigest)) {
|
|
456
|
+
throw httpError("Invalid DSH Desktop channel", 401);
|
|
457
|
+
}
|
|
458
|
+
return state;
|
|
459
|
+
}
|
|
460
|
+
}
|