@chenacc/agentbridge 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,1355 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { adapterSupports } from "./adapters/capabilities.js";
3
+
4
+ function publicSession(session) {
5
+ return {
6
+ id: session.id,
7
+ workspaceId: session.workspaceId ?? null,
8
+ adapterId: session.adapterId,
9
+ projectAlias: session.projectAlias,
10
+ title: session.title,
11
+ status: session.status,
12
+ providerSessionId: session.providerSessionId ?? session.handle?.providerSessionId ?? null,
13
+ attached: Boolean(session.attached),
14
+ activeTurnId: session.activeTurnId ?? null,
15
+ lastError: session.lastError ?? null,
16
+ controller: session.controller ? {
17
+ deviceId: session.controller.deviceId,
18
+ deviceName: session.controller.deviceName,
19
+ expiresAt: session.controller.expiresAt,
20
+ } : null,
21
+ modelDirectory: session.modelDirectory ?? null,
22
+ desktopConnected: desktopConnectedView(session),
23
+ autoApprove: autoApproveView(session),
24
+ usage: session.usage ?? { turns: 0, toolCalls: 0 },
25
+ queued: session.queued ?? [],
26
+ lastActiveAt: session.lastActiveAt ?? session.updatedAt,
27
+ createdAt: session.createdAt,
28
+ updatedAt: session.updatedAt,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Can the phone actually drive this session right now?
34
+ *
35
+ * A DSH Desktop conversation lives inside the desktop app, so it is only drivable
36
+ * while that conversation's companion channel is connected. `null` means the
37
+ * question does not apply (every other adapter owns its own process).
38
+ */
39
+ function desktopConnectedView(session) {
40
+ if (session?.adapterId !== "deepseek-harness-desktop") return null;
41
+ const channels = session.adapter?.channels;
42
+ if (typeof channels?.isOnline !== "function") return null;
43
+ const providerSessionId = session.providerSessionId ?? session.handle?.providerSessionId ?? null;
44
+ return channels.isOnline(providerSessionId) === true;
45
+ }
46
+
47
+ /** Expired policies must not leak into the UI as if they were still armed. */
48
+ function autoApproveView(session) {
49
+ const policy = session?.autoApprove;
50
+ if (!policy || policy.until <= Date.now()) return null;
51
+ return { until: policy.until, tools: policy.tools, deviceName: policy.deviceName ?? null };
52
+ }
53
+
54
+ function badRequest(message) {
55
+ return Object.assign(new Error(message), { statusCode: 400, code: "INPUT_ANSWER_INVALID" });
56
+ }
57
+
58
+ // A model directory is written into the conversation catalog on every ready
59
+ // event, so persistence keeps just enough to render the phone's model control
60
+ // after a restart: the current selection, provider and model names, and the
61
+ // reasoning efforts that selection can use. The live directory stays in memory.
62
+ // Only these five events are worth waking someone up for. Everything else is
63
+ // transcript traffic that the phone can pick up whenever it next opens.
64
+ // A time-boxed blanket approval is powerful enough to deserve hard limits: it can
65
+ // never outlive this window, whatever the client asks for.
66
+ const MAX_AUTO_APPROVE_MS = 30 * 60_000;
67
+
68
+ // A queue is a convenience, not a mailbox: five messages is already a lot to hold.
69
+ const MAX_QUEUED_MESSAGES = 5;
70
+
71
+ // How long a dispatched turn may produce nothing at all before the bridge decides
72
+ // the Agent never picked it up.
73
+ //
74
+ // `send` marks the session running *before* the adapter is asked to do anything,
75
+ // and only an event from the Agent clears that mark. When nothing ever comes back
76
+ // — a desktop app that was restarted, a conversation that no longer exists, a
77
+ // provider process that died before its first frame — the mark used to stay
78
+ // forever: the phone showed 执行中 with a dead composer, the stop button asked a
79
+ // ghost to cancel, and even the safe shutdown was refused because the session
80
+ // still looked busy. Every adapter here emits `turn.started` (or at least its first
81
+ // delta/tool event) within seconds, so a minute of complete silence is not a slow
82
+ // model, it is a turn that never started.
83
+ const DEFAULT_TURN_ACK_MS = 60_000;
84
+
85
+ // How long a stop request may go unanswered before the bridge ends the turn itself.
86
+ // The desktop companion polls every 800 ms, so a healthy agent echoes `turn/end`
87
+ // almost immediately; this bound exists so a phone can always get its input back.
88
+ const DEFAULT_STOP_ACK_MS = 20_000;
89
+
90
+ // Any of these means the Agent is alive inside the turn the bridge is watching, so
91
+ // the turn-acknowledgement watchdog is satisfied.
92
+ const PROGRESS_KINDS = new Set([
93
+ "turn.started",
94
+ "message.delta",
95
+ "message.completed",
96
+ "adapter.output",
97
+ "tool.started",
98
+ "tool.completed",
99
+ "approval.requested",
100
+ "input.requested",
101
+ "turn.completed",
102
+ "turn.cancelled",
103
+ "turn.failed",
104
+ ]);
105
+
106
+ // Turn ends are the only events that release `running`, so they also release every
107
+ // watchdog watching for them.
108
+ const TURN_END_KINDS = new Set(["turn.completed", "turn.cancelled", "turn.failed"]);
109
+
110
+ const NOTIFY_KINDS = new Set([
111
+ "approval.requested",
112
+ "input.requested",
113
+ "turn.completed",
114
+ "turn.failed",
115
+ "session.failed",
116
+ ]);
117
+ const STORED_MODEL_GROUPS = 20;
118
+ const STORED_MODELS_PER_GROUP = 40;
119
+ const STORED_EFFORTS_PER_MODEL = 8;
120
+
121
+ function compactModelDirectoryForStorage(directory) {
122
+ if (!directory || typeof directory !== "object") return null;
123
+ const groups = Array.isArray(directory.groups) ? directory.groups : [];
124
+ const models = (group) => (Array.isArray(group?.models) ? group.models : []).slice(0, STORED_MODELS_PER_GROUP).map((model) => ({
125
+ id: String(model?.id ?? "").slice(0, 160),
126
+ name: String(model?.name ?? model?.id ?? "").slice(0, 160),
127
+ ...(Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
128
+ ? { reasoning: { efforts: model.reasoning.efforts.slice(0, STORED_EFFORTS_PER_MODEL).map((effort) => ({
129
+ id: String(effort?.id ?? "").slice(0, 100),
130
+ name: String(effort?.name ?? effort?.id ?? "").slice(0, 160),
131
+ })) } }
132
+ : {}),
133
+ })).filter((model) => model.id);
134
+ const compact = {
135
+ current: directory.current ?? null,
136
+ routable: directory.routable === true,
137
+ groups: groups.slice(0, STORED_MODEL_GROUPS).map((group) => ({
138
+ id: String(group?.id ?? "").slice(0, 100),
139
+ name: String(group?.name ?? group?.id ?? "").slice(0, 160),
140
+ models: models(group),
141
+ })).filter((group) => group.id),
142
+ failures: (Array.isArray(directory.failures) ? directory.failures : []).slice(0, 5).map((failure) => ({
143
+ id: String(failure?.id ?? "").slice(0, 100),
144
+ name: String(failure?.name ?? failure?.id ?? "").slice(0, 160),
145
+ message: String(failure?.message ?? "").slice(0, 300),
146
+ })),
147
+ };
148
+ return compact.groups.length || compact.current || compact.failures.length ? compact : null;
149
+ }
150
+
151
+ // A phone that switches conversations re-reads this stream, so payload size is
152
+ // a direct latency cost. Measured on a real log: 3.09 MB of replay, of which 35%
153
+ // was `session.ready` repeating the whole model directory and 43% was raw tool
154
+ // arguments and tool output. Conversation text is never truncated.
155
+ const TOOL_COMMAND_LIMIT = 2_000;
156
+ const TOOL_TEXT_LIMIT = 4_000;
157
+ const STREAM_TEXT_LIMIT = 2_000;
158
+
159
+ function clampText(value, limit) {
160
+ if (typeof value !== "string" || value.length <= limit) return value;
161
+ return `${value.slice(0, limit)}… [已截断 ${value.length - limit} 字符]`;
162
+ }
163
+
164
+ /**
165
+ * Shrink the payloads the phone only ever glances at.
166
+ *
167
+ * `session.ready` and `session.created` used to embed the entire public session
168
+ * view, including the provider's model catalog (~43 KB each), on every attach;
169
+ * `/api/state` is the one place that view belongs. Tool arguments and tool
170
+ * output are shown as a single line on the phone, so a multi-kilobyte bash dump
171
+ * bought nothing but bandwidth.
172
+ */
173
+ export function compactEventPayload(kind, payload) {
174
+ if (!payload || typeof payload !== "object") return payload;
175
+ if (kind === "tool.started") {
176
+ return { ...payload, command: clampText(payload.command, TOOL_COMMAND_LIMIT) };
177
+ }
178
+ if (kind === "tool.completed") {
179
+ return {
180
+ ...payload,
181
+ command: clampText(payload.command, TOOL_COMMAND_LIMIT),
182
+ text: clampText(payload.text, TOOL_TEXT_LIMIT),
183
+ };
184
+ }
185
+ if (kind === "adapter.output") {
186
+ return { ...payload, text: clampText(payload.text, STREAM_TEXT_LIMIT) };
187
+ }
188
+ if (kind === "session.created" || kind === "session.ready") {
189
+ const { session, ...rest } = payload;
190
+ if (!session) return rest;
191
+ return {
192
+ ...rest,
193
+ sessionId: session.id,
194
+ adapterId: session.adapterId,
195
+ title: session.title,
196
+ status: session.status,
197
+ };
198
+ }
199
+ return payload;
200
+ }
201
+
202
+ /**
203
+ * Turn a phone answer batch into the exact shape the agent is waiting for.
204
+ *
205
+ * The provider matches an answer batch against its own pending request by
206
+ * position and id, so the batch is rebuilt from the stored questions instead of
207
+ * trusting the client's order or completeness. A question the phone skipped
208
+ * still appears with an empty selection, which every provider accepts as "no
209
+ * answer" while keeping the rest of the batch aligned.
210
+ */
211
+ export function buildAnswerBatch(questions, answers) {
212
+ if (!Array.isArray(answers)) throw badRequest("answers must be an array");
213
+ const byId = new Map(questions.map((question) => [question.id, question]));
214
+ const given = new Map();
215
+ for (const answer of answers) {
216
+ const id = String(answer?.id ?? "");
217
+ const question = byId.get(id);
218
+ if (!question) throw badRequest("This answer does not belong to the pending question");
219
+ if (given.has(id)) throw badRequest("Each question may only be answered once");
220
+ const selected = Array.isArray(answer.selected)
221
+ ? [...new Set(answer.selected.map((label) => String(label).slice(0, 200)))]
222
+ : [];
223
+ const labels = new Set((question.options ?? []).map((option) => option.label));
224
+ for (const label of selected) {
225
+ if (!labels.has(label)) throw badRequest("The selected option is not one of this question's options");
226
+ }
227
+ if (question.multiSelect !== true && selected.length > 1) {
228
+ throw badRequest("This question accepts a single option");
229
+ }
230
+ const trimmed = typeof answer.custom === "string" ? answer.custom.trim().slice(0, 4_000) : "";
231
+ if (question.multiSelect !== true && trimmed && selected.length > 0) {
232
+ throw badRequest("A single-choice question takes either an option or free text, not both");
233
+ }
234
+ given.set(id, { id, selected, ...(trimmed ? { custom: trimmed } : {}) });
235
+ }
236
+ return questions.map((question) => given.get(question.id) ?? { id: question.id, selected: [] });
237
+ }
238
+
239
+ export class SessionManager {
240
+ #sessions = new Map();
241
+ #commands = new Map();
242
+ #approvals = new Map();
243
+ #requests = new Map();
244
+ #deltas = new Map();
245
+ // Messages typed while a turn is running wait here instead of failing with 409.
246
+ #queues = new Map();
247
+ // Watches for the two ways a turn can leave the bridge waiting forever: an Agent
248
+ // that never picked the message up, and a stop request nobody answers.
249
+ #acks = new Map();
250
+ #stopWatchdogs = new Map();
251
+
252
+ constructor({
253
+ eventStore,
254
+ registry,
255
+ projects,
256
+ conversationCatalog = null,
257
+ workspaceCatalog = null,
258
+ machineId = "local",
259
+ controlTtlMs = 5 * 60_000,
260
+ deltaFlushMs = 120,
261
+ notifier = null,
262
+ attachments = null,
263
+ turnAckMs = DEFAULT_TURN_ACK_MS,
264
+ stopAckMs = DEFAULT_STOP_ACK_MS,
265
+ }) {
266
+ this.eventStore = eventStore;
267
+ this.registry = registry;
268
+ this.projects = projects;
269
+ this.conversationCatalog = conversationCatalog;
270
+ this.workspaceCatalog = workspaceCatalog;
271
+ this.machineId = machineId;
272
+ this.notifier = notifier;
273
+ this.attachments = attachments;
274
+ this.controlTtlMs = controlTtlMs;
275
+ this.deltaFlushMs = deltaFlushMs;
276
+ this.turnAckMs = Number.isFinite(turnAckMs) && turnAckMs > 0 ? turnAckMs : DEFAULT_TURN_ACK_MS;
277
+ this.stopAckMs = Number.isFinite(stopAckMs) && stopAckMs > 0 ? stopAckMs : DEFAULT_STOP_ACK_MS;
278
+ for (const record of this.conversationCatalog?.list() ?? []) {
279
+ this.#sessions.set(record.id, this.#sessionFromRecord(record));
280
+ }
281
+ }
282
+
283
+ list({ workspaceId = null, adapterId = null } = {}) {
284
+ return [...this.#sessions.values()]
285
+ .filter((session) => !workspaceId || session.workspaceId === workspaceId)
286
+ .filter((session) => !adapterId || session.adapterId === adapterId)
287
+ .map(publicSession)
288
+ .sort((a, b) => (b.lastActiveAt ?? b.updatedAt).localeCompare(a.lastActiveAt ?? a.updatedAt));
289
+ }
290
+
291
+ get(id) {
292
+ return this.#sessions.get(id);
293
+ }
294
+
295
+ async shutdown() {
296
+ const active = this.list().filter((session) => ["starting", "running", "stopping"].includes(session.status));
297
+ if (active.length) {
298
+ throw Object.assign(new Error("Stop active Agent turns before shutting down AgentBridge"), {
299
+ statusCode: 409,
300
+ code: "ACTIVE_SESSIONS",
301
+ diagnostics: {
302
+ count: active.length,
303
+ sessions: active.map(({ id, title, status, adapterId }) => ({ id, title, status, adapterId })),
304
+ },
305
+ });
306
+ }
307
+
308
+ const failures = [];
309
+ for (const session of this.#sessions.values()) {
310
+ this.#clearAck(session.id);
311
+ this.#clearStopWatchdog(session.id);
312
+ await this.#flushDelta(session.id);
313
+ if (session.handle && typeof session.adapter?.dispose === "function") {
314
+ try {
315
+ await session.adapter.dispose(session.handle);
316
+ } catch (error) {
317
+ failures.push({ sessionId: session.id, error });
318
+ }
319
+ }
320
+ session.handle = null;
321
+ session.controller = null;
322
+ if (session.status !== "closed") session.status = "offline";
323
+ session.lastError = null;
324
+ session.updatedAt = new Date().toISOString();
325
+ await this.#persistSession(session);
326
+ }
327
+
328
+ if (failures.length) {
329
+ throw new AggregateError(failures.map((item) => item.error), `Failed to dispose ${failures.length} Agent session(s)`);
330
+ }
331
+ return { stopped: true, sessions: this.#sessions.size };
332
+ }
333
+
334
+ async discover({ workspaceId, adapterId }) {
335
+ const workspace = this.workspaceCatalog?.get(workspaceId);
336
+ if (!workspace || !workspace.enabled) throw Object.assign(new Error("Workspace not found"), { statusCode: 404 });
337
+ const adapter = this.registry.get(adapterId);
338
+ if (!adapter) throw Object.assign(new Error("Unknown adapter"), { statusCode: 404 });
339
+ if (!adapterSupports(adapter, "discover")) {
340
+ throw Object.assign(new Error(`${adapter.label} cannot discover conversations`), {
341
+ statusCode: 501,
342
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
343
+ });
344
+ }
345
+ if (!this.conversationCatalog) {
346
+ throw Object.assign(new Error("Conversation discovery requires the persistent catalog"), { statusCode: 501 });
347
+ }
348
+ const availability = await adapter.detect();
349
+ if (!availability.available) throw Object.assign(new Error(`${adapter.label} is not installed`), { statusCode: 409 });
350
+ if (availability.supported === false) {
351
+ throw Object.assign(new Error(availability.note ?? `${adapter.label} is not supported yet`), { statusCode: 501 });
352
+ }
353
+
354
+ const result = await adapter.discoverConversations({ workspace });
355
+ const conversations = Array.isArray(result) ? result : result?.conversations;
356
+ if (!Array.isArray(conversations)) {
357
+ throw Object.assign(new Error(`${adapter.label} returned an invalid discovery result`), {
358
+ statusCode: 502,
359
+ code: "ADAPTER_DISCOVERY_INVALID_RESPONSE",
360
+ });
361
+ }
362
+ const merged = await this.conversationCatalog.mergeDiscovered({
363
+ workspaceId: workspace.id,
364
+ projectAlias: workspace.alias,
365
+ adapterId,
366
+ conversations,
367
+ resumable: adapterSupports(adapter, "resume"),
368
+ });
369
+ for (const record of merged.items) {
370
+ const existing = this.#sessions.get(record.id);
371
+ if (existing) {
372
+ existing.workspaceId = record.workspaceId;
373
+ existing.projectAlias = record.projectAlias;
374
+ existing.title = record.title;
375
+ existing.lastActiveAt = record.lastActiveAt;
376
+ existing.updatedAt = record.updatedAt;
377
+ } else {
378
+ this.#sessions.set(record.id, this.#sessionFromRecord(record));
379
+ }
380
+ }
381
+ for (const id of merged.createdIds) {
382
+ const session = this.#sessions.get(id);
383
+ await this.eventStore.append("conversation.discovered", {
384
+ conversation: publicSession(session),
385
+ workspaceId: workspace.id,
386
+ adapterId,
387
+ }, {
388
+ machineId: this.machineId,
389
+ sessionId: id,
390
+ actor: { type: "agent", adapter: adapterId },
391
+ });
392
+ }
393
+ return {
394
+ created: merged.createdIds.length,
395
+ updated: merged.updatedIds.length,
396
+ items: this.list({ workspaceId: workspace.id, adapterId }),
397
+ diagnostics: Array.isArray(result) ? null : result?.diagnostics ?? null,
398
+ };
399
+ }
400
+
401
+ async create({ adapterId, projectAlias, title, commandId, device }) {
402
+ return this.#idempotent(commandId, async () => {
403
+ const adapter = this.registry.get(adapterId);
404
+ if (!adapter) throw Object.assign(new Error("Unknown adapter"), { statusCode: 404 });
405
+ const availability = await adapter.detect();
406
+ if (!availability.available) throw Object.assign(new Error(`${adapter.label} is not installed`), { statusCode: 409 });
407
+ if (availability.supported === false) throw Object.assign(new Error(availability.note ?? `${adapter.label} is not supported yet`), { statusCode: 501 });
408
+ if (!adapterSupports(adapter, "create")) {
409
+ const { createReason } = adapter.capabilities ?? {};
410
+ throw Object.assign(new Error(`${adapter.label} cannot create a new conversation${createReason ? `:${createReason}` : ""}`), {
411
+ statusCode: 501,
412
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
413
+ diagnostics: createReason ? { createReason } : undefined,
414
+ });
415
+ }
416
+ const cwd = this.projects.get(projectAlias);
417
+ if (!cwd) throw Object.assign(new Error("Project alias is not allowed"), { statusCode: 403 });
418
+
419
+ const now = new Date().toISOString();
420
+ const sessionTitle = String(title || `${adapter.label} session`).slice(0, 100);
421
+ const workspace = this.workspaceCatalog?.get(projectAlias) ?? null;
422
+ const catalogRecord = this.conversationCatalog
423
+ ? await this.conversationCatalog.create({
424
+ workspaceId: workspace?.id ?? null,
425
+ projectAlias,
426
+ adapterId,
427
+ providerConversationId: null,
428
+ title: sessionTitle,
429
+ origin: "phone-created",
430
+ lifecycle: "starting",
431
+ resumable: adapterSupports(adapter, "resume"),
432
+ attached: false,
433
+ })
434
+ : null;
435
+ const session = {
436
+ id: catalogRecord?.id ?? randomUUID(),
437
+ workspaceId: workspace?.id ?? null,
438
+ adapterId,
439
+ adapter,
440
+ projectAlias,
441
+ title: sessionTitle,
442
+ status: "starting",
443
+ createdAt: now,
444
+ updatedAt: now,
445
+ lastActiveAt: now,
446
+ controller: null,
447
+ activeTurnId: null,
448
+ lastError: null,
449
+ handle: null,
450
+ providerSessionId: null,
451
+ };
452
+ this.#sessions.set(session.id, session);
453
+ this.#grant(session, device);
454
+ await this.#emit(session, "session.created", { session: publicSession(session) });
455
+
456
+ try {
457
+ const emit = (kind, payload, extra = {}) => this.#emitQuietly(session, kind, payload, extra);
458
+ session.handle = await adapter.start({
459
+ cwd,
460
+ projectAlias,
461
+ bridgeSessionId: session.id,
462
+ emit,
463
+ });
464
+ session.providerSessionId = session.handle?.providerSessionId ?? null;
465
+ session.modelDirectory = typeof adapter.modelDirectory === "function" ? adapter.modelDirectory(session.handle) : null;
466
+ session.status = "ready";
467
+ session.updatedAt = new Date().toISOString();
468
+ await this.#emit(session, "session.ready", { session: publicSession(session) });
469
+ } catch (error) {
470
+ session.status = "failed";
471
+ session.updatedAt = new Date().toISOString();
472
+ await this.#emit(session, "session.failed", { error: error.message });
473
+ throw error;
474
+ }
475
+ return publicSession(session);
476
+ });
477
+ }
478
+
479
+ async attach({ adapterId, projectAlias, providerSessionId, title, commandId, device }) {
480
+ return this.#idempotent(commandId, async () => {
481
+ if (typeof providerSessionId !== "string" || providerSessionId.length < 8 || providerSessionId.length > 200) {
482
+ throw Object.assign(new Error("A valid provider session id is required"), { statusCode: 400 });
483
+ }
484
+ const existing = [...this.#sessions.values()].find((session) => (
485
+ session.adapterId === adapterId && (session.providerSessionId ?? session.handle?.providerSessionId) === providerSessionId
486
+ ));
487
+ if (existing) {
488
+ if (!existing.handle) await this.#ensureHandle(existing);
489
+ existing.modelDirectory = typeof existing.adapter.modelDirectory === "function"
490
+ ? existing.adapter.modelDirectory(existing.handle)
491
+ : existing.modelDirectory ?? null;
492
+ existing.status = "ready";
493
+ existing.updatedAt = new Date().toISOString();
494
+ // A reconnect arrives with no device: keep whoever holds the lease.
495
+ if (device) this.#grant(existing, device);
496
+ await this.#emit(existing, "session.ready", { session: publicSession(existing), attached: true, rebound: true });
497
+ return publicSession(existing);
498
+ }
499
+
500
+ const adapter = this.registry.get(adapterId);
501
+ if (!adapter) throw Object.assign(new Error("Unknown adapter"), { statusCode: 404 });
502
+ if (!adapterSupports(adapter, "attach")) {
503
+ throw Object.assign(new Error(`${adapter.label} cannot attach an existing conversation`), { statusCode: 501 });
504
+ }
505
+ const availability = await adapter.detect();
506
+ if (!availability.available) throw Object.assign(new Error(`${adapter.label} is not installed`), { statusCode: 409 });
507
+ const cwd = this.projects.get(projectAlias);
508
+ if (!cwd) throw Object.assign(new Error("Project alias is not allowed"), { statusCode: 403 });
509
+
510
+ const now = new Date().toISOString();
511
+ const sessionTitle = String(title || `${adapter.label} attached conversation`).slice(0, 100);
512
+ const workspace = this.workspaceCatalog?.get(projectAlias) ?? null;
513
+ const catalogRecord = this.conversationCatalog
514
+ ? await this.conversationCatalog.create({
515
+ workspaceId: workspace?.id ?? null,
516
+ projectAlias,
517
+ adapterId,
518
+ providerConversationId: providerSessionId,
519
+ title: sessionTitle,
520
+ origin: "desktop-attached",
521
+ lifecycle: "starting",
522
+ resumable: adapterSupports(adapter, "resume"),
523
+ attached: true,
524
+ })
525
+ : null;
526
+ const session = {
527
+ id: catalogRecord?.id ?? randomUUID(),
528
+ workspaceId: workspace?.id ?? null,
529
+ adapterId,
530
+ adapter,
531
+ projectAlias,
532
+ title: sessionTitle,
533
+ status: "starting",
534
+ createdAt: now,
535
+ updatedAt: now,
536
+ lastActiveAt: now,
537
+ controller: null,
538
+ activeTurnId: null,
539
+ lastError: null,
540
+ handle: null,
541
+ providerSessionId,
542
+ attached: true,
543
+ };
544
+ this.#sessions.set(session.id, session);
545
+ if (device) this.#grant(session, device);
546
+ await this.#emit(session, "session.created", { session: publicSession(session), attached: true });
547
+
548
+ try {
549
+ const emit = (kind, payload, extra = {}) => this.#emitQuietly(session, kind, payload, extra);
550
+ session.handle = await adapter.attach({ cwd, projectAlias, bridgeSessionId: session.id, emit }, providerSessionId);
551
+ session.providerSessionId = session.handle?.providerSessionId ?? providerSessionId;
552
+ session.modelDirectory = typeof adapter.modelDirectory === "function" ? adapter.modelDirectory(session.handle) : null;
553
+ session.status = "ready";
554
+ session.updatedAt = new Date().toISOString();
555
+ await this.#emit(session, "session.ready", { session: publicSession(session), attached: true });
556
+ } catch (error) {
557
+ session.status = "failed";
558
+ session.updatedAt = new Date().toISOString();
559
+ await this.#emit(session, "session.failed", { error: error.message });
560
+ throw error;
561
+ }
562
+ return publicSession(session);
563
+ });
564
+ }
565
+
566
+ async send({ sessionId, text, attachments: attachmentIds = [], commandId, device }) {
567
+ return this.#idempotent(commandId, async () => {
568
+ const session = this.#requireSession(sessionId);
569
+ this.#requireControl(session, device);
570
+ const cleanText = String(text ?? "").trim();
571
+ const attached = await this.#resolveAttachments(session, attachmentIds);
572
+ // An image alone is a perfectly good message ("what is wrong here?").
573
+ if ((!cleanText && !attached.length) || cleanText.length > 20_000) {
574
+ throw Object.assign(new Error("消息需要文字或附件,且文字不超过 20000 字符"), { statusCode: 400 });
575
+ }
576
+ // A busy agent is not an error: the message waits its turn and the phone can
577
+ // show the queue, which is how a person actually types while the agent works.
578
+ // `stopping` counts as busy for the same reason — the turn it would queue
579
+ // behind is the one being cancelled, and it leaves as soon as that lands.
580
+ if (session.status === "running" || session.status === "stopping") {
581
+ const queue = this.#queueOf(session);
582
+ if (queue.length >= MAX_QUEUED_MESSAGES) {
583
+ throw Object.assign(new Error(`排队已满(${MAX_QUEUED_MESSAGES} 条),等这一轮结束后再发`), { statusCode: 429 });
584
+ }
585
+ const entry = {
586
+ commandId,
587
+ text: cleanText,
588
+ attachments: attached,
589
+ device: { id: device.id, deviceName: device.deviceName },
590
+ at: Date.now(),
591
+ };
592
+ queue.push(entry);
593
+ await this.#emit(session, "message.queued", {
594
+ commandId,
595
+ text: cleanText.slice(0, 200),
596
+ position: queue.length,
597
+ attachments: attached.map(({ id, name, mimeType, bytes, isImage }) => ({ id, name, mimeType, bytes, isImage })),
598
+ }, { actor: { type: "user", deviceId: device.id, deviceName: device.deviceName } });
599
+ return { queued: true, position: queue.length, sessionId };
600
+ }
601
+ await this.#dispatch(session, { text: cleanText, attachments: attached, device, commandId });
602
+ return { accepted: true, sessionId };
603
+ });
604
+ }
605
+
606
+ /**
607
+ * Turn upload ids into something a provider can actually consume.
608
+ *
609
+ * Images arrive as base64 because every content-block protocol (Claude's
610
+ * stream-json, ACP's prompt blocks) wants them inline; everything else is handed
611
+ * over as an absolute path so an agent with file tools can open it itself. A
612
+ * missing id is a client bug, not a silent drop.
613
+ */
614
+ async #resolveAttachments(session, ids) {
615
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id).slice(0, 6) : [];
616
+ if (!list.length) return [];
617
+ if (!this.attachments) throw Object.assign(new Error("This bridge cannot store attachments"), { statusCode: 501 });
618
+ const resolved = [];
619
+ for (const id of list) {
620
+ const entry = this.attachments.get(id);
621
+ if (!entry || entry.sessionId !== session.id) {
622
+ throw Object.assign(new Error("附件不存在或不属于这个会话"), { statusCode: 404 });
623
+ }
624
+ const image = /^image\/(png|jpe?g|webp|gif)$/i.test(entry.mimeType);
625
+ const inline = image && entry.bytes <= 5 * 1024 * 1024;
626
+ const file = inline ? await this.attachments.read(id) : null;
627
+ if (inline && !file) throw Object.assign(new Error("附件文件已丢失"), { statusCode: 404 });
628
+ resolved.push({
629
+ id: entry.id,
630
+ name: entry.name,
631
+ mimeType: entry.mimeType,
632
+ bytes: entry.bytes,
633
+ isImage: image,
634
+ path: this.attachments.pathOf(id),
635
+ ...(inline ? { dataBase64: file.data.toString("base64") } : {}),
636
+ });
637
+ }
638
+ return resolved;
639
+ }
640
+ async stop({ sessionId, commandId, device }) {
641
+ return this.#idempotent(commandId, async () => {
642
+ const session = this.#requireSession(sessionId);
643
+ this.#requireControl(session, device);
644
+ if (!adapterSupports(session.adapter, "cancel") || typeof session.adapter?.stop !== "function") {
645
+ throw Object.assign(new Error(`${session.adapter?.label ?? "This Agent"} cannot stop an active turn`), {
646
+ statusCode: 501,
647
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
648
+ });
649
+ }
650
+ if (!["running", "stopping"].includes(session.status) || !session.handle) {
651
+ throw Object.assign(new Error("There is no active Agent turn to stop"), { statusCode: 409 });
652
+ }
653
+ // Delivering the cancel can fail for a reason the user cannot fix by tapping
654
+ // again (the desktop app that owns this conversation is gone). That failure
655
+ // must not leave the phone holding a session that is running forever, so it is
656
+ // caught here and answered with a local end-of-turn instead of a bare error.
657
+ let stopped = false;
658
+ let failure = null;
659
+ try {
660
+ stopped = Boolean(await session.adapter.stop(session.handle));
661
+ } catch (error) {
662
+ failure = error;
663
+ }
664
+ await this.#emit(session, "turn.stop_requested", {
665
+ stopped,
666
+ ...(failure ? { delivered: false, error: String(failure.message ?? failure).slice(0, 500) } : {}),
667
+ }, {
668
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
669
+ });
670
+ if (failure) {
671
+ await this.#forceCancel(session, `停止请求没有送达(${String(failure.message ?? failure).slice(0, 200)}),桥已直接结束这一轮`);
672
+ return { stopped: false, forced: true };
673
+ }
674
+ // The request is out. Until the Agent echoes `turn/end` the turn is still
675
+ // live, so the phone shows 停止中 rather than pretending it is already over.
676
+ // Ownership of the outcome moves to the stop watchdog: an unanswered stop is
677
+ // reported as a stop, not as "the Agent never started".
678
+ this.#clearAck(session.id);
679
+ session.status = "stopping";
680
+ session.updatedAt = new Date().toISOString();
681
+ this.#armStopWatchdog(session);
682
+ return { stopped: true };
683
+ });
684
+ }
685
+
686
+ async selectModel({ sessionId, provider, model, reasoningEffort, commandId, device }) {
687
+ return this.#idempotent(commandId, async () => {
688
+ const session = this.#requireSession(sessionId);
689
+ this.#requireControl(session, device);
690
+ if (session.status === "running" || session.status === "stopping") throw Object.assign(new Error("Wait for the current Agent turn to finish before changing models"), { statusCode: 409 });
691
+ if (!session.handle || typeof session.adapter.selectModel !== "function") {
692
+ throw Object.assign(new Error("This Agent session does not support changing models from AgentBridge"), { statusCode: 501 });
693
+ }
694
+ const selection = {
695
+ provider: String(provider ?? "").slice(0, 100),
696
+ model: String(model ?? "").slice(0, 160),
697
+ ...(typeof reasoningEffort === "string" && reasoningEffort ? { reasoningEffort: reasoningEffort.slice(0, 100) } : {}),
698
+ };
699
+ if (!selection.provider || !selection.model) throw Object.assign(new Error("A provider and model are required"), { statusCode: 400 });
700
+ const result = await session.adapter.selectModel(session.handle, selection);
701
+ session.modelDirectory = typeof session.adapter.modelDirectory === "function" ? session.adapter.modelDirectory(session.handle) : session.modelDirectory;
702
+ await this.#emit(session, "model.changed", { selected: result.selected }, {
703
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
704
+ });
705
+ return { selected: result.selected };
706
+ });
707
+ }
708
+
709
+ async claimControl({ sessionId, commandId, device, force = false }) {
710
+ return this.#idempotent(commandId, async () => {
711
+ const session = this.#requireSession(sessionId);
712
+ const activeOther = session.controller
713
+ && session.controller.expiresAt > Date.now()
714
+ && session.controller.deviceId !== device.id;
715
+ if (activeOther && !force) {
716
+ throw Object.assign(new Error(`Controlled by ${session.controller.deviceName}`), { statusCode: 409 });
717
+ }
718
+ this.#grant(session, device);
719
+ await this.#emit(session, "control.granted", { controller: publicSession(session).controller }, {
720
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
721
+ });
722
+ return publicSession(session).controller;
723
+ });
724
+ }
725
+
726
+ async resolveApproval({ approvalId, decision, commandId, device }) {
727
+ return this.#idempotent(commandId, async () => {
728
+ if (decision !== "approve" && decision !== "deny") {
729
+ throw Object.assign(new Error("Decision must be approve or deny"), { statusCode: 400 });
730
+ }
731
+ const target = this.#approvals.get(approvalId);
732
+ if (!target) throw Object.assign(new Error("Approval is missing or already resolved"), { statusCode: 409 });
733
+ const session = this.#requireSession(target.sessionId);
734
+ this.#requireControl(session, device);
735
+ await session.adapter.resolveApproval(session.handle, approvalId, decision);
736
+ this.#approvals.delete(approvalId);
737
+ await this.#emit(session, "approval.resolved", { approvalId, decision }, {
738
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
739
+ });
740
+ return { approvalId, decision };
741
+ });
742
+ }
743
+
744
+ /**
745
+ * Grant a time-boxed blanket approval for this session.
746
+ *
747
+ * The phone should not have to babysit a long task, but a blanket approval is
748
+ * also the most dangerous switch in the product: it is capped at 30 minutes,
749
+ * scoped to an explicit tool list (`["*"]` for everything), owned by the device
750
+ * that asked for it, and mirrored into the session so every client renders the
751
+ * same countdown.
752
+ */
753
+ async setAutoApproval({ sessionId, minutes, tools = ["*"], commandId, device }) {
754
+ return this.#idempotent(commandId, async () => {
755
+ const session = this.#requireSession(sessionId);
756
+ this.#requireControl(session, device);
757
+ if (!adapterSupports(session.adapter, "approve")) {
758
+ throw Object.assign(new Error(`${session.adapter?.label ?? "This Agent"} cannot approve tool calls`), {
759
+ statusCode: 501,
760
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
761
+ });
762
+ }
763
+ const requested = Number(minutes);
764
+ if (!Number.isFinite(requested) || requested < 0) throw badRequest("minutes must be a number");
765
+ if (requested === 0) {
766
+ session.autoApprove = null;
767
+ await this.#emit(session, "approval.policy", { autoApprove: null }, {
768
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
769
+ });
770
+ return { autoApprove: null };
771
+ }
772
+ const windowMs = Math.min(Math.round(requested * 60_000), MAX_AUTO_APPROVE_MS);
773
+ if (windowMs < 60_000) throw badRequest("Ask for at least one minute, or 0 to turn it off");
774
+ const scope = (Array.isArray(tools) ? tools : ["*"])
775
+ .map((tool) => String(tool).trim().slice(0, 60))
776
+ .filter(Boolean)
777
+ .slice(0, 20);
778
+ const policy = {
779
+ until: Date.now() + windowMs,
780
+ tools: scope.length ? scope : ["*"],
781
+ by: device.id,
782
+ deviceName: device.deviceName,
783
+ };
784
+ session.autoApprove = policy;
785
+ await this.#emit(session, "approval.policy", { autoApprove: policy }, {
786
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
787
+ });
788
+ return { autoApprove: policy };
789
+ });
790
+ }
791
+
792
+ /** The live policy for a session, or null once it has expired. */
793
+ #liveAutoApprove(session) {
794
+ const policy = session.autoApprove;
795
+ if (!policy) return null;
796
+ if (policy.until <= Date.now()) {
797
+ session.autoApprove = null;
798
+ return null;
799
+ }
800
+ return policy;
801
+ }
802
+
803
+ #autoApprovalAllows(session, payload) {
804
+ const policy = this.#liveAutoApprove(session);
805
+ if (!policy) return false;
806
+ const tool = String(payload.request?.toolName ?? payload.method ?? "").slice(0, 60);
807
+ return policy.tools.includes("*") || policy.tools.includes(tool);
808
+ }
809
+
810
+ async #autoResolveApproval(session, approvalId) {
811
+ const policy = this.#liveAutoApprove(session);
812
+ if (!policy) return;
813
+ try {
814
+ // Approvals are per tool call, so each one still goes through the adapter;
815
+ // only the human decision is skipped.
816
+ await session.adapter.resolveApproval(session.handle, approvalId, "approve");
817
+ this.#approvals.delete(approvalId);
818
+ await this.#emit(session, "approval.resolved", {
819
+ approvalId,
820
+ decision: "approve",
821
+ auto: true,
822
+ until: policy.until,
823
+ }, { actor: { type: "system", source: "auto-approve" } });
824
+ } catch (error) {
825
+ // If the blanket approval could not be delivered, say so on the card instead
826
+ // of leaving a phone waiting for a prompt that will never resolve.
827
+ session.lastError = `自动放行失败:${error.message}`;
828
+ await this.#emit(session, "approval.resolve_failed", { approvalId, error: String(error.message).slice(0, 500) });
829
+ }
830
+ }
831
+ // An agent that pauses on a question is not running a turn the phone can stop
832
+ // its way out of: the tool call is waiting on a human. This is the answer path
833
+ // for `input.requested`, kept separate from `resolveApproval` because the
834
+ // payload (option labels plus optional free text) and the provider seam differ.
835
+ async resolveInput({ requestId, answers, cancel = false, commandId, device }) {
836
+ return this.#idempotent(commandId, async () => {
837
+ const target = this.#requests.get(String(requestId ?? ""));
838
+ if (!target) throw Object.assign(new Error("This question is missing or already answered"), { statusCode: 409 });
839
+ const session = this.#requireSession(target.sessionId);
840
+ this.#requireControl(session, device);
841
+ if (!adapterSupports(session.adapter, "answer") || typeof session.adapter?.resolveInput !== "function") {
842
+ throw Object.assign(new Error(`${session.adapter?.label ?? "This Agent"} cannot answer questions from AgentBridge`), {
843
+ statusCode: 501,
844
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
845
+ });
846
+ }
847
+ const batch = cancel ? [] : buildAnswerBatch(target.questions, answers);
848
+ await session.adapter.resolveInput(session.handle, requestId, { answers: batch, cancel: Boolean(cancel) });
849
+ this.#requests.delete(requestId);
850
+ const outcome = cancel ? "cancelled" : "answered";
851
+ await this.#emit(session, "input.resolved", { requestId, outcome }, {
852
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
853
+ });
854
+ return { requestId, outcome };
855
+ });
856
+ }
857
+
858
+ // Agents emit one delta per token. Forwarding each one as its own stored
859
+ // event floods the phone over a tunnel and lets token chatter push real
860
+ // messages out of the replay window, so deltas are coalesced per turn and
861
+ // addressed by character offset. A client folds them with
862
+ // `text = current.slice(0, offset) + text`, which makes streaming idempotent
863
+ // even when chunks are merged, replayed, or arrive out of order.
864
+ #queueDelta(session, payload, extra) {
865
+ const turnId = extra.turnId ?? session.activeTurnId ?? null;
866
+ const text = String(payload?.text ?? "");
867
+ if (!text) return;
868
+ let state = this.#deltas.get(session.id);
869
+ if (!state || state.turnId !== turnId) {
870
+ state = { turnId, pending: "", offset: 0, timer: null, chain: Promise.resolve() };
871
+ this.#deltas.set(session.id, state);
872
+ }
873
+ state.pending += text;
874
+ if (!state.timer) {
875
+ state.timer = setTimeout(() => {
876
+ state.timer = null;
877
+ void this.#flushDelta(session.id).catch(() => undefined);
878
+ }, this.deltaFlushMs);
879
+ state.timer.unref?.();
880
+ }
881
+ }
882
+
883
+ #flushDelta(sessionId) {
884
+ const state = this.#deltas.get(sessionId);
885
+ if (!state) return Promise.resolve();
886
+ if (state.timer) {
887
+ clearTimeout(state.timer);
888
+ state.timer = null;
889
+ }
890
+ // Serialised per session so a turn's offsets are always emitted in order,
891
+ // and so callers can await the last delta before a message.completed.
892
+ state.chain = state.chain.catch(() => undefined).then(() => this.#writeDelta(sessionId));
893
+ return state.chain;
894
+ }
895
+
896
+ async #writeDelta(sessionId) {
897
+ const state = this.#deltas.get(sessionId);
898
+ if (!state || !state.pending) return;
899
+ const session = this.#sessions.get(sessionId);
900
+ if (!session) {
901
+ this.#deltas.delete(sessionId);
902
+ return;
903
+ }
904
+ const text = state.pending;
905
+ const offset = state.offset;
906
+ state.pending = "";
907
+ state.offset += text.length;
908
+ await this.eventStore.append("message.delta", { role: "assistant", text, offset }, {
909
+ machineId: this.machineId,
910
+ sessionId,
911
+ turnId: state.turnId,
912
+ actor: { type: "agent", adapter: session.adapterId },
913
+ });
914
+ }
915
+
916
+ // Adapters receive an `emit` closure and usually call it without awaiting it, so a
917
+ // failure here (a full disk, a data directory that vanished) would otherwise become an
918
+ // unhandled rejection and kill the whole bridge mid-turn. Losing one event is bad;
919
+ // taking every other conversation down with it is worse.
920
+ #emitQuietly(session, kind, payload, extra = {}) {
921
+ return this.#emit(session, kind, payload, extra).catch((error) => {
922
+ console.error(`AgentBridge dropped ${kind} for session ${session.id}: ${error?.message ?? error}`);
923
+ return null;
924
+ });
925
+ }
926
+
927
+ async #emit(session, kind, payload, extra = {}) {
928
+ if (kind === "message.delta") return this.#queueDelta(session, payload, extra);
929
+ // Anything the Agent says after a dispatch proves it picked the turn up, and a
930
+ // turn end is the only thing that can release `running` — so both watchdogs are
931
+ // disarmed here, before the state machine below runs.
932
+ if (PROGRESS_KINDS.has(kind)) this.#clearAck(session.id);
933
+ if (TURN_END_KINDS.has(kind)) this.#clearStopWatchdog(session.id);
934
+ // Any other event must land after the deltas that preceded it, or the phone
935
+ // would render a completed message before its own streamed prefix.
936
+ await this.#flushDelta(session.id);
937
+ session.updatedAt = new Date().toISOString();
938
+ session.lastActiveAt = session.updatedAt;
939
+ if (kind === "tool.started") {
940
+ this.#countUsage(session, { toolCalls: 1 });
941
+ }
942
+ if (kind === "turn.started") {
943
+ session.status = "running";
944
+ session.activeTurnId = extra.turnId ?? null;
945
+ session.lastError = null;
946
+ } else if (["turn.completed", "turn.cancelled", "turn.failed"].includes(kind)) {
947
+ this.#countUsage(session, {
948
+ turns: 1,
949
+ ...(Number.isFinite(payload.usage?.inputTokens) ? { inputTokens: payload.usage.inputTokens } : {}),
950
+ ...(Number.isFinite(payload.usage?.outputTokens) ? { outputTokens: payload.usage.outputTokens } : {}),
951
+ });
952
+ session.status = kind === "turn.failed" ? "failed" : "ready";
953
+ session.activeTurnId = null;
954
+ session.lastError = kind === "turn.failed" ? String(payload.error ?? "Agent execution failed").slice(-2000) : null;
955
+ this.#deltas.delete(session.id);
956
+ this.#dropRequests(session.id);
957
+ } else if (kind === "session.failed") {
958
+ session.lastError = String(payload.error ?? "Agent session failed").slice(-2000);
959
+ } else if (kind === "session.ready") {
960
+ session.lastError = null;
961
+ } else if (kind === "adapter.exited" && session.status !== "failed") {
962
+ session.status = "offline";
963
+ session.lastError = String(payload.error ?? payload.stderr ?? "Agent process exited").slice(-2000);
964
+ this.#dropRequests(session.id);
965
+ } else if (kind === "approval.requested" && payload.approvalId) {
966
+ this.#approvals.set(payload.approvalId, { sessionId: session.id });
967
+ // A live policy answers this one instead of the phone, so the agent never
968
+ // stalls on a prompt nobody is looking at.
969
+ if (this.#autoApprovalAllows(session, payload)) void this.#autoResolveApproval(session, payload.approvalId);
970
+ } else if (kind === "approval.resolved" && payload.approvalId) {
971
+ this.#approvals.delete(payload.approvalId);
972
+ } else if (kind === "input.requested" && payload.requestId) {
973
+ this.#requests.set(payload.requestId, {
974
+ sessionId: session.id,
975
+ questions: Array.isArray(payload.questions) ? payload.questions : [],
976
+ });
977
+ } else if (kind === "input.resolved" && payload.requestId) {
978
+ this.#requests.delete(payload.requestId);
979
+ }
980
+ if ([
981
+ "session.ready",
982
+ "session.failed",
983
+ "turn.started",
984
+ "turn.completed",
985
+ "turn.cancelled",
986
+ "turn.failed",
987
+ "adapter.exited",
988
+ ].includes(kind)) {
989
+ // The catalog is an index, not the conversation: a failed write must never
990
+ // take the bridge down. Several call sites emit fire-and-forget, so a rejection
991
+ // here surfaces as an unhandled rejection and kills the process mid-turn.
992
+ await this.#persistSession(session).catch((error) => {
993
+ console.error(`AgentBridge catalog write failed for session ${session.id}: ${error?.message ?? error}`);
994
+ });
995
+ }
996
+ session.queued = this.queuedMessages(session.id);
997
+ const appended = await this.eventStore.append(kind, compactEventPayload(kind, payload), {
998
+ machineId: this.machineId,
999
+ sessionId: session.id,
1000
+ turnId: extra.turnId ?? session.activeTurnId,
1001
+ actor: extra.actor ?? { type: "agent", adapter: session.adapterId },
1002
+ });
1003
+ // Fire and forget: a notification must never delay or fail the event it
1004
+ // describes, and the notifier's own rate limit keeps a long turn from storming
1005
+ // the user's chat.
1006
+ if (this.notifier?.enabled) void this.#notify(session, kind, payload);
1007
+ // A finished turn is the moment a queued message can go out.
1008
+ if (["turn.completed", "turn.failed", "turn.cancelled"].includes(kind)) {
1009
+ void this.#drainQueue(session).catch(() => undefined);
1010
+ }
1011
+ return appended;
1012
+ }
1013
+
1014
+ #notify(session, kind, payload) {
1015
+ if (!NOTIFY_KINDS.has(kind)) return;
1016
+ return this.#notificationContext(session)
1017
+ .then((context) => this.notifier.send(kind, payload, context))
1018
+ .catch(() => undefined);
1019
+ }
1020
+
1021
+ async #notificationContext(session) {
1022
+ return {
1023
+ sessionId: session.id,
1024
+ sessionTitle: session.title,
1025
+ adapterId: session.adapterId,
1026
+ adapterLabel: session.adapter?.label ?? session.adapterId,
1027
+ machineName: this.machineName,
1028
+ lastAssistantText: await this.#lastAssistantText(session),
1029
+ };
1030
+ }
1031
+
1032
+ /** The tail of the newest assistant message, for a notification preview. */
1033
+ async #lastAssistantText(session) {
1034
+ if (typeof this.eventStore.readSessionHistory !== "function") return "";
1035
+ const history = await this.eventStore.readSessionHistory(session.id, { limit: 12 });
1036
+ const events = Array.isArray(history?.events) ? history.events : [];
1037
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1038
+ const event = events[index];
1039
+ if (event?.kind !== "message.completed") continue;
1040
+ if (event.payload?.role !== "assistant") continue;
1041
+ const text = String(event.payload.text ?? "").replace(/\s+/g, " ").trim();
1042
+ if (text) return text;
1043
+ }
1044
+ return "";
1045
+ }
1046
+
1047
+ async #ensureHandle(session) {
1048
+ if (session.handle) return session.handle;
1049
+ const adapter = session.adapter ?? this.registry.get(session.adapterId);
1050
+ if (!adapter) throw Object.assign(new Error("Session adapter is unavailable"), { statusCode: 409 });
1051
+ if (!session.providerSessionId || !adapterSupports(adapter, "attach") || !adapterSupports(adapter, "resume")) {
1052
+ throw Object.assign(new Error(`${adapter.label} cannot resume this conversation`), {
1053
+ statusCode: 501,
1054
+ code: "ADAPTER_CAPABILITY_UNSUPPORTED",
1055
+ });
1056
+ }
1057
+ const availability = await adapter.detect();
1058
+ if (!availability.available) throw Object.assign(new Error(`${adapter.label} is not installed`), { statusCode: 409 });
1059
+ if (availability.supported === false) {
1060
+ throw Object.assign(new Error(availability.note ?? `${adapter.label} is not supported yet`), { statusCode: 501 });
1061
+ }
1062
+ const cwd = this.projects.get(session.projectAlias);
1063
+ if (!cwd) throw Object.assign(new Error("Project alias is not allowed"), { statusCode: 403 });
1064
+
1065
+ session.adapter = adapter;
1066
+ session.status = "starting";
1067
+ session.updatedAt = new Date().toISOString();
1068
+ await this.#persistSession(session);
1069
+ try {
1070
+ const emit = (kind, payload, extra = {}) => this.#emitQuietly(session, kind, payload, extra);
1071
+ session.handle = await adapter.attach({
1072
+ cwd,
1073
+ projectAlias: session.projectAlias,
1074
+ bridgeSessionId: session.id,
1075
+ emit,
1076
+ }, session.providerSessionId);
1077
+ session.providerSessionId = session.handle?.providerSessionId ?? session.providerSessionId;
1078
+ session.modelDirectory = typeof adapter.modelDirectory === "function" ? adapter.modelDirectory(session.handle) : null;
1079
+ session.status = "ready";
1080
+ session.updatedAt = new Date().toISOString();
1081
+ await this.#emit(session, "session.ready", { session: publicSession(session), resumed: true });
1082
+ return session.handle;
1083
+ } catch (error) {
1084
+ session.status = "failed";
1085
+ session.lastError = error.message;
1086
+ session.updatedAt = new Date().toISOString();
1087
+ await this.#emit(session, "session.failed", { error: error.message, resumed: true });
1088
+ throw error;
1089
+ }
1090
+ }
1091
+
1092
+ #persistSession(session) {
1093
+ if (!this.conversationCatalog?.get(session.id)) return Promise.resolve();
1094
+ return this.conversationCatalog.update(session.id, {
1095
+ providerConversationId: session.providerSessionId ?? session.handle?.providerSessionId ?? null,
1096
+ title: session.title,
1097
+ lifecycle: session.status,
1098
+ resumable: adapterSupports(session.adapter, "resume"),
1099
+ attached: Boolean(session.attached),
1100
+ lastError: session.lastError ?? null,
1101
+ lastActiveAt: session.lastActiveAt ?? session.updatedAt,
1102
+ // Without this the phone hides the model control of every conversation
1103
+ // whose Agent is offline, which read as "the feature disappeared".
1104
+ modelDirectory: compactModelDirectoryForStorage(session.modelDirectory),
1105
+ });
1106
+ }
1107
+
1108
+ #sessionFromRecord(record) {
1109
+ return {
1110
+ id: record.id,
1111
+ workspaceId: record.workspaceId ?? null,
1112
+ adapterId: record.adapterId,
1113
+ adapter: this.registry.get(record.adapterId) ?? null,
1114
+ projectAlias: record.projectAlias,
1115
+ title: record.title,
1116
+ status: record.lifecycle === "closed" ? "closed" : "offline",
1117
+ createdAt: record.createdAt,
1118
+ updatedAt: record.updatedAt,
1119
+ lastActiveAt: record.lastActiveAt ?? record.updatedAt,
1120
+ controller: null,
1121
+ activeTurnId: null,
1122
+ lastError: record.lastError,
1123
+ handle: null,
1124
+ providerSessionId: record.providerConversationId,
1125
+ attached: record.attached,
1126
+ modelDirectory: record.modelDirectory ?? null,
1127
+ };
1128
+ }
1129
+
1130
+ #requireSession(id) {
1131
+ const session = this.#sessions.get(id);
1132
+ if (!session) throw Object.assign(new Error("Session not found"), { statusCode: 404 });
1133
+ return session;
1134
+ }
1135
+
1136
+ #grant(session, device) {
1137
+ session.controller = {
1138
+ deviceId: device.id,
1139
+ deviceName: device.deviceName,
1140
+ expiresAt: Date.now() + this.controlTtlMs,
1141
+ };
1142
+ }
1143
+
1144
+ /**
1145
+ * Actually start a turn. Shared by `send` and by the queue drain, because both
1146
+ * have to produce exactly the same events.
1147
+ */
1148
+ async #dispatch(session, { text, attachments = [], device, commandId }) {
1149
+ await this.#ensureHandle(session);
1150
+ if (!session.handle) throw Object.assign(new Error("Session is not ready"), { statusCode: 409 });
1151
+ session.status = "running";
1152
+ session.updatedAt = new Date().toISOString();
1153
+ await this.#emit(session, "message.completed", {
1154
+ role: "user",
1155
+ text,
1156
+ ...(attachments.length
1157
+ ? { attachments: attachments.map(({ id, name, mimeType, bytes, isImage }) => ({ id, name, mimeType, bytes, isImage })) }
1158
+ : {}),
1159
+ }, {
1160
+ actor: { type: "user", deviceId: device.id, deviceName: device.deviceName },
1161
+ });
1162
+ const emit = (kind, payload, extra = {}) => this.#emitQuietly(session, kind, payload, extra);
1163
+ // Armed after the user's own message is recorded (that event must not count as
1164
+ // the Agent answering) and before the adapter is asked to run, so a dispatch
1165
+ // that produces nothing at all cannot hold `running` indefinitely.
1166
+ this.#armAck(session);
1167
+ void Promise.resolve(session.adapter.send(session.handle, { text, attachments, emit, commandId }))
1168
+ .catch((error) => this.#emitQuietly(session, "turn.failed", { error: error.message }));
1169
+ }
1170
+
1171
+ /**
1172
+ * Look for the Agent's first sign of life after a dispatch.
1173
+ *
1174
+ * The phone has no way to distinguish "thinking hard" from "the message never
1175
+ * arrived", so the bridge has to make that call: silence for `turnAckMs` with the
1176
+ * session still marked running means the turn never started, and the person's
1177
+ * input comes back with an explanation instead of a frozen composer.
1178
+ */
1179
+ #armAck(session) {
1180
+ this.#clearAck(session.id);
1181
+ const timer = setTimeout(() => {
1182
+ this.#acks.delete(session.id);
1183
+ const live = this.#sessions.get(session.id);
1184
+ // A turn already being stopped is the stop watchdog's business: reporting
1185
+ // "never started" there would overwrite a stop the person explicitly asked for.
1186
+ if (!live || live.status !== "running") return;
1187
+ const seconds = Math.round(this.turnAckMs / 1000);
1188
+ void this.#emitQuietly(live, "turn.failed", {
1189
+ error: `Agent 没有开始执行这一轮(${seconds} 秒内没有任何回应),已释放输入。请确认那台电脑上的 Agent 还连着这个会话,再重新发送。`,
1190
+ code: "TURN_ACK_TIMEOUT",
1191
+ });
1192
+ }, this.turnAckMs);
1193
+ timer.unref?.();
1194
+ this.#acks.set(session.id, timer);
1195
+ }
1196
+
1197
+ #clearAck(sessionId) {
1198
+ const timer = this.#acks.get(sessionId);
1199
+ if (!timer) return;
1200
+ clearTimeout(timer);
1201
+ this.#acks.delete(sessionId);
1202
+ }
1203
+
1204
+ /**
1205
+ * Watch a stop request that has been delivered but not answered.
1206
+ *
1207
+ * A cancel is a request, not a state change: only the Agent's own `turn/end`
1208
+ * releases the turn. When that echo never arrives the phone would sit in 停止中
1209
+ * with no way out, which is the exact shape of the bug that motivated this.
1210
+ */
1211
+ #armStopWatchdog(session) {
1212
+ this.#clearStopWatchdog(session.id);
1213
+ const timer = setTimeout(() => {
1214
+ this.#stopWatchdogs.delete(session.id);
1215
+ const live = this.#sessions.get(session.id);
1216
+ if (!live || live.status !== "stopping") return;
1217
+ void this.#forceCancel(live, "Agent 没有回应停止请求,桥已直接结束这一轮");
1218
+ }, this.stopAckMs);
1219
+ timer.unref?.();
1220
+ this.#stopWatchdogs.set(session.id, timer);
1221
+ }
1222
+
1223
+ #clearStopWatchdog(sessionId) {
1224
+ const timer = this.#stopWatchdogs.get(sessionId);
1225
+ if (!timer) return;
1226
+ clearTimeout(timer);
1227
+ this.#stopWatchdogs.delete(sessionId);
1228
+ }
1229
+
1230
+ /**
1231
+ * End a turn locally, without the Agent's cooperation.
1232
+ *
1233
+ * Reported as `turn.cancelled` with `reason.kind = "forced"` so the transcript
1234
+ * stays honest about *why* a turn nobody answered stopped: the person asked for
1235
+ * it, and the bridge finished the job the Agent never acknowledged.
1236
+ */
1237
+ async #forceCancel(session, note) {
1238
+ this.#clearAck(session.id);
1239
+ this.#clearStopWatchdog(session.id);
1240
+ if (!["running", "stopping"].includes(session.status)) return;
1241
+ await this.#emitQuietly(session, "turn.cancelled", {
1242
+ reason: { kind: "forced", note },
1243
+ }, { turnId: session.activeTurnId ?? undefined });
1244
+ }
1245
+
1246
+ /**
1247
+ * Activity counters, and token totals when the Agent reports them.
1248
+ *
1249
+ * Every provider exposes something different: Claude puts token counts on its
1250
+ * result frame, others report nothing. Turns and tool calls are always counted
1251
+ * (they are what "how much has this done" means in practice); tokens are added only
1252
+ * when an adapter actually knows them.
1253
+ */
1254
+ #countUsage(session, delta = {}) {
1255
+ const usage = session.usage ?? { turns: 0, toolCalls: 0 };
1256
+ for (const key of ["turns", "toolCalls", "inputTokens", "outputTokens"]) {
1257
+ if (Number.isFinite(delta[key])) usage[key] = (usage[key] ?? 0) + delta[key];
1258
+ }
1259
+ session.usage = usage;
1260
+ }
1261
+ #queueOf(session) {
1262
+ let queue = this.#queues.get(session.id);
1263
+ if (!queue) {
1264
+ queue = [];
1265
+ this.#queues.set(session.id, queue);
1266
+ }
1267
+ return queue;
1268
+ }
1269
+
1270
+ /** Public view of the queue, so every client renders the same list. */
1271
+ queuedMessages(sessionId) {
1272
+ return (this.#queues.get(sessionId) ?? []).map((entry, index) => ({
1273
+ commandId: entry.commandId,
1274
+ text: entry.text.slice(0, 200),
1275
+ position: index + 1,
1276
+ at: entry.at,
1277
+ attachments: (entry.attachments ?? []).map(({ id, name, isImage }) => ({ id, name, isImage })),
1278
+ }));
1279
+ }
1280
+
1281
+ /** Send the next queued message once the current turn is over. */
1282
+ async #drainQueue(session) {
1283
+ const queue = this.#queues.get(session.id);
1284
+ if (!queue?.length) return;
1285
+ if (session.status === "running" || session.status === "stopping") return;
1286
+ const next = queue.shift();
1287
+ try {
1288
+ await this.#emit(session, "message.dequeued", { commandId: next.commandId, remaining: queue.length }, {
1289
+ actor: { type: "user", deviceId: next.device.id, deviceName: next.device.deviceName },
1290
+ });
1291
+ await this.#dispatch(session, {
1292
+ text: next.text,
1293
+ attachments: next.attachments ?? [],
1294
+ device: next.device,
1295
+ commandId: next.commandId,
1296
+ });
1297
+ } catch (error) {
1298
+ session.status = "failed";
1299
+ session.lastError = `排队消息发送失败:${error.message}`;
1300
+ await this.#emit(session, "turn.failed", { error: session.lastError });
1301
+ }
1302
+ }
1303
+
1304
+ /** Drop a queued message before it is sent. */
1305
+ async cancelQueued({ sessionId, commandId, device, force = false }) {
1306
+ const session = this.#requireSession(sessionId);
1307
+ if (!force) this.#requireControl(session, device);
1308
+ const queue = this.#queues.get(session.id) ?? [];
1309
+ const index = queue.findIndex((entry) => entry.commandId === commandId);
1310
+ if (index < 0) throw Object.assign(new Error("这条排队消息已经不存在"), { statusCode: 404 });
1311
+ const [removed] = queue.splice(index, 1);
1312
+ await this.#emit(session, "message.dequeued", {
1313
+ commandId: removed.commandId,
1314
+ cancelled: true,
1315
+ remaining: queue.length,
1316
+ }, { actor: { type: "user", deviceId: device.id, deviceName: device.deviceName } });
1317
+ return { cancelled: true, remaining: queue.length };
1318
+ }
1319
+ /** Route-level guard for endpoints that live outside this class (uploads). */
1320
+ requireControlFor(sessionId, device) {
1321
+ this.#requireControl(this.#requireSession(sessionId), device);
1322
+ }
1323
+
1324
+ #requireControl(session, device) {
1325
+ if (!session.controller || session.controller.expiresAt <= Date.now()) this.#grant(session, device);
1326
+ if (session.controller.deviceId !== device.id) {
1327
+ // The code lets the phone recover without string-matching: tapping 提交回答 is
1328
+ // an explicit decision to drive, so it claims the lease and retries once.
1329
+ throw Object.assign(
1330
+ new Error(`这个会话现在由「${session.controller.deviceName}」控制:先接管输入再继续`),
1331
+ { statusCode: 409, code: "CONTROL_REQUIRED" },
1332
+ );
1333
+ }
1334
+ session.controller.expiresAt = Date.now() + this.controlTtlMs;
1335
+ }
1336
+
1337
+ // A question only lives as long as the turn that asked it: once the run ends,
1338
+ // answering it would be a 409 anyway, so the entry goes with it.
1339
+ #dropRequests(sessionId) {
1340
+ for (const [requestId, request] of this.#requests) {
1341
+ if (request.sessionId === sessionId) this.#requests.delete(requestId);
1342
+ }
1343
+ }
1344
+
1345
+ #idempotent(commandId, operation) {
1346
+ if (typeof commandId !== "string" || commandId.length < 8 || commandId.length > 100) {
1347
+ throw Object.assign(new Error("A valid commandId is required"), { statusCode: 400 });
1348
+ }
1349
+ if (this.#commands.has(commandId)) return this.#commands.get(commandId);
1350
+ const result = Promise.resolve().then(operation);
1351
+ this.#commands.set(commandId, result);
1352
+ if (this.#commands.size > 2000) this.#commands.delete(this.#commands.keys().next().value);
1353
+ return result;
1354
+ }
1355
+ }