@pix3/agent-bridge 0.2.0 → 0.2.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/src/sessions.ts CHANGED
@@ -1,597 +1,597 @@
1
- /**
2
- * Harness inversion: exposes the stateless Anthropic Messages protocol (which pix3's agent loop
3
- * speaks) on top of stateful Claude Agent SDK sessions (the sanctioned consumer of a Claude
4
- * Code / MAX-subscription login).
5
- *
6
- * How one pix3 request round-trips:
7
- *
8
- * 1. pix3 POSTs `/v1/messages` (system + tools + full history). The manager routes it to a live
9
- * session (or starts one). New trailing user message -> pushed into the SDK's streaming input.
10
- * 2. The SDK calls the model. pix3's tools are registered as an in-process MCP server whose
11
- * CallTool handler BLOCKS: when the model requests a tool, the assistant message (with
12
- * `tool_use` blocks) is returned as the HTTP response and the handler's promise stays pending.
13
- * 3. pix3 executes the tool in the editor and POSTs again with `tool_result` blocks. Those
14
- * resolve the blocked handlers, the SDK forwards the results to the model, and the next
15
- * assistant message answers this HTTP request. A turn with no tool calls ends with the SDK's
16
- * `result` message -> `stop_reason: "end_turn"`.
17
- *
18
- * The real conversation state lives in the SDK session; pix3's replayed history is used only for
19
- * routing/correlation. When nothing matches (bridge restart, edited history, model switch) the
20
- * manager degrades gracefully: it starts a fresh session whose first user message is a plain-text
21
- * transcript of the history.
22
- */
23
-
24
- import { randomUUID } from 'node:crypto';
25
- import os from 'node:os';
26
-
27
- import { query } from '@anthropic-ai/claude-agent-sdk';
28
- import type { Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
29
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
30
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
31
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
32
-
33
- import {
34
- HttpError,
35
- extractToolResults,
36
- isRecord,
37
- renderTranscript,
38
- systemToText,
39
- toUserContent,
40
- } from './wire.ts';
41
- import type { WireBlock, WireMessagesRequest, WireToolDefinition, WireToolResult } from './wire.ts';
42
-
43
- const MCP_SERVER_NAME = 'pix3';
44
- const MCP_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
45
- /** Hard cap on how long one `/v1/messages` request may wait for the model. */
46
- const RESPONSE_TIMEOUT_MS = 20 * 60 * 1000;
47
- const IDLE_TIMEOUT_MS = 45 * 60 * 1000;
48
- const MAX_SESSIONS = 4;
49
-
50
- export type Logger = (line: string) => void;
51
-
52
- interface PendingCall {
53
- readonly name: string;
54
- readonly input: unknown;
55
- resolve: (result: CallToolResult) => void;
56
- /** Anthropic `tool_use` block id this call was matched to (assigned from the assistant message). */
57
- toolUseId?: string;
58
- }
59
-
60
- class Deferred<T> {
61
- readonly promise: Promise<T>;
62
- resolve!: (value: T) => void;
63
- reject!: (error: unknown) => void;
64
- constructor() {
65
- this.promise = new Promise<T>((resolve, reject) => {
66
- this.resolve = resolve;
67
- this.reject = reject;
68
- });
69
- }
70
- }
71
-
72
- /** Push-based async iterable used as the SDK's streaming input. */
73
- class AsyncQueue<T> implements AsyncIterable<T> {
74
- private readonly buffer: T[] = [];
75
- private readonly waiters: Array<(result: IteratorResult<T>) => void> = [];
76
- private ended = false;
77
-
78
- push(item: T): void {
79
- const waiter = this.waiters.shift();
80
- if (waiter) waiter({ value: item, done: false });
81
- else this.buffer.push(item);
82
- }
83
-
84
- end(): void {
85
- this.ended = true;
86
- for (const waiter of this.waiters.splice(0)) {
87
- waiter({ value: undefined as never, done: true });
88
- }
89
- }
90
-
91
- [Symbol.asyncIterator](): AsyncIterator<T> {
92
- return {
93
- next: (): Promise<IteratorResult<T>> => {
94
- if (this.buffer.length > 0) {
95
- return Promise.resolve({ value: this.buffer.shift() as T, done: false });
96
- }
97
- if (this.ended) return Promise.resolve({ value: undefined as never, done: true });
98
- return new Promise(resolve => this.waiters.push(resolve));
99
- },
100
- };
101
- }
102
- }
103
-
104
- const toolUseName = (block: WireBlock): string =>
105
- typeof block.name === 'string' ? block.name : '';
106
-
107
- const stripPrefix = (name: string): string =>
108
- name.startsWith(MCP_PREFIX) ? name.slice(MCP_PREFIX.length) : name;
109
-
110
- const sameJson = (a: unknown, b: unknown): boolean => {
111
- try {
112
- return JSON.stringify(a) === JSON.stringify(b);
113
- } catch {
114
- return false;
115
- }
116
- };
117
-
118
- const toCallToolResult = (result: WireToolResult): CallToolResult => ({
119
- content: [{ type: 'text', text: result.content || '(empty result)' }],
120
- ...(result.isError ? { isError: true } : {}),
121
- });
122
-
123
- export interface BridgeResponse {
124
- readonly status: number;
125
- readonly body: Record<string, unknown>;
126
- }
127
-
128
- export class BridgeSession {
129
- readonly id = randomUUID().slice(0, 8);
130
- model: string;
131
- /** Expected `messages.length` of pix3's NEXT request if this chat simply continues. */
132
- transcriptLen = 0;
133
- lastActivity = Date.now();
134
- closed = false;
135
-
136
- private readonly log: Logger;
137
- private readonly q: Query;
138
- private readonly input = new AsyncQueue<SDKUserMessage>();
139
- private readonly toolNames: Set<string>;
140
- private readonly pending: PendingCall[] = [];
141
- /** Assistant content blocks accumulated since the last HTTP response. */
142
- private buffer: WireBlock[] = [];
143
- private readonly seenToolUseIds = new Set<string>();
144
- private waiting: Deferred<BridgeResponse> | null = null;
145
- private waitingTimer: NodeJS.Timeout | null = null;
146
- private requestLen = 0;
147
- private lastUsage: Record<string, unknown> | null = null;
148
- private lastSystem: string;
149
- private pendingContextRefresh: string | null = null;
150
- /** Set after an interrupt: the interrupted turn's late `result` must not answer a new request. */
151
- private discardNextResult = false;
152
-
153
- constructor(request: WireMessagesRequest, log: Logger) {
154
- this.log = log;
155
- this.model = request.model;
156
- this.lastSystem = systemToText(request.system);
157
- const tools = request.tools ?? [];
158
- this.toolNames = new Set(tools.map(tool => tool.name));
159
-
160
- this.q = query({
161
- prompt: this.input,
162
- options: {
163
- model: request.model,
164
- systemPrompt: this.lastSystem || undefined,
165
- // pix3's tools, exposed verbatim (JSON Schema and all) via an in-process MCP server.
166
- mcpServers: {
167
- [MCP_SERVER_NAME]: { type: 'sdk', name: MCP_SERVER_NAME, instance: this.buildMcpServer(tools) },
168
- },
169
- strictMcpConfig: true,
170
- // No built-in tools: the bridge process must never touch the local FS/shell/web on the
171
- // model's behalf — every capability comes from pix3 and executes inside the editor.
172
- tools: [],
173
- allowedTools: tools.map(tool => `${MCP_PREFIX}${tool.name}`),
174
- permissionMode: 'bypassPermissions',
175
- allowDangerouslySkipPermissions: true,
176
- settingSources: [],
177
- persistSession: false,
178
- cwd: os.tmpdir(),
179
- includePartialMessages: false,
180
- stderr: data => {
181
- const line = data.trim();
182
- if (line) this.log(`[${this.id}] cli: ${line.slice(0, 400)}`);
183
- },
184
- },
185
- });
186
- void this.pump();
187
- }
188
-
189
- get busy(): boolean {
190
- return this.waiting !== null;
191
- }
192
-
193
- hasPendingToolUse(toolUseId: string): boolean {
194
- return this.pending.some(call => call.toolUseId === toolUseId);
195
- }
196
-
197
- toolsMatch(tools: readonly WireToolDefinition[] | undefined): boolean {
198
- const names = (tools ?? []).map(tool => tool.name);
199
- return names.length === this.toolNames.size && names.every(name => this.toolNames.has(name));
200
- }
201
-
202
- /** Advance the session with one pix3 request and wait for the next assistant boundary. */
203
- handleRequest(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
204
- if (this.closed) throw new HttpError(409, 'Session already closed.');
205
- if (this.waiting) throw new HttpError(409, 'Session is still processing a previous request.');
206
- this.lastActivity = Date.now();
207
- this.trackSystemDrift(request);
208
-
209
- const last = request.messages[request.messages.length - 1];
210
- const toolResults = extractToolResults(last);
211
- if (toolResults.length > 0) {
212
- this.resolveToolResults(toolResults);
213
- } else {
214
- this.pushUserMessage(toUserContent(last));
215
- }
216
- return this.awaitResponse(request, signal);
217
- }
218
-
219
- /**
220
- * Recovery path: no live session matched, so this fresh session is seeded with a plain-text
221
- * transcript of pix3's history (built by the manager) instead of a real user message.
222
- */
223
- handleTranscriptReplay(
224
- request: WireMessagesRequest,
225
- transcript: string,
226
- signal: AbortSignal
227
- ): Promise<BridgeResponse> {
228
- this.lastActivity = Date.now();
229
- this.pushUserMessage([{ type: 'text', text: transcript }]);
230
- return this.awaitResponse(request, signal);
231
- }
232
-
233
- close(reason: string): void {
234
- if (this.closed) return;
235
- this.closed = true;
236
- this.log(`[${this.id}] closing (${reason})`);
237
- this.cancelPendingCalls('The session was closed.');
238
- this.failWaiting(new HttpError(500, `Claude Code session closed: ${reason}`));
239
- this.input.end();
240
- try {
241
- this.q.close();
242
- } catch {
243
- /* already terminated */
244
- }
245
- }
246
-
247
- // -- request plumbing -------------------------------------------------------
248
-
249
- private awaitResponse(
250
- request: WireMessagesRequest,
251
- signal: AbortSignal
252
- ): Promise<BridgeResponse> {
253
- const waiting = new Deferred<BridgeResponse>();
254
- this.waiting = waiting;
255
- this.requestLen = request.messages.length;
256
- this.waitingTimer = setTimeout(() => {
257
- if (this.waiting === waiting) {
258
- this.waiting = null;
259
- waiting.reject(new HttpError(504, 'Timed out waiting for the model.'));
260
- }
261
- }, RESPONSE_TIMEOUT_MS);
262
- if (signal.aborted) {
263
- this.onHttpAbort(waiting);
264
- } else {
265
- signal.addEventListener('abort', () => this.onHttpAbort(waiting), { once: true });
266
- }
267
- // A tool_use flush may already be satisfiable (parallel calls resolved in a prior tick).
268
- this.tryAssignAndFlush();
269
- return waiting.promise.finally(() => {
270
- if (this.waitingTimer) clearTimeout(this.waitingTimer);
271
- this.waitingTimer = null;
272
- this.lastActivity = Date.now();
273
- });
274
- }
275
-
276
- private pushUserMessage(blocks: WireBlock[]): void {
277
- const content: WireBlock[] = [];
278
- if (this.pendingContextRefresh) {
279
- content.push({
280
- type: 'text',
281
- text: `<context-refresh>\nUpdated live editor context (replaces the earlier snapshot):\n${this.pendingContextRefresh}\n</context-refresh>`,
282
- });
283
- this.pendingContextRefresh = null;
284
- }
285
- content.push(...blocks);
286
- if (content.length === 0) content.push({ type: 'text', text: '(empty message)' });
287
- this.input.push({
288
- type: 'user',
289
- message: { role: 'user', content: content as never },
290
- parent_tool_use_id: null,
291
- });
292
- }
293
-
294
- private resolveToolResults(results: WireToolResult[]): void {
295
- for (const result of results) {
296
- let index = this.pending.findIndex(call => call.toolUseId === result.toolUseId);
297
- if (index < 0) index = this.pending.findIndex(call => call.toolUseId === undefined);
298
- if (index < 0) {
299
- this.log(`[${this.id}] no pending tool call for result ${result.toolUseId} — dropped`);
300
- continue;
301
- }
302
- const [call] = this.pending.splice(index, 1);
303
- call.resolve(toCallToolResult(result));
304
- }
305
- }
306
-
307
- /**
308
- * pix3 saw the `system` field change between requests (the editor appends live scene context to
309
- * a stable head). The SDK session's system prompt is fixed, so the drift is injected into the
310
- * next user message instead.
311
- */
312
- private trackSystemDrift(request: WireMessagesRequest): void {
313
- const system = systemToText(request.system);
314
- if (system === this.lastSystem) return;
315
- let common = 0;
316
- const max = Math.min(system.length, this.lastSystem.length);
317
- while (common < max && system[common] === this.lastSystem[common]) common += 1;
318
- // Back up to a line boundary so the injected snippet starts cleanly.
319
- const lineStart = system.lastIndexOf('\n', common);
320
- const suffix = system.slice(lineStart >= 0 ? lineStart + 1 : 0);
321
- this.pendingContextRefresh = suffix.slice(0, 32_000);
322
- this.lastSystem = system;
323
- }
324
-
325
- private onHttpAbort(waiting: Deferred<BridgeResponse>): void {
326
- if (this.waiting !== waiting) return;
327
- this.log(`[${this.id}] request aborted by client — interrupting`);
328
- this.waiting = null;
329
- waiting.reject(new HttpError(499, 'Request cancelled.'));
330
- this.buffer = [];
331
- this.discardNextResult = true;
332
- this.cancelPendingCalls('Cancelled by the user.');
333
- void this.q.interrupt().catch(() => {});
334
- }
335
-
336
- private cancelPendingCalls(message: string): void {
337
- for (const call of this.pending.splice(0)) {
338
- call.resolve({ content: [{ type: 'text', text: message }], isError: true });
339
- }
340
- }
341
-
342
- private failWaiting(error: unknown): void {
343
- const waiting = this.waiting;
344
- if (!waiting) return;
345
- this.waiting = null;
346
- waiting.reject(error);
347
- }
348
-
349
- // -- SDK message pump -------------------------------------------------------
350
-
351
- private async pump(): Promise<void> {
352
- try {
353
- for await (const message of this.q) {
354
- if (message.type === 'assistant' && !message.parent_tool_use_id) {
355
- this.onAssistantMessage(message.message as unknown as Record<string, unknown>);
356
- } else if (message.type === 'result') {
357
- this.onResult(message as unknown as Record<string, unknown>);
358
- } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'init') {
359
- this.log(`[${this.id}] Claude Code session ${(message as { session_id?: string }).session_id ?? '?'} (${this.model})`);
360
- }
361
- }
362
- if (!this.closed) this.close('CLI stream ended');
363
- } catch (error) {
364
- this.log(`[${this.id}] pump error: ${error instanceof Error ? error.message : String(error)}`);
365
- if (!this.closed) {
366
- this.closed = true;
367
- this.cancelPendingCalls('The session crashed.');
368
- this.failWaiting(new HttpError(502, `Claude Code error: ${error instanceof Error ? error.message : String(error)}`));
369
- }
370
- }
371
- }
372
-
373
- private onAssistantMessage(message: Record<string, unknown>): void {
374
- const content = Array.isArray(message.content) ? (message.content as WireBlock[]) : [];
375
- for (const block of content) {
376
- if (block.type === 'tool_use') {
377
- const id = typeof block.id === 'string' ? block.id : '';
378
- if (this.seenToolUseIds.has(id)) continue;
379
- this.seenToolUseIds.add(id);
380
- }
381
- this.buffer.push(block);
382
- }
383
- if (isRecord(message.usage)) this.lastUsage = message.usage;
384
- this.tryAssignAndFlush();
385
- }
386
-
387
- private onResult(result: Record<string, unknown>): void {
388
- if (this.discardNextResult) {
389
- this.discardNextResult = false;
390
- this.buffer = [];
391
- return;
392
- }
393
- if (result.subtype !== 'success') {
394
- const errors = Array.isArray(result.errors) ? result.errors.join('; ') : String(result.subtype);
395
- this.failWaiting(new HttpError(502, `Claude Code stopped: ${errors}`));
396
- this.buffer = [];
397
- return;
398
- }
399
- if (!this.waiting) {
400
- this.buffer = [];
401
- return;
402
- }
403
- if (this.buffer.length === 0 && typeof result.result === 'string' && result.result) {
404
- this.buffer.push({ type: 'text', text: result.result });
405
- }
406
- const stop = result.stop_reason === 'max_tokens' || result.stop_reason === 'refusal'
407
- ? (result.stop_reason as string)
408
- : 'end_turn';
409
- this.respond(stop);
410
- }
411
-
412
- /** Handler for the in-process MCP server: park the call until pix3 posts its tool_result. */
413
- private onToolCall(name: string, input: unknown): Promise<CallToolResult> {
414
- this.log(`[${this.id}] tool call: ${name}`);
415
- return new Promise<CallToolResult>(resolve => {
416
- this.pending.push({ name, input, resolve });
417
- this.tryAssignAndFlush();
418
- });
419
- }
420
-
421
- /**
422
- * Match parked MCP calls to the `tool_use` blocks buffered from the assistant message (by name +
423
- * identical input, falling back to first-with-same-name), then — if an HTTP request is waiting
424
- * and every buffered pix3 tool_use has its parked call — answer it with `stop_reason: tool_use`.
425
- */
426
- private tryAssignAndFlush(): void {
427
- const toolUseBlocks = this.buffer.filter(
428
- block => block.type === 'tool_use' && toolUseName(block).startsWith(MCP_PREFIX)
429
- );
430
- const assigned = new Set(
431
- this.pending.map(call => call.toolUseId).filter((id): id is string => id !== undefined)
432
- );
433
- for (const call of this.pending) {
434
- if (call.toolUseId) continue;
435
- const candidates = toolUseBlocks.filter(
436
- block =>
437
- !assigned.has(String(block.id)) && stripPrefix(toolUseName(block)) === call.name
438
- );
439
- const match = candidates.find(block => sameJson(block.input ?? {}, call.input)) ?? candidates[0];
440
- if (match) {
441
- call.toolUseId = String(match.id);
442
- assigned.add(call.toolUseId);
443
- }
444
- }
445
- if (!this.waiting || toolUseBlocks.length === 0) return;
446
- const allParked = toolUseBlocks.every(block =>
447
- this.pending.some(call => call.toolUseId === String(block.id))
448
- );
449
- if (allParked) this.respond('tool_use');
450
- }
451
-
452
- private respond(stopReason: string): void {
453
- const waiting = this.waiting;
454
- if (!waiting) {
455
- this.buffer = [];
456
- return;
457
- }
458
- const content = this.buffer
459
- .filter(
460
- block =>
461
- block.type === 'text' ||
462
- (block.type === 'tool_use' && toolUseName(block).startsWith(MCP_PREFIX))
463
- )
464
- .map(block =>
465
- block.type === 'tool_use'
466
- ? { ...block, name: stripPrefix(toolUseName(block)) }
467
- : block
468
- );
469
- this.buffer = [];
470
- this.waiting = null;
471
- this.transcriptLen = this.requestLen + 1;
472
- waiting.resolve({
473
- status: 200,
474
- body: {
475
- id: `msg_bridge_${randomUUID().replace(/-/g, '').slice(0, 24)}`,
476
- type: 'message',
477
- role: 'assistant',
478
- model: this.model,
479
- content,
480
- stop_reason: stopReason,
481
- usage: this.lastUsage ?? {},
482
- },
483
- });
484
- }
485
-
486
- private buildMcpServer(tools: readonly WireToolDefinition[]): McpServer {
487
- const server = new McpServer(
488
- { name: MCP_SERVER_NAME, version: '1.0.0' },
489
- { capabilities: { tools: {} } }
490
- );
491
- server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
492
- tools: tools.map(tool => ({
493
- name: tool.name,
494
- description: tool.description,
495
- inputSchema: (isRecord(tool.input_schema) && typeof tool.input_schema.type === 'string'
496
- ? tool.input_schema
497
- : { type: 'object', ...tool.input_schema }) as { type: 'object' },
498
- _meta: { 'anthropic/alwaysLoad': true },
499
- })),
500
- }));
501
- server.server.setRequestHandler(CallToolRequestSchema, async request =>
502
- this.onToolCall(request.params.name, request.params.arguments ?? {})
503
- );
504
- return server;
505
- }
506
- }
507
-
508
- export class SessionManager {
509
- private readonly log: Logger;
510
- private sessions: BridgeSession[] = [];
511
- private readonly sweeper: NodeJS.Timeout;
512
-
513
- constructor(log: Logger) {
514
- this.log = log;
515
- this.sweeper = setInterval(() => this.sweep(), 60_000);
516
- this.sweeper.unref();
517
- }
518
-
519
- async handle(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
520
- this.sessions = this.sessions.filter(session => !session.closed);
521
- const last = request.messages[request.messages.length - 1];
522
- const toolResults = extractToolResults(last);
523
-
524
- if (toolResults.length > 0) {
525
- const session = this.sessions.find(candidate =>
526
- toolResults.every(result => candidate.hasPendingToolUse(result.toolUseId))
527
- );
528
- if (session) return session.handleRequest(request, signal);
529
- this.log('tool results with no live session (bridge restarted?) — replaying transcript');
530
- return this.replay(request, signal);
531
- }
532
-
533
- if (request.messages.length > 1) {
534
- const session = this.sessions.find(
535
- candidate =>
536
- !candidate.busy &&
537
- candidate.transcriptLen === request.messages.length - 1 &&
538
- candidate.model === request.model &&
539
- candidate.toolsMatch(request.tools)
540
- );
541
- if (session) return session.handleRequest(request, signal);
542
- this.log(
543
- `no session matches a ${request.messages.length}-message history (restart/edit/model switch) — replaying transcript`
544
- );
545
- return this.replay(request, signal);
546
- }
547
-
548
- const session = this.create(request);
549
- this.log(`[${session.id}] new session (${request.model}, ${request.tools?.length ?? 0} tools)`);
550
- return session.handleRequest(request, signal);
551
- }
552
-
553
- closeAll(reason: string): void {
554
- clearInterval(this.sweeper);
555
- for (const session of this.sessions.splice(0)) session.close(reason);
556
- }
557
-
558
- private replay(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
559
- const session = this.create(request);
560
- const transcript = [
561
- 'The conversation below already happened in the pix3 editor (the previous bridge session was lost).',
562
- 'Continue it from where it left off — do not re-introduce yourself or redo completed work.',
563
- '',
564
- '<conversation-replay>',
565
- renderTranscript(request.messages),
566
- '</conversation-replay>',
567
- '',
568
- 'Respond to the last entry above.',
569
- ].join('\n');
570
- this.log(`[${session.id}] replay session (${request.messages.length} messages)`);
571
- return session.handleTranscriptReplay(request, transcript, signal);
572
- }
573
-
574
- private create(request: WireMessagesRequest): BridgeSession {
575
- while (this.sessions.length >= MAX_SESSIONS) {
576
- const idle = this.sessions.filter(session => !session.busy);
577
- const victim = (idle.length > 0 ? idle : this.sessions).reduce((a, b) =>
578
- a.lastActivity <= b.lastActivity ? a : b
579
- );
580
- this.sessions = this.sessions.filter(session => session !== victim);
581
- victim.close('evicted (too many sessions)');
582
- }
583
- const session = new BridgeSession(request, this.log);
584
- this.sessions.push(session);
585
- return session;
586
- }
587
-
588
- private sweep(): void {
589
- const now = Date.now();
590
- for (const session of [...this.sessions]) {
591
- if (session.closed || (!session.busy && now - session.lastActivity > IDLE_TIMEOUT_MS)) {
592
- this.sessions = this.sessions.filter(candidate => candidate !== session);
593
- if (!session.closed) session.close('idle timeout');
594
- }
595
- }
596
- }
597
- }
1
+ /**
2
+ * Harness inversion: exposes the stateless Anthropic Messages protocol (which pix3's agent loop
3
+ * speaks) on top of stateful Claude Agent SDK sessions (the sanctioned consumer of a Claude
4
+ * Code / MAX-subscription login).
5
+ *
6
+ * How one pix3 request round-trips:
7
+ *
8
+ * 1. pix3 POSTs `/v1/messages` (system + tools + full history). The manager routes it to a live
9
+ * session (or starts one). New trailing user message -> pushed into the SDK's streaming input.
10
+ * 2. The SDK calls the model. pix3's tools are registered as an in-process MCP server whose
11
+ * CallTool handler BLOCKS: when the model requests a tool, the assistant message (with
12
+ * `tool_use` blocks) is returned as the HTTP response and the handler's promise stays pending.
13
+ * 3. pix3 executes the tool in the editor and POSTs again with `tool_result` blocks. Those
14
+ * resolve the blocked handlers, the SDK forwards the results to the model, and the next
15
+ * assistant message answers this HTTP request. A turn with no tool calls ends with the SDK's
16
+ * `result` message -> `stop_reason: "end_turn"`.
17
+ *
18
+ * The real conversation state lives in the SDK session; pix3's replayed history is used only for
19
+ * routing/correlation. When nothing matches (bridge restart, edited history, model switch) the
20
+ * manager degrades gracefully: it starts a fresh session whose first user message is a plain-text
21
+ * transcript of the history.
22
+ */
23
+
24
+ import { randomUUID } from 'node:crypto';
25
+ import os from 'node:os';
26
+
27
+ import { query } from '@anthropic-ai/claude-agent-sdk';
28
+ import type { Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
29
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
30
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
31
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
32
+
33
+ import {
34
+ HttpError,
35
+ extractToolResults,
36
+ isRecord,
37
+ renderTranscript,
38
+ systemToText,
39
+ toUserContent,
40
+ } from './wire.ts';
41
+ import type { WireBlock, WireMessagesRequest, WireToolDefinition, WireToolResult } from './wire.ts';
42
+
43
+ const MCP_SERVER_NAME = 'pix3';
44
+ const MCP_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
45
+ /** Hard cap on how long one `/v1/messages` request may wait for the model. */
46
+ const RESPONSE_TIMEOUT_MS = 20 * 60 * 1000;
47
+ const IDLE_TIMEOUT_MS = 45 * 60 * 1000;
48
+ const MAX_SESSIONS = 4;
49
+
50
+ export type Logger = (line: string) => void;
51
+
52
+ interface PendingCall {
53
+ readonly name: string;
54
+ readonly input: unknown;
55
+ resolve: (result: CallToolResult) => void;
56
+ /** Anthropic `tool_use` block id this call was matched to (assigned from the assistant message). */
57
+ toolUseId?: string;
58
+ }
59
+
60
+ class Deferred<T> {
61
+ readonly promise: Promise<T>;
62
+ resolve!: (value: T) => void;
63
+ reject!: (error: unknown) => void;
64
+ constructor() {
65
+ this.promise = new Promise<T>((resolve, reject) => {
66
+ this.resolve = resolve;
67
+ this.reject = reject;
68
+ });
69
+ }
70
+ }
71
+
72
+ /** Push-based async iterable used as the SDK's streaming input. */
73
+ class AsyncQueue<T> implements AsyncIterable<T> {
74
+ private readonly buffer: T[] = [];
75
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = [];
76
+ private ended = false;
77
+
78
+ push(item: T): void {
79
+ const waiter = this.waiters.shift();
80
+ if (waiter) waiter({ value: item, done: false });
81
+ else this.buffer.push(item);
82
+ }
83
+
84
+ end(): void {
85
+ this.ended = true;
86
+ for (const waiter of this.waiters.splice(0)) {
87
+ waiter({ value: undefined as never, done: true });
88
+ }
89
+ }
90
+
91
+ [Symbol.asyncIterator](): AsyncIterator<T> {
92
+ return {
93
+ next: (): Promise<IteratorResult<T>> => {
94
+ if (this.buffer.length > 0) {
95
+ return Promise.resolve({ value: this.buffer.shift() as T, done: false });
96
+ }
97
+ if (this.ended) return Promise.resolve({ value: undefined as never, done: true });
98
+ return new Promise(resolve => this.waiters.push(resolve));
99
+ },
100
+ };
101
+ }
102
+ }
103
+
104
+ const toolUseName = (block: WireBlock): string =>
105
+ typeof block.name === 'string' ? block.name : '';
106
+
107
+ const stripPrefix = (name: string): string =>
108
+ name.startsWith(MCP_PREFIX) ? name.slice(MCP_PREFIX.length) : name;
109
+
110
+ const sameJson = (a: unknown, b: unknown): boolean => {
111
+ try {
112
+ return JSON.stringify(a) === JSON.stringify(b);
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+
118
+ const toCallToolResult = (result: WireToolResult): CallToolResult => ({
119
+ content: [{ type: 'text', text: result.content || '(empty result)' }],
120
+ ...(result.isError ? { isError: true } : {}),
121
+ });
122
+
123
+ export interface BridgeResponse {
124
+ readonly status: number;
125
+ readonly body: Record<string, unknown>;
126
+ }
127
+
128
+ export class BridgeSession {
129
+ readonly id = randomUUID().slice(0, 8);
130
+ model: string;
131
+ /** Expected `messages.length` of pix3's NEXT request if this chat simply continues. */
132
+ transcriptLen = 0;
133
+ lastActivity = Date.now();
134
+ closed = false;
135
+
136
+ private readonly log: Logger;
137
+ private readonly q: Query;
138
+ private readonly input = new AsyncQueue<SDKUserMessage>();
139
+ private readonly toolNames: Set<string>;
140
+ private readonly pending: PendingCall[] = [];
141
+ /** Assistant content blocks accumulated since the last HTTP response. */
142
+ private buffer: WireBlock[] = [];
143
+ private readonly seenToolUseIds = new Set<string>();
144
+ private waiting: Deferred<BridgeResponse> | null = null;
145
+ private waitingTimer: NodeJS.Timeout | null = null;
146
+ private requestLen = 0;
147
+ private lastUsage: Record<string, unknown> | null = null;
148
+ private lastSystem: string;
149
+ private pendingContextRefresh: string | null = null;
150
+ /** Set after an interrupt: the interrupted turn's late `result` must not answer a new request. */
151
+ private discardNextResult = false;
152
+
153
+ constructor(request: WireMessagesRequest, log: Logger) {
154
+ this.log = log;
155
+ this.model = request.model;
156
+ this.lastSystem = systemToText(request.system);
157
+ const tools = request.tools ?? [];
158
+ this.toolNames = new Set(tools.map(tool => tool.name));
159
+
160
+ this.q = query({
161
+ prompt: this.input,
162
+ options: {
163
+ model: request.model,
164
+ systemPrompt: this.lastSystem || undefined,
165
+ // pix3's tools, exposed verbatim (JSON Schema and all) via an in-process MCP server.
166
+ mcpServers: {
167
+ [MCP_SERVER_NAME]: { type: 'sdk', name: MCP_SERVER_NAME, instance: this.buildMcpServer(tools) },
168
+ },
169
+ strictMcpConfig: true,
170
+ // No built-in tools: the bridge process must never touch the local FS/shell/web on the
171
+ // model's behalf — every capability comes from pix3 and executes inside the editor.
172
+ tools: [],
173
+ allowedTools: tools.map(tool => `${MCP_PREFIX}${tool.name}`),
174
+ permissionMode: 'bypassPermissions',
175
+ allowDangerouslySkipPermissions: true,
176
+ settingSources: [],
177
+ persistSession: false,
178
+ cwd: os.tmpdir(),
179
+ includePartialMessages: false,
180
+ stderr: data => {
181
+ const line = data.trim();
182
+ if (line) this.log(`[${this.id}] cli: ${line.slice(0, 400)}`);
183
+ },
184
+ },
185
+ });
186
+ void this.pump();
187
+ }
188
+
189
+ get busy(): boolean {
190
+ return this.waiting !== null;
191
+ }
192
+
193
+ hasPendingToolUse(toolUseId: string): boolean {
194
+ return this.pending.some(call => call.toolUseId === toolUseId);
195
+ }
196
+
197
+ toolsMatch(tools: readonly WireToolDefinition[] | undefined): boolean {
198
+ const names = (tools ?? []).map(tool => tool.name);
199
+ return names.length === this.toolNames.size && names.every(name => this.toolNames.has(name));
200
+ }
201
+
202
+ /** Advance the session with one pix3 request and wait for the next assistant boundary. */
203
+ handleRequest(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
204
+ if (this.closed) throw new HttpError(409, 'Session already closed.');
205
+ if (this.waiting) throw new HttpError(409, 'Session is still processing a previous request.');
206
+ this.lastActivity = Date.now();
207
+ this.trackSystemDrift(request);
208
+
209
+ const last = request.messages[request.messages.length - 1];
210
+ const toolResults = extractToolResults(last);
211
+ if (toolResults.length > 0) {
212
+ this.resolveToolResults(toolResults);
213
+ } else {
214
+ this.pushUserMessage(toUserContent(last));
215
+ }
216
+ return this.awaitResponse(request, signal);
217
+ }
218
+
219
+ /**
220
+ * Recovery path: no live session matched, so this fresh session is seeded with a plain-text
221
+ * transcript of pix3's history (built by the manager) instead of a real user message.
222
+ */
223
+ handleTranscriptReplay(
224
+ request: WireMessagesRequest,
225
+ transcript: string,
226
+ signal: AbortSignal
227
+ ): Promise<BridgeResponse> {
228
+ this.lastActivity = Date.now();
229
+ this.pushUserMessage([{ type: 'text', text: transcript }]);
230
+ return this.awaitResponse(request, signal);
231
+ }
232
+
233
+ close(reason: string): void {
234
+ if (this.closed) return;
235
+ this.closed = true;
236
+ this.log(`[${this.id}] closing (${reason})`);
237
+ this.cancelPendingCalls('The session was closed.');
238
+ this.failWaiting(new HttpError(500, `Claude Code session closed: ${reason}`));
239
+ this.input.end();
240
+ try {
241
+ this.q.close();
242
+ } catch {
243
+ /* already terminated */
244
+ }
245
+ }
246
+
247
+ // -- request plumbing -------------------------------------------------------
248
+
249
+ private awaitResponse(
250
+ request: WireMessagesRequest,
251
+ signal: AbortSignal
252
+ ): Promise<BridgeResponse> {
253
+ const waiting = new Deferred<BridgeResponse>();
254
+ this.waiting = waiting;
255
+ this.requestLen = request.messages.length;
256
+ this.waitingTimer = setTimeout(() => {
257
+ if (this.waiting === waiting) {
258
+ this.waiting = null;
259
+ waiting.reject(new HttpError(504, 'Timed out waiting for the model.'));
260
+ }
261
+ }, RESPONSE_TIMEOUT_MS);
262
+ if (signal.aborted) {
263
+ this.onHttpAbort(waiting);
264
+ } else {
265
+ signal.addEventListener('abort', () => this.onHttpAbort(waiting), { once: true });
266
+ }
267
+ // A tool_use flush may already be satisfiable (parallel calls resolved in a prior tick).
268
+ this.tryAssignAndFlush();
269
+ return waiting.promise.finally(() => {
270
+ if (this.waitingTimer) clearTimeout(this.waitingTimer);
271
+ this.waitingTimer = null;
272
+ this.lastActivity = Date.now();
273
+ });
274
+ }
275
+
276
+ private pushUserMessage(blocks: WireBlock[]): void {
277
+ const content: WireBlock[] = [];
278
+ if (this.pendingContextRefresh) {
279
+ content.push({
280
+ type: 'text',
281
+ text: `<context-refresh>\nUpdated live editor context (replaces the earlier snapshot):\n${this.pendingContextRefresh}\n</context-refresh>`,
282
+ });
283
+ this.pendingContextRefresh = null;
284
+ }
285
+ content.push(...blocks);
286
+ if (content.length === 0) content.push({ type: 'text', text: '(empty message)' });
287
+ this.input.push({
288
+ type: 'user',
289
+ message: { role: 'user', content: content as never },
290
+ parent_tool_use_id: null,
291
+ });
292
+ }
293
+
294
+ private resolveToolResults(results: WireToolResult[]): void {
295
+ for (const result of results) {
296
+ let index = this.pending.findIndex(call => call.toolUseId === result.toolUseId);
297
+ if (index < 0) index = this.pending.findIndex(call => call.toolUseId === undefined);
298
+ if (index < 0) {
299
+ this.log(`[${this.id}] no pending tool call for result ${result.toolUseId} — dropped`);
300
+ continue;
301
+ }
302
+ const [call] = this.pending.splice(index, 1);
303
+ call.resolve(toCallToolResult(result));
304
+ }
305
+ }
306
+
307
+ /**
308
+ * pix3 saw the `system` field change between requests (the editor appends live scene context to
309
+ * a stable head). The SDK session's system prompt is fixed, so the drift is injected into the
310
+ * next user message instead.
311
+ */
312
+ private trackSystemDrift(request: WireMessagesRequest): void {
313
+ const system = systemToText(request.system);
314
+ if (system === this.lastSystem) return;
315
+ let common = 0;
316
+ const max = Math.min(system.length, this.lastSystem.length);
317
+ while (common < max && system[common] === this.lastSystem[common]) common += 1;
318
+ // Back up to a line boundary so the injected snippet starts cleanly.
319
+ const lineStart = system.lastIndexOf('\n', common);
320
+ const suffix = system.slice(lineStart >= 0 ? lineStart + 1 : 0);
321
+ this.pendingContextRefresh = suffix.slice(0, 32_000);
322
+ this.lastSystem = system;
323
+ }
324
+
325
+ private onHttpAbort(waiting: Deferred<BridgeResponse>): void {
326
+ if (this.waiting !== waiting) return;
327
+ this.log(`[${this.id}] request aborted by client — interrupting`);
328
+ this.waiting = null;
329
+ waiting.reject(new HttpError(499, 'Request cancelled.'));
330
+ this.buffer = [];
331
+ this.discardNextResult = true;
332
+ this.cancelPendingCalls('Cancelled by the user.');
333
+ void this.q.interrupt().catch(() => {});
334
+ }
335
+
336
+ private cancelPendingCalls(message: string): void {
337
+ for (const call of this.pending.splice(0)) {
338
+ call.resolve({ content: [{ type: 'text', text: message }], isError: true });
339
+ }
340
+ }
341
+
342
+ private failWaiting(error: unknown): void {
343
+ const waiting = this.waiting;
344
+ if (!waiting) return;
345
+ this.waiting = null;
346
+ waiting.reject(error);
347
+ }
348
+
349
+ // -- SDK message pump -------------------------------------------------------
350
+
351
+ private async pump(): Promise<void> {
352
+ try {
353
+ for await (const message of this.q) {
354
+ if (message.type === 'assistant' && !message.parent_tool_use_id) {
355
+ this.onAssistantMessage(message.message as unknown as Record<string, unknown>);
356
+ } else if (message.type === 'result') {
357
+ this.onResult(message as unknown as Record<string, unknown>);
358
+ } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'init') {
359
+ this.log(`[${this.id}] Claude Code session ${(message as { session_id?: string }).session_id ?? '?'} (${this.model})`);
360
+ }
361
+ }
362
+ if (!this.closed) this.close('CLI stream ended');
363
+ } catch (error) {
364
+ this.log(`[${this.id}] pump error: ${error instanceof Error ? error.message : String(error)}`);
365
+ if (!this.closed) {
366
+ this.closed = true;
367
+ this.cancelPendingCalls('The session crashed.');
368
+ this.failWaiting(new HttpError(502, `Claude Code error: ${error instanceof Error ? error.message : String(error)}`));
369
+ }
370
+ }
371
+ }
372
+
373
+ private onAssistantMessage(message: Record<string, unknown>): void {
374
+ const content = Array.isArray(message.content) ? (message.content as WireBlock[]) : [];
375
+ for (const block of content) {
376
+ if (block.type === 'tool_use') {
377
+ const id = typeof block.id === 'string' ? block.id : '';
378
+ if (this.seenToolUseIds.has(id)) continue;
379
+ this.seenToolUseIds.add(id);
380
+ }
381
+ this.buffer.push(block);
382
+ }
383
+ if (isRecord(message.usage)) this.lastUsage = message.usage;
384
+ this.tryAssignAndFlush();
385
+ }
386
+
387
+ private onResult(result: Record<string, unknown>): void {
388
+ if (this.discardNextResult) {
389
+ this.discardNextResult = false;
390
+ this.buffer = [];
391
+ return;
392
+ }
393
+ if (result.subtype !== 'success') {
394
+ const errors = Array.isArray(result.errors) ? result.errors.join('; ') : String(result.subtype);
395
+ this.failWaiting(new HttpError(502, `Claude Code stopped: ${errors}`));
396
+ this.buffer = [];
397
+ return;
398
+ }
399
+ if (!this.waiting) {
400
+ this.buffer = [];
401
+ return;
402
+ }
403
+ if (this.buffer.length === 0 && typeof result.result === 'string' && result.result) {
404
+ this.buffer.push({ type: 'text', text: result.result });
405
+ }
406
+ const stop = result.stop_reason === 'max_tokens' || result.stop_reason === 'refusal'
407
+ ? (result.stop_reason as string)
408
+ : 'end_turn';
409
+ this.respond(stop);
410
+ }
411
+
412
+ /** Handler for the in-process MCP server: park the call until pix3 posts its tool_result. */
413
+ private onToolCall(name: string, input: unknown): Promise<CallToolResult> {
414
+ this.log(`[${this.id}] tool call: ${name}`);
415
+ return new Promise<CallToolResult>(resolve => {
416
+ this.pending.push({ name, input, resolve });
417
+ this.tryAssignAndFlush();
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Match parked MCP calls to the `tool_use` blocks buffered from the assistant message (by name +
423
+ * identical input, falling back to first-with-same-name), then — if an HTTP request is waiting
424
+ * and every buffered pix3 tool_use has its parked call — answer it with `stop_reason: tool_use`.
425
+ */
426
+ private tryAssignAndFlush(): void {
427
+ const toolUseBlocks = this.buffer.filter(
428
+ block => block.type === 'tool_use' && toolUseName(block).startsWith(MCP_PREFIX)
429
+ );
430
+ const assigned = new Set(
431
+ this.pending.map(call => call.toolUseId).filter((id): id is string => id !== undefined)
432
+ );
433
+ for (const call of this.pending) {
434
+ if (call.toolUseId) continue;
435
+ const candidates = toolUseBlocks.filter(
436
+ block =>
437
+ !assigned.has(String(block.id)) && stripPrefix(toolUseName(block)) === call.name
438
+ );
439
+ const match = candidates.find(block => sameJson(block.input ?? {}, call.input)) ?? candidates[0];
440
+ if (match) {
441
+ call.toolUseId = String(match.id);
442
+ assigned.add(call.toolUseId);
443
+ }
444
+ }
445
+ if (!this.waiting || toolUseBlocks.length === 0) return;
446
+ const allParked = toolUseBlocks.every(block =>
447
+ this.pending.some(call => call.toolUseId === String(block.id))
448
+ );
449
+ if (allParked) this.respond('tool_use');
450
+ }
451
+
452
+ private respond(stopReason: string): void {
453
+ const waiting = this.waiting;
454
+ if (!waiting) {
455
+ this.buffer = [];
456
+ return;
457
+ }
458
+ const content = this.buffer
459
+ .filter(
460
+ block =>
461
+ block.type === 'text' ||
462
+ (block.type === 'tool_use' && toolUseName(block).startsWith(MCP_PREFIX))
463
+ )
464
+ .map(block =>
465
+ block.type === 'tool_use'
466
+ ? { ...block, name: stripPrefix(toolUseName(block)) }
467
+ : block
468
+ );
469
+ this.buffer = [];
470
+ this.waiting = null;
471
+ this.transcriptLen = this.requestLen + 1;
472
+ waiting.resolve({
473
+ status: 200,
474
+ body: {
475
+ id: `msg_bridge_${randomUUID().replace(/-/g, '').slice(0, 24)}`,
476
+ type: 'message',
477
+ role: 'assistant',
478
+ model: this.model,
479
+ content,
480
+ stop_reason: stopReason,
481
+ usage: this.lastUsage ?? {},
482
+ },
483
+ });
484
+ }
485
+
486
+ private buildMcpServer(tools: readonly WireToolDefinition[]): McpServer {
487
+ const server = new McpServer(
488
+ { name: MCP_SERVER_NAME, version: '1.0.0' },
489
+ { capabilities: { tools: {} } }
490
+ );
491
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
492
+ tools: tools.map(tool => ({
493
+ name: tool.name,
494
+ description: tool.description,
495
+ inputSchema: (isRecord(tool.input_schema) && typeof tool.input_schema.type === 'string'
496
+ ? tool.input_schema
497
+ : { type: 'object', ...tool.input_schema }) as { type: 'object' },
498
+ _meta: { 'anthropic/alwaysLoad': true },
499
+ })),
500
+ }));
501
+ server.server.setRequestHandler(CallToolRequestSchema, async request =>
502
+ this.onToolCall(request.params.name, request.params.arguments ?? {})
503
+ );
504
+ return server;
505
+ }
506
+ }
507
+
508
+ export class SessionManager {
509
+ private readonly log: Logger;
510
+ private sessions: BridgeSession[] = [];
511
+ private readonly sweeper: NodeJS.Timeout;
512
+
513
+ constructor(log: Logger) {
514
+ this.log = log;
515
+ this.sweeper = setInterval(() => this.sweep(), 60_000);
516
+ this.sweeper.unref();
517
+ }
518
+
519
+ async handle(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
520
+ this.sessions = this.sessions.filter(session => !session.closed);
521
+ const last = request.messages[request.messages.length - 1];
522
+ const toolResults = extractToolResults(last);
523
+
524
+ if (toolResults.length > 0) {
525
+ const session = this.sessions.find(candidate =>
526
+ toolResults.every(result => candidate.hasPendingToolUse(result.toolUseId))
527
+ );
528
+ if (session) return session.handleRequest(request, signal);
529
+ this.log('tool results with no live session (bridge restarted?) — replaying transcript');
530
+ return this.replay(request, signal);
531
+ }
532
+
533
+ if (request.messages.length > 1) {
534
+ const session = this.sessions.find(
535
+ candidate =>
536
+ !candidate.busy &&
537
+ candidate.transcriptLen === request.messages.length - 1 &&
538
+ candidate.model === request.model &&
539
+ candidate.toolsMatch(request.tools)
540
+ );
541
+ if (session) return session.handleRequest(request, signal);
542
+ this.log(
543
+ `no session matches a ${request.messages.length}-message history (restart/edit/model switch) — replaying transcript`
544
+ );
545
+ return this.replay(request, signal);
546
+ }
547
+
548
+ const session = this.create(request);
549
+ this.log(`[${session.id}] new session (${request.model}, ${request.tools?.length ?? 0} tools)`);
550
+ return session.handleRequest(request, signal);
551
+ }
552
+
553
+ closeAll(reason: string): void {
554
+ clearInterval(this.sweeper);
555
+ for (const session of this.sessions.splice(0)) session.close(reason);
556
+ }
557
+
558
+ private replay(request: WireMessagesRequest, signal: AbortSignal): Promise<BridgeResponse> {
559
+ const session = this.create(request);
560
+ const transcript = [
561
+ 'The conversation below already happened in the pix3 editor (the previous bridge session was lost).',
562
+ 'Continue it from where it left off — do not re-introduce yourself or redo completed work.',
563
+ '',
564
+ '<conversation-replay>',
565
+ renderTranscript(request.messages),
566
+ '</conversation-replay>',
567
+ '',
568
+ 'Respond to the last entry above.',
569
+ ].join('\n');
570
+ this.log(`[${session.id}] replay session (${request.messages.length} messages)`);
571
+ return session.handleTranscriptReplay(request, transcript, signal);
572
+ }
573
+
574
+ private create(request: WireMessagesRequest): BridgeSession {
575
+ while (this.sessions.length >= MAX_SESSIONS) {
576
+ const idle = this.sessions.filter(session => !session.busy);
577
+ const victim = (idle.length > 0 ? idle : this.sessions).reduce((a, b) =>
578
+ a.lastActivity <= b.lastActivity ? a : b
579
+ );
580
+ this.sessions = this.sessions.filter(session => session !== victim);
581
+ victim.close('evicted (too many sessions)');
582
+ }
583
+ const session = new BridgeSession(request, this.log);
584
+ this.sessions.push(session);
585
+ return session;
586
+ }
587
+
588
+ private sweep(): void {
589
+ const now = Date.now();
590
+ for (const session of [...this.sessions]) {
591
+ if (session.closed || (!session.busy && now - session.lastActivity > IDLE_TIMEOUT_MS)) {
592
+ this.sessions = this.sessions.filter(candidate => candidate !== session);
593
+ if (!session.closed) session.close('idle timeout');
594
+ }
595
+ }
596
+ }
597
+ }