@dmsdc-ai/aigentry-deliberation 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dmsdc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @dmsdc-ai/aigentry-deliberation
2
+
3
+ MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles.
4
+
5
+ ## Features
6
+
7
+ - **Multi-session** parallel deliberation support
8
+ - **Smart speaker ordering**: cyclic, random, weighted-random strategies
9
+ - **Persona roles**: critic, implementer, mediator, researcher, free — with prompt templates
10
+ - **Vote parsing**: [AGREE] / [DISAGREE] / [CONDITIONAL] extraction
11
+ - **Browser LLM integration**: CDP-based auto-turn for ChatGPT, Claude, Gemini browser tabs
12
+ - **Chrome Extension support**: Side panel detection via title-based matching
13
+ - **Cross-platform**: macOS (tmux + Terminal.app), Windows (Windows Terminal), Linux
14
+ - **Obsidian archiving**: Auto-archive deliberation results to Obsidian vault
15
+ - **Session monitoring**: Real-time tmux/terminal monitoring
16
+
17
+ ## Installation
18
+
19
+ ### As standalone MCP server
20
+
21
+ ```bash
22
+ npm install -g @dmsdc-ai/aigentry-deliberation
23
+ ```
24
+
25
+ ### Via aigentry-devkit
26
+
27
+ ```bash
28
+ npx @dmsdc-ai/aigentry-devkit setup
29
+ ```
30
+
31
+ ### Manual
32
+
33
+ ```bash
34
+ git clone https://github.com/dmsdc-ai/aigentry-deliberation.git
35
+ cd aigentry-deliberation
36
+ npm install
37
+ ```
38
+
39
+ ## MCP Configuration
40
+
41
+ Add to `~/.claude/.mcp.json`:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "deliberation": {
47
+ "command": "node",
48
+ "args": ["/path/to/aigentry-deliberation/index.js"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## MCP Tools
55
+
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `deliberation_start` | Start a new deliberation session |
59
+ | `deliberation_respond` | Submit a speaker's response |
60
+ | `deliberation_synthesize` | Generate synthesis report |
61
+ | `deliberation_status` | Check session status |
62
+ | `deliberation_context` | Load project context |
63
+ | `deliberation_history` | View discussion history |
64
+ | `deliberation_list_active` | List active sessions |
65
+ | `deliberation_list` | List archived sessions |
66
+ | `deliberation_reset` | Reset session(s) |
67
+ | `deliberation_speaker_candidates` | List available speakers |
68
+ | `deliberation_browser_llm_tabs` | List browser LLM tabs |
69
+ | `deliberation_browser_auto_turn` | Auto-send turn to browser LLM |
70
+ | `deliberation_route_turn` | Route turn to appropriate transport |
71
+ | `deliberation_request_review` | Request code review |
72
+ | `deliberation_cli_config` | Configure CLI settings |
73
+
74
+ ## Speaker Ordering Strategies
75
+
76
+ | Strategy | Description |
77
+ |----------|-------------|
78
+ | `cyclic` | Sequential round-robin (default) |
79
+ | `random` | Random selection each turn |
80
+ | `weighted-random` | Less-spoken speakers prioritized |
81
+
82
+ ## Persona Roles
83
+
84
+ | Role | Focus |
85
+ |------|-------|
86
+ | `critic` | Risk analysis, weaknesses, counterarguments |
87
+ | `implementer` | Technical feasibility, code design |
88
+ | `mediator` | Consensus building, synthesis |
89
+ | `researcher` | Data, benchmarks, references |
90
+ | `free` | No role constraint (default) |
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,563 @@
1
+ /**
2
+ * BrowserControlPort — Abstract interface + Chrome DevTools MCP adapter
3
+ *
4
+ * Deliberation 합의 스펙:
5
+ * - 6 메서드: attach, sendTurn, waitTurnResult, health, recover, detach
6
+ * - Chrome DevTools MCP 1차 구현
7
+ * - DegradationStateMachine 위임 복구
8
+ * - MVP: ChatGPT 단일 지원
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { DegradationStateMachine, makeResult, ERROR_CODES } from "./degradation-state-machine.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ // ─── Selector Config Loader ───
19
+
20
+ function loadSelectorConfig(provider) {
21
+ const configPath = join(__dirname, "selectors", `${provider}.json`);
22
+ try {
23
+ const raw = readFileSync(configPath, "utf-8");
24
+ return JSON.parse(raw);
25
+ } catch (err) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // ─── BrowserControlPort Interface ───
31
+
32
+ class BrowserControlPort {
33
+ /**
34
+ * Bind to a browser tab for a deliberation session.
35
+ * @param {string} sessionId
36
+ * @param {{ url?: string, provider?: string }} targetHint
37
+ * @returns {Promise<Result>}
38
+ */
39
+ async attach(sessionId, targetHint) {
40
+ throw new Error("attach() not implemented");
41
+ }
42
+
43
+ /**
44
+ * Send a turn message to the LLM chat input.
45
+ * @param {string} sessionId
46
+ * @param {string} turnId
47
+ * @param {string} text
48
+ * @returns {Promise<Result>}
49
+ */
50
+ async sendTurn(sessionId, turnId, text) {
51
+ throw new Error("sendTurn() not implemented");
52
+ }
53
+
54
+ /**
55
+ * Wait for the LLM to produce a response.
56
+ * @param {string} sessionId
57
+ * @param {string} turnId
58
+ * @param {number} timeoutSec
59
+ * @returns {Promise<Result>}
60
+ */
61
+ async waitTurnResult(sessionId, turnId, timeoutSec) {
62
+ throw new Error("waitTurnResult() not implemented");
63
+ }
64
+
65
+ /**
66
+ * Check if the browser binding is healthy.
67
+ * @param {string} sessionId
68
+ * @returns {Promise<Result>}
69
+ */
70
+ async health(sessionId) {
71
+ throw new Error("health() not implemented");
72
+ }
73
+
74
+ /**
75
+ * Recover from failure.
76
+ * @param {string} sessionId
77
+ * @param {"rebind"|"reload"|"reopen"} mode
78
+ * @returns {Promise<Result>}
79
+ */
80
+ async recover(sessionId, mode) {
81
+ throw new Error("recover() not implemented");
82
+ }
83
+
84
+ /**
85
+ * Detach from the browser tab.
86
+ * @param {string} sessionId
87
+ * @returns {Promise<Result>}
88
+ */
89
+ async detach(sessionId) {
90
+ throw new Error("detach() not implemented");
91
+ }
92
+ }
93
+
94
+ // ─── Chrome DevTools MCP Adapter ───
95
+
96
+ class DevToolsMcpAdapter extends BrowserControlPort {
97
+ constructor({ cdpEndpoints = [], autoResend = true } = {}) {
98
+ super();
99
+ /** @type {Map<string, { tabId: string, wsUrl: string, provider: string, selectors: object }>} */
100
+ this.bindings = new Map();
101
+ this.cdpEndpoints = cdpEndpoints;
102
+ this.autoResend = autoResend;
103
+ this._cmdId = 0;
104
+ /** @type {Map<string, Set<string>>} dedupe: sessionId → Set<turnId> */
105
+ this.sentTurns = new Map();
106
+ }
107
+
108
+ async attach(sessionId, targetHint = {}) {
109
+ const provider = targetHint.provider || "chatgpt";
110
+ const selectorConfig = loadSelectorConfig(provider);
111
+ if (!selectorConfig) {
112
+ return makeResult(false, null, {
113
+ code: "INVALID_SELECTOR_CONFIG",
114
+ message: `No selector config found for provider: ${provider}`,
115
+ });
116
+ }
117
+
118
+ // Find matching tab via CDP /json/list
119
+ const targetUrl = targetHint.url;
120
+ const domains = selectorConfig.domains || [];
121
+
122
+ let foundTab = null;
123
+ for (const endpoint of this.cdpEndpoints) {
124
+ try {
125
+ const resp = await fetch(endpoint, {
126
+ signal: AbortSignal.timeout(3000),
127
+ headers: { accept: "application/json" },
128
+ });
129
+ const tabs = await resp.json();
130
+ for (const tab of tabs) {
131
+ if (tab.type !== "page") continue;
132
+ const tabUrl = tab.url || "";
133
+ if (targetUrl && tabUrl.includes(targetUrl)) {
134
+ foundTab = { ...tab, endpoint };
135
+ break;
136
+ }
137
+ if (domains.some(d => tabUrl.includes(d))) {
138
+ foundTab = { ...tab, endpoint };
139
+ break;
140
+ }
141
+ // Strategy 3: Extension title match
142
+ if (selectorConfig.isExtension && tabUrl.startsWith("chrome-extension://")) {
143
+ const titlePatterns = selectorConfig.titlePatterns || [];
144
+ const lowerTitle = String(tab.title || "").toLowerCase();
145
+ if (titlePatterns.some(p => lowerTitle.includes(p.toLowerCase()))) {
146
+ foundTab = { ...tab, endpoint };
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ if (foundTab) break;
152
+ } catch {
153
+ // endpoint not reachable
154
+ }
155
+ }
156
+
157
+ if (!foundTab) {
158
+ return makeResult(false, null, {
159
+ code: "BIND_FAILED",
160
+ message: `No matching browser tab found for provider "${provider}" (checked ${this.cdpEndpoints.length} endpoints)`,
161
+ });
162
+ }
163
+
164
+ this.bindings.set(sessionId, {
165
+ tabId: foundTab.id,
166
+ wsUrl: foundTab.webSocketDebuggerUrl,
167
+ provider,
168
+ selectors: selectorConfig.selectors,
169
+ timing: selectorConfig.timing,
170
+ pageUrl: foundTab.url,
171
+ title: foundTab.title,
172
+ });
173
+
174
+ return makeResult(true, {
175
+ provider,
176
+ tabId: foundTab.id,
177
+ title: foundTab.title,
178
+ url: foundTab.url,
179
+ });
180
+ }
181
+
182
+ async sendTurn(sessionId, turnId, text) {
183
+ const binding = this.bindings.get(sessionId);
184
+ if (!binding) {
185
+ return makeResult(false, null, {
186
+ code: "BIND_FAILED",
187
+ message: `No binding for session ${sessionId}. Call attach() first.`,
188
+ });
189
+ }
190
+
191
+ // Idempotency check
192
+ if (!this.sentTurns.has(sessionId)) this.sentTurns.set(sessionId, new Set());
193
+ const sent = this.sentTurns.get(sessionId);
194
+ if (sent.has(turnId)) {
195
+ return makeResult(true, { deduplicated: true, turnId });
196
+ }
197
+
198
+ try {
199
+ // Step 1: Focus input and insert text via execCommand for React/ProseMirror compatibility
200
+ const inputSel = JSON.stringify(binding.selectors.inputSelector);
201
+ const sendBtnSel = JSON.stringify(binding.selectors.sendButton);
202
+ const escapedText = JSON.stringify(text);
203
+
204
+ const result = await this._cdpEvaluate(binding, `
205
+ (function() {
206
+ const input = document.querySelector(${inputSel});
207
+ if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
208
+
209
+ // Focus and select all existing content
210
+ input.focus();
211
+ if (input.isContentEditable) {
212
+ // For contenteditable (ChatGPT ProseMirror, Claude, etc.)
213
+ const sel = window.getSelection();
214
+ const range = document.createRange();
215
+ range.selectNodeContents(input);
216
+ sel.removeAllRanges();
217
+ sel.addRange(range);
218
+ // execCommand triggers framework state updates (React, ProseMirror, Quill)
219
+ document.execCommand('insertText', false, ${escapedText});
220
+ } else {
221
+ // For regular <textarea>/<input>
222
+ const nativeSetter = Object.getOwnPropertyDescriptor(
223
+ Object.getPrototypeOf(input), 'value'
224
+ )?.set || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
225
+ if (nativeSetter) {
226
+ nativeSetter.call(input, ${escapedText});
227
+ } else {
228
+ input.value = ${escapedText};
229
+ }
230
+ input.dispatchEvent(new Event('input', { bubbles: true }));
231
+ input.dispatchEvent(new Event('change', { bubbles: true }));
232
+ }
233
+ return { ok: true };
234
+ })()
235
+ `);
236
+
237
+ if (!result.ok) {
238
+ return makeResult(false, null, {
239
+ code: "DOM_CHANGED",
240
+ message: `Input selector not found: ${binding.selectors.inputSelector}`,
241
+ });
242
+ }
243
+
244
+ // Small delay for framework state propagation
245
+ await new Promise(r => setTimeout(r, binding.timing?.sendDelayMs || 200));
246
+
247
+ // Step 2: Send via Enter key (primary) + button click (fallback)
248
+ const sendResult = await this._cdpEvaluate(binding, `
249
+ (function() {
250
+ const input = document.querySelector(${inputSel});
251
+ if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
252
+
253
+ // Primary: dispatch Enter key event on the input
254
+ input.focus();
255
+ const enterEvent = new KeyboardEvent('keydown', {
256
+ key: 'Enter', code: 'Enter',
257
+ keyCode: 13, which: 13,
258
+ bubbles: true, cancelable: true
259
+ });
260
+ input.dispatchEvent(enterEvent);
261
+
262
+ // Fallback: also click send button if it exists and is enabled
263
+ const btn = document.querySelector(${sendBtnSel});
264
+ if (btn && !btn.disabled) {
265
+ btn.click();
266
+ }
267
+ return { ok: true };
268
+ })()
269
+ `);
270
+
271
+ if (!sendResult.ok) {
272
+ return makeResult(false, null, {
273
+ code: "SEND_FAILED",
274
+ message: `Send button not found: ${binding.selectors.sendButton}`,
275
+ });
276
+ }
277
+
278
+ sent.add(turnId);
279
+ return makeResult(true, { turnId, sent: true });
280
+ } catch (err) {
281
+ return this._classifyError(err);
282
+ }
283
+ }
284
+
285
+ async waitTurnResult(sessionId, turnId, timeoutSec = 45) {
286
+ const binding = this.bindings.get(sessionId);
287
+ if (!binding) {
288
+ return makeResult(false, null, {
289
+ code: "BIND_FAILED",
290
+ message: `No binding for session ${sessionId}`,
291
+ });
292
+ }
293
+
294
+ const timeoutMs = timeoutSec * 1000;
295
+ const pollInterval = binding.timing?.pollIntervalMs || 500;
296
+ const startTime = Date.now();
297
+
298
+ const streamSel = JSON.stringify(binding.selectors.streamingIndicator);
299
+ const respContSel = JSON.stringify(binding.selectors.responseContainer);
300
+ const respSel = JSON.stringify(binding.selectors.responseSelector);
301
+
302
+ try {
303
+ while (Date.now() - startTime < timeoutMs) {
304
+ // Check if streaming is complete
305
+ const status = await this._cdpEvaluate(binding, `
306
+ (function() {
307
+ const streaming = document.querySelector(${streamSel});
308
+ if (streaming) return { streaming: true };
309
+ const responses = document.querySelectorAll(${respContSel});
310
+ if (responses.length === 0) return { streaming: true };
311
+ const last = responses[responses.length - 1];
312
+ const content = last.querySelector(${respSel});
313
+ return {
314
+ streaming: false,
315
+ text: content ? content.textContent : last.textContent,
316
+ };
317
+ })()
318
+ `);
319
+
320
+ if (status.data && !status.data.streaming && status.data.text) {
321
+ return makeResult(true, {
322
+ turnId,
323
+ response: status.data.text.trim(),
324
+ elapsedMs: Date.now() - startTime,
325
+ });
326
+ }
327
+
328
+ await new Promise(r => setTimeout(r, pollInterval));
329
+ }
330
+
331
+ return makeResult(false, null, {
332
+ code: "TIMEOUT",
333
+ message: `Response not received within ${timeoutSec}s`,
334
+ });
335
+ } catch (err) {
336
+ return this._classifyError(err);
337
+ }
338
+ }
339
+
340
+ async health(sessionId) {
341
+ const binding = this.bindings.get(sessionId);
342
+ if (!binding) {
343
+ return makeResult(true, { bound: false, sessionId });
344
+ }
345
+
346
+ try {
347
+ const result = await this._cdpEvaluate(binding, "document.readyState");
348
+ return makeResult(true, {
349
+ bound: true,
350
+ sessionId,
351
+ provider: binding.provider,
352
+ pageUrl: binding.pageUrl,
353
+ readyState: result.data,
354
+ });
355
+ } catch (err) {
356
+ return makeResult(false, null, {
357
+ code: "TAB_CLOSED",
358
+ message: `Health check failed: ${err.message}`,
359
+ });
360
+ }
361
+ }
362
+
363
+ async recover(sessionId, mode = "rebind") {
364
+ const binding = this.bindings.get(sessionId);
365
+
366
+ switch (mode) {
367
+ case "rebind": {
368
+ // Re-scan for the tab
369
+ if (!binding) return makeResult(false, null, { code: "BIND_FAILED", message: "No previous binding to rebind" });
370
+ return this.attach(sessionId, { provider: binding.provider });
371
+ }
372
+ case "reload": {
373
+ if (!binding) return makeResult(false, null, { code: "TAB_CLOSED", message: "No binding to reload" });
374
+ try {
375
+ await this._cdpCommand(binding, "Page.reload", {});
376
+ await new Promise(r => setTimeout(r, 3000)); // wait for reload
377
+ return makeResult(true, { mode: "reload", sessionId });
378
+ } catch (err) {
379
+ return this._classifyError(err);
380
+ }
381
+ }
382
+ case "reopen": {
383
+ // Detach old binding, try re-attach
384
+ this.bindings.delete(sessionId);
385
+ const provider = binding?.provider || "chatgpt";
386
+ return this.attach(sessionId, { provider });
387
+ }
388
+ default:
389
+ return makeResult(false, null, { code: "SEND_FAILED", message: `Unknown recover mode: ${mode}` });
390
+ }
391
+ }
392
+
393
+ async detach(sessionId) {
394
+ this.bindings.delete(sessionId);
395
+ this.sentTurns.delete(sessionId);
396
+ return makeResult(true, { sessionId, detached: true });
397
+ }
398
+
399
+ // ─── CDP Helpers ───
400
+
401
+ async _cdpEvaluate(binding, expression) {
402
+ return this._cdpCommand(binding, "Runtime.evaluate", {
403
+ expression,
404
+ returnByValue: true,
405
+ }).then(result => {
406
+ const val = result?.result?.value;
407
+ if (val && typeof val === "object" && val.ok === false) {
408
+ return makeResult(false, null, { code: "DOM_CHANGED", message: val.error || "DOM evaluation failed" });
409
+ }
410
+ return makeResult(true, val);
411
+ });
412
+ }
413
+
414
+ async _cdpCommand(binding, method, params = {}) {
415
+ if (!binding.wsUrl) {
416
+ throw Object.assign(new Error("No WebSocket URL for CDP"), { code: "MCP_CHANNEL_CLOSED" });
417
+ }
418
+
419
+ // Use dynamic import for WebSocket (Node 18+ has it globally, or ws package)
420
+ const ws = await this._connectWs(binding.wsUrl);
421
+ const id = ++this._cmdId;
422
+
423
+ return new Promise((resolve, reject) => {
424
+ const timeout = setTimeout(() => {
425
+ ws.close();
426
+ reject(Object.assign(new Error("CDP command timeout"), { code: "TIMEOUT" }));
427
+ }, 10000);
428
+
429
+ ws.onmessage = (event) => {
430
+ try {
431
+ const data = JSON.parse(typeof event === "string" ? event : event.data);
432
+ if (data.id === id) {
433
+ clearTimeout(timeout);
434
+ ws.close();
435
+ if (data.error) {
436
+ reject(Object.assign(new Error(data.error.message), { code: "SEND_FAILED" }));
437
+ } else {
438
+ resolve(data.result);
439
+ }
440
+ }
441
+ } catch { /* ignore parse errors */ }
442
+ };
443
+
444
+ ws.onerror = (err) => {
445
+ clearTimeout(timeout);
446
+ ws.close();
447
+ reject(Object.assign(new Error(err.message || "WebSocket error"), { code: "NETWORK_DISCONNECTED" }));
448
+ };
449
+
450
+ ws.send(JSON.stringify({ id, method, params }));
451
+ });
452
+ }
453
+
454
+ async _connectWs(url) {
455
+ // Node.js 22+ has global WebSocket; fallback to ws package
456
+ if (typeof globalThis.WebSocket !== "undefined") {
457
+ const ws = new globalThis.WebSocket(url);
458
+ await new Promise((resolve, reject) => {
459
+ ws.onopen = resolve;
460
+ ws.onerror = reject;
461
+ });
462
+ return ws;
463
+ }
464
+
465
+ // Try dynamic import of ws
466
+ try {
467
+ const { default: WS } = await import("ws");
468
+ const ws = new WS(url);
469
+ await new Promise((resolve, reject) => {
470
+ ws.on("open", resolve);
471
+ ws.on("error", reject);
472
+ });
473
+ return ws;
474
+ } catch {
475
+ throw Object.assign(new Error("WebSocket not available. Install 'ws' package or use Node 22+."), { code: "MCP_CHANNEL_CLOSED" });
476
+ }
477
+ }
478
+
479
+ _classifyError(err) {
480
+ const code = err.code || "UNKNOWN";
481
+ if (ERROR_CODES[code]) {
482
+ return makeResult(false, null, { code, message: err.message });
483
+ }
484
+ // Classify by message patterns
485
+ if (/ECONNREFUSED|ENOTFOUND|fetch failed/i.test(err.message)) {
486
+ return makeResult(false, null, { code: "NETWORK_DISCONNECTED", message: err.message });
487
+ }
488
+ if (/WebSocket|ws:/i.test(err.message)) {
489
+ return makeResult(false, null, { code: "MCP_CHANNEL_CLOSED", message: err.message });
490
+ }
491
+ if (/target.*closed|page.*crashed/i.test(err.message)) {
492
+ return makeResult(false, null, { code: "BROWSER_CRASHED", message: err.message });
493
+ }
494
+ return makeResult(false, null, { code: "UNKNOWN", message: err.message });
495
+ }
496
+ }
497
+
498
+ // ─── Orchestrated Port (with DegradationStateMachine) ───
499
+
500
+ class OrchestratedBrowserPort {
501
+ constructor({ cdpEndpoints = [], autoResend = true, skipEnabled = false } = {}) {
502
+ this.adapter = new DevToolsMcpAdapter({ cdpEndpoints, autoResend });
503
+ this.machines = new Map(); // sessionId → DegradationStateMachine
504
+ }
505
+
506
+ _getOrCreateMachine(sessionId) {
507
+ if (!this.machines.has(sessionId)) {
508
+ this.machines.set(sessionId, new DegradationStateMachine({
509
+ onRetry: () => makeResult(false, null, { code: "SEND_FAILED", message: "retry pass-through" }),
510
+ onRebind: () => this.adapter.recover(sessionId, "rebind"),
511
+ onReload: () => this.adapter.recover(sessionId, "reload"),
512
+ onFallback: (lastResult) => {
513
+ return makeResult(false, null, {
514
+ code: "TIMEOUT",
515
+ message: "All degradation stages exhausted. Falling back to clipboard mode.",
516
+ });
517
+ },
518
+ }));
519
+ }
520
+ return this.machines.get(sessionId);
521
+ }
522
+
523
+ async attach(sessionId, targetHint) {
524
+ return this.adapter.attach(sessionId, targetHint);
525
+ }
526
+
527
+ /**
528
+ * Send a turn with full degradation pipeline.
529
+ */
530
+ async sendTurnWithDegradation(sessionId, turnId, text) {
531
+ const machine = this._getOrCreateMachine(sessionId);
532
+ return machine.execute(() => this.adapter.sendTurn(sessionId, turnId, text));
533
+ }
534
+
535
+ async waitTurnResult(sessionId, turnId, timeoutSec) {
536
+ return this.adapter.waitTurnResult(sessionId, turnId, timeoutSec);
537
+ }
538
+
539
+ async health(sessionId) {
540
+ return this.adapter.health(sessionId);
541
+ }
542
+
543
+ async recover(sessionId, mode) {
544
+ return this.adapter.recover(sessionId, mode);
545
+ }
546
+
547
+ async detach(sessionId) {
548
+ this.machines.delete(sessionId);
549
+ return this.adapter.detach(sessionId);
550
+ }
551
+
552
+ getDegradationState(sessionId) {
553
+ const machine = this.machines.get(sessionId);
554
+ return machine ? machine.toJSON() : null;
555
+ }
556
+ }
557
+
558
+ export {
559
+ BrowserControlPort,
560
+ DevToolsMcpAdapter,
561
+ OrchestratedBrowserPort,
562
+ loadSelectorConfig,
563
+ };