@dmsdc-ai/aigentry-devkit 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -56,13 +56,15 @@ Pre-configured settings with template substitution:
56
56
  All OS (recommended):
57
57
 
58
58
  ```bash
59
- npx -y @dmsdc-ai/aigentry-devkit install
59
+ npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install
60
60
  ```
61
61
 
62
+ `git clone` is not required for this path. The npm package contains the full installer + runtime files.
63
+
62
64
  Force reinstall:
63
65
 
64
66
  ```bash
65
- npx -y @dmsdc-ai/aigentry-devkit install --force
67
+ npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install --force
66
68
  ```
67
69
 
68
70
  Manual install (local clone) is still available:
@@ -86,9 +88,9 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1
86
88
  The installer will:
87
89
 
88
90
  1. Verify Node.js and optional dependencies (Claude Code CLI, tmux, direnv)
89
- 2. Link skills to `~/.claude/skills/`
91
+ 2. Install skills to `~/.claude/skills/`
90
92
  3. Install HUD statusline to `~/.claude/hud/`
91
- 4. Set up MCP Deliberation Server at `~/.local/lib/mcp-deliberation/`
93
+ 4. Set up full MCP Deliberation runtime at `~/.local/lib/mcp-deliberation/` (server + browser adapters + selectors + monitor)
92
94
  5. Register MCP server in `~/.claude/.mcp.json`
93
95
  6. Create configuration templates from templates
94
96
  7. Attempt Codex CLI integration (if available)
@@ -105,9 +107,10 @@ After installation, restart Claude/Codex for changes to take effect:
105
107
  To verify installation:
106
108
 
107
109
  ```bash
108
- ls -la ~/.claude/skills/ # Check skills are linked
110
+ ls -la ~/.claude/skills/ # Check skills are installed
109
111
  ls -la ~/.claude/hud/ # Check HUD is installed
110
112
  ls -la ~/.local/lib/mcp-deliberation/ # Check MCP server
113
+ ls -la ~/.local/lib/mcp-deliberation/selectors/ # Check browser selector assets
111
114
  cat ~/.claude/.mcp.json # Verify MCP registration
112
115
  ```
113
116
 
@@ -313,7 +316,7 @@ direnv allow
313
316
 
314
317
  ### Skills not loading
315
318
 
316
- 1. Verify skills are linked: `ls -la ~/.claude/skills/`
319
+ 1. Verify skills are installed: `ls -la ~/.claude/skills/`
317
320
  2. Restart your MCP client process (Claude/Codex/etc.)
318
321
  3. Check for keyword matches in skill definitions
319
322
 
@@ -321,8 +324,9 @@ direnv allow
321
324
 
322
325
  1. Verify MCP registration: `cat ~/.claude/.mcp.json`
323
326
  2. Check installation: `ls ~/.local/lib/mcp-deliberation/`
324
- 3. Restart your MCP client process (Claude/Codex/etc.)
325
- 4. Review MCP server logs in Claude Code console
327
+ 3. Reinstall runtime files: `npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install --force`
328
+ 4. Restart your MCP client process (Claude/Codex/etc.)
329
+ 5. Review runtime log: `tail -n 120 ~/.local/lib/mcp-deliberation/runtime.log`
326
330
 
327
331
  ### MCP `Transport closed` in multi-session use
328
332
 
@@ -415,10 +419,10 @@ Language-specific requirements for skills:
415
419
  ### Installation Flow
416
420
 
417
421
  ```
418
- npx @dmsdc-ai/aigentry-devkit install
422
+ npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install
419
423
  ├─ dispatches to install.sh (macOS/Linux) or install.ps1 (Windows)
420
424
  ├─ Check prerequisites (Node.js, npm, optional tools)
421
- ├─ Link skills to ~/.claude/skills/
425
+ ├─ Install skills to ~/.claude/skills/
422
426
  ├─ Install HUD to ~/.claude/hud/
423
427
  ├─ Deploy MCP server to ~/.local/lib/mcp-deliberation/
424
428
  ├─ Register MCP in ~/.claude/.mcp.json
@@ -15,8 +15,8 @@ function printHelp() {
15
15
  " aigentry-devkit --help",
16
16
  "",
17
17
  "Examples:",
18
- " npx -y @dmsdc-ai/aigentry-devkit install",
19
- " npx -y @dmsdc-ai/aigentry-devkit install --force",
18
+ " npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install",
19
+ " npx --yes --package @dmsdc-ai/aigentry-devkit aigentry-devkit install --force",
20
20
  ].join("\n");
21
21
  process.stdout.write(`${text}\n`);
22
22
  }
package/install.ps1 CHANGED
@@ -94,14 +94,20 @@ if (-not (Test-Path $hudTarget) -or $Force) {
94
94
 
95
95
  Write-Header "4. MCP Deliberation Server"
96
96
 
97
+ if ($Force -and (Test-Path $McpDest)) {
98
+ Remove-Item -Path $McpDest -Recurse -Force
99
+ }
97
100
  New-Item -ItemType Directory -Path $McpDest -Force | Out-Null
98
- Copy-Item -Path (Join-Path $DevkitDir "mcp-servers\deliberation\index.js") -Destination (Join-Path $McpDest "index.js") -Force
99
- Copy-Item -Path (Join-Path $DevkitDir "mcp-servers\deliberation\package.json") -Destination (Join-Path $McpDest "package.json") -Force
100
- Copy-Item -Path (Join-Path $DevkitDir "mcp-servers\deliberation\session-monitor.sh") -Destination (Join-Path $McpDest "session-monitor.sh") -Force
101
+ $mcpSource = Join-Path $DevkitDir "mcp-servers\deliberation"
102
+ Copy-Item -Path (Join-Path $mcpSource "*") -Destination $McpDest -Recurse -Force
101
103
 
102
104
  Write-Info "Installing dependencies..."
103
105
  Push-Location $McpDest
104
- npm install --silent | Out-Null
106
+ npm install --omit=dev
107
+ if ($LASTEXITCODE -ne 0) {
108
+ Pop-Location
109
+ throw "npm install failed in $McpDest"
110
+ }
105
111
  Pop-Location
106
112
  Write-Info "MCP deliberation server installed at $McpDest"
107
113
 
@@ -132,6 +138,48 @@ $cfg.mcpServers | Add-Member -MemberType NoteProperty -Name deliberation -Value
132
138
  $cfg | ConvertTo-Json -Depth 8 | Set-Content -Path $mcpConfig -Encoding utf8
133
139
  Write-Info "Registered deliberation MCP in $mcpConfig"
134
140
 
141
+ if (Get-Command claude -ErrorAction SilentlyContinue) {
142
+ $claudeScopeSupported = $false
143
+ try {
144
+ $claudeAddHelp = claude mcp add --help 2>$null | Out-String
145
+ if ($claudeAddHelp -match "--scope") {
146
+ $claudeScopeSupported = $true
147
+ }
148
+ } catch {}
149
+
150
+ if ($claudeScopeSupported) {
151
+ try { claude mcp remove --scope local deliberation 2>$null | Out-Null } catch {}
152
+ try { claude mcp remove --scope user deliberation 2>$null | Out-Null } catch {}
153
+
154
+ try {
155
+ claude mcp add --scope user deliberation -- node (Join-Path $McpDest "index.js") | Out-Null
156
+ Write-Info "Registered deliberation MCP in Claude Code user scope (~/.claude.json)"
157
+ } catch {
158
+ Write-Warn "Claude Code MCP registration failed. Run manually: claude mcp add --scope user deliberation -- node $McpDest\\index.js"
159
+ }
160
+ } else {
161
+ try {
162
+ claude mcp add deliberation -- node (Join-Path $McpDest "index.js") | Out-Null
163
+ Write-Info "Registered deliberation MCP in Claude Code"
164
+ } catch {
165
+ Write-Warn "Claude Code MCP registration failed (legacy CLI)."
166
+ }
167
+ }
168
+
169
+ try {
170
+ $claudeMcpList = claude mcp list 2>$null | Out-String
171
+ if ($claudeMcpList -match "(?m)^deliberation:") {
172
+ Write-Info "Claude Code MCP verification passed (deliberation found)"
173
+ } else {
174
+ Write-Warn "Claude Code MCP verification failed. Restart Claude and run: claude mcp list"
175
+ }
176
+ } catch {
177
+ Write-Warn "Claude Code MCP verification failed (claude mcp list unavailable)"
178
+ }
179
+ } else {
180
+ Write-Warn "Claude CLI not found. Skipping Claude MCP registration."
181
+ }
182
+
135
183
  Write-Header "6. Config Templates"
136
184
 
137
185
  $settingsDest = Join-Path $ClaudeDir "settings.json"
package/install.sh CHANGED
@@ -13,6 +13,15 @@ DEVKIT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
13
  CLAUDE_DIR="$HOME/.claude"
14
14
  MCP_DEST="$HOME/.local/lib/mcp-deliberation"
15
15
  PLATFORM="$(uname -s 2>/dev/null || echo unknown)"
16
+ FORCE=0
17
+
18
+ for arg in "$@"; do
19
+ case "$arg" in
20
+ --force|-f)
21
+ FORCE=1
22
+ ;;
23
+ esac
24
+ done
16
25
 
17
26
  # 색상
18
27
  GREEN='\033[0;32m'
@@ -68,17 +77,22 @@ for skill_dir in "$DEVKIT_DIR"/skills/*/; do
68
77
  skill_name=$(basename "$skill_dir")
69
78
  target="$SKILLS_DEST/$skill_name"
70
79
 
71
- if [ -L "$target" ]; then
72
- rm "$target"
80
+ if [ -e "$target" ]; then
81
+ if [ "$FORCE" -eq 1 ]; then
82
+ rm -rf "$target"
83
+ else
84
+ warn "$skill_name already exists (skipping, use --force to overwrite)"
85
+ continue
86
+ fi
73
87
  fi
74
88
 
75
- if [ -d "$target" ]; then
76
- warn "$skill_name already exists (skipping, use --force to overwrite)"
89
+ if [ ! -d "$skill_dir" ]; then
90
+ warn "Skill source missing: $skill_dir"
77
91
  continue
78
92
  fi
79
93
 
80
- ln -s "$skill_dir" "$target"
81
- info "Linked skill: $skill_name"
94
+ cp -R "$skill_dir" "$target"
95
+ info "Installed skill: $skill_name"
82
96
  done
83
97
 
84
98
  # ── HUD / Statusline ──
@@ -87,7 +101,7 @@ header "3. HUD Statusline"
87
101
  HUD_DEST="$CLAUDE_DIR/hud"
88
102
  mkdir -p "$HUD_DEST"
89
103
 
90
- if [ ! -f "$HUD_DEST/simple-status.sh" ] || [ "${1:-}" = "--force" ]; then
104
+ if [ ! -f "$HUD_DEST/simple-status.sh" ] || [ "$FORCE" -eq 1 ]; then
91
105
  cp "$DEVKIT_DIR/hud/simple-status.sh" "$HUD_DEST/simple-status.sh"
92
106
  chmod +x "$HUD_DEST/simple-status.sh"
93
107
  info "Installed HUD: simple-status.sh"
@@ -98,14 +112,15 @@ fi
98
112
  # ── MCP Deliberation Server ──
99
113
  header "4. MCP Deliberation Server"
100
114
 
115
+ if [ "$FORCE" -eq 1 ] && [ -d "$MCP_DEST" ]; then
116
+ rm -rf "$MCP_DEST"
117
+ fi
101
118
  mkdir -p "$MCP_DEST"
102
- cp "$DEVKIT_DIR/mcp-servers/deliberation/index.js" "$MCP_DEST/"
103
- cp "$DEVKIT_DIR/mcp-servers/deliberation/package.json" "$MCP_DEST/"
104
- cp "$DEVKIT_DIR/mcp-servers/deliberation/session-monitor.sh" "$MCP_DEST/"
119
+ cp -R "$DEVKIT_DIR/mcp-servers/deliberation/." "$MCP_DEST/"
105
120
  chmod +x "$MCP_DEST/session-monitor.sh"
106
121
 
107
122
  info "Installing dependencies..."
108
- (cd "$MCP_DEST" && npm install --silent 2>/dev/null)
123
+ (cd "$MCP_DEST" && npm install --omit=dev)
109
124
  info "MCP deliberation server installed at $MCP_DEST"
110
125
 
111
126
  # ── MCP 등록 ──
@@ -115,13 +130,18 @@ MCP_CONFIG="$CLAUDE_DIR/.mcp.json"
115
130
 
116
131
  if [ -f "$MCP_CONFIG" ]; then
117
132
  # deliberation 서버가 이미 등록되어 있는지 확인
118
- if node -e "const c=JSON.parse(require('fs').readFileSync('$MCP_CONFIG','utf-8'));process.exit(c.mcpServers?.deliberation?0:1)" 2>/dev/null; then
133
+ if node -e "const fs=require('fs');let c={};try{c=JSON.parse(fs.readFileSync('$MCP_CONFIG','utf-8'));}catch{}process.exit(c.mcpServers?.deliberation?0:1)" 2>/dev/null; then
119
134
  info "Deliberation MCP already registered"
120
135
  else
121
136
  # 기존 설정에 deliberation 추가
122
137
  node -e "
123
138
  const fs = require('fs');
124
- const c = JSON.parse(fs.readFileSync('$MCP_CONFIG', 'utf-8'));
139
+ let c = {};
140
+ try {
141
+ c = JSON.parse(fs.readFileSync('$MCP_CONFIG', 'utf-8'));
142
+ } catch {
143
+ c = {};
144
+ }
125
145
  if (!c.mcpServers) c.mcpServers = {};
126
146
  c.mcpServers.deliberation = {
127
147
  command: 'node',
@@ -146,6 +166,33 @@ MCPEOF
146
166
  info "Created $MCP_CONFIG with deliberation MCP"
147
167
  fi
148
168
 
169
+ # Claude Code CLI 등록 (최신 런타임 경로)
170
+ if command -v claude >/dev/null 2>&1; then
171
+ if claude mcp add --help 2>/dev/null | grep -q -- '--scope'; then
172
+ claude mcp remove --scope local deliberation >/dev/null 2>&1 || true
173
+ claude mcp remove --scope user deliberation >/dev/null 2>&1 || true
174
+ if claude mcp add --scope user deliberation -- node "$MCP_DEST/index.js" >/dev/null 2>&1; then
175
+ info "Registered deliberation MCP in Claude Code user scope (~/.claude.json)"
176
+ else
177
+ warn "Claude Code MCP registration failed. Run manually: claude mcp add --scope user deliberation -- node $MCP_DEST/index.js"
178
+ fi
179
+ else
180
+ if claude mcp add deliberation -- node "$MCP_DEST/index.js" >/dev/null 2>&1; then
181
+ info "Registered deliberation MCP in Claude Code"
182
+ else
183
+ warn "Claude Code MCP registration failed (legacy CLI)."
184
+ fi
185
+ fi
186
+
187
+ if claude mcp list 2>/dev/null | grep -q '^deliberation:'; then
188
+ info "Claude Code MCP verification passed (deliberation found)"
189
+ else
190
+ warn "Claude Code MCP verification failed. Restart Claude and run: claude mcp list"
191
+ fi
192
+ else
193
+ warn "Claude CLI not found. Skipping Claude MCP registration."
194
+ fi
195
+
149
196
  # ── Config 템플릿 ──
150
197
  header "6. Config Templates"
151
198
 
@@ -0,0 +1,509 @@
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
+ }
142
+ if (foundTab) break;
143
+ } catch {
144
+ // endpoint not reachable
145
+ }
146
+ }
147
+
148
+ if (!foundTab) {
149
+ return makeResult(false, null, {
150
+ code: "BIND_FAILED",
151
+ message: `No matching browser tab found for provider "${provider}" (checked ${this.cdpEndpoints.length} endpoints)`,
152
+ });
153
+ }
154
+
155
+ this.bindings.set(sessionId, {
156
+ tabId: foundTab.id,
157
+ wsUrl: foundTab.webSocketDebuggerUrl,
158
+ provider,
159
+ selectors: selectorConfig.selectors,
160
+ timing: selectorConfig.timing,
161
+ pageUrl: foundTab.url,
162
+ title: foundTab.title,
163
+ });
164
+
165
+ return makeResult(true, {
166
+ provider,
167
+ tabId: foundTab.id,
168
+ title: foundTab.title,
169
+ url: foundTab.url,
170
+ });
171
+ }
172
+
173
+ async sendTurn(sessionId, turnId, text) {
174
+ const binding = this.bindings.get(sessionId);
175
+ if (!binding) {
176
+ return makeResult(false, null, {
177
+ code: "BIND_FAILED",
178
+ message: `No binding for session ${sessionId}. Call attach() first.`,
179
+ });
180
+ }
181
+
182
+ // Idempotency check
183
+ if (!this.sentTurns.has(sessionId)) this.sentTurns.set(sessionId, new Set());
184
+ const sent = this.sentTurns.get(sessionId);
185
+ if (sent.has(turnId)) {
186
+ return makeResult(true, { deduplicated: true, turnId });
187
+ }
188
+
189
+ try {
190
+ // Execute CDP commands to type and send
191
+ const result = await this._cdpEvaluate(binding, `
192
+ (function() {
193
+ const input = document.querySelector('${binding.selectors.inputSelector}');
194
+ if (!input) return { ok: false, error: 'INPUT_NOT_FOUND' };
195
+ input.focus();
196
+ input.textContent = ${JSON.stringify(text)};
197
+ input.dispatchEvent(new Event('input', { bubbles: true }));
198
+ return { ok: true };
199
+ })()
200
+ `);
201
+
202
+ if (!result.ok) {
203
+ return makeResult(false, null, {
204
+ code: "DOM_CHANGED",
205
+ message: `Input selector not found: ${binding.selectors.inputSelector}`,
206
+ });
207
+ }
208
+
209
+ // Small delay then click send
210
+ await new Promise(r => setTimeout(r, binding.timing?.sendDelayMs || 200));
211
+
212
+ const sendResult = await this._cdpEvaluate(binding, `
213
+ (function() {
214
+ const btn = document.querySelector('${binding.selectors.sendButton}');
215
+ if (!btn) return { ok: false, error: 'SEND_BUTTON_NOT_FOUND' };
216
+ btn.click();
217
+ return { ok: true };
218
+ })()
219
+ `);
220
+
221
+ if (!sendResult.ok) {
222
+ return makeResult(false, null, {
223
+ code: "SEND_FAILED",
224
+ message: `Send button not found: ${binding.selectors.sendButton}`,
225
+ });
226
+ }
227
+
228
+ sent.add(turnId);
229
+ return makeResult(true, { turnId, sent: true });
230
+ } catch (err) {
231
+ return this._classifyError(err);
232
+ }
233
+ }
234
+
235
+ async waitTurnResult(sessionId, turnId, timeoutSec = 45) {
236
+ const binding = this.bindings.get(sessionId);
237
+ if (!binding) {
238
+ return makeResult(false, null, {
239
+ code: "BIND_FAILED",
240
+ message: `No binding for session ${sessionId}`,
241
+ });
242
+ }
243
+
244
+ const timeoutMs = timeoutSec * 1000;
245
+ const pollInterval = binding.timing?.pollIntervalMs || 500;
246
+ const startTime = Date.now();
247
+
248
+ try {
249
+ while (Date.now() - startTime < timeoutMs) {
250
+ // Check if streaming is complete
251
+ const status = await this._cdpEvaluate(binding, `
252
+ (function() {
253
+ const streaming = document.querySelector('${binding.selectors.streamingIndicator}');
254
+ if (streaming) return { streaming: true };
255
+ const responses = document.querySelectorAll('${binding.selectors.responseContainer}');
256
+ if (responses.length === 0) return { streaming: true };
257
+ const last = responses[responses.length - 1];
258
+ const content = last.querySelector('${binding.selectors.responseSelector}');
259
+ return {
260
+ streaming: false,
261
+ text: content ? content.textContent : last.textContent,
262
+ };
263
+ })()
264
+ `);
265
+
266
+ if (status.data && !status.data.streaming && status.data.text) {
267
+ return makeResult(true, {
268
+ turnId,
269
+ response: status.data.text.trim(),
270
+ elapsedMs: Date.now() - startTime,
271
+ });
272
+ }
273
+
274
+ await new Promise(r => setTimeout(r, pollInterval));
275
+ }
276
+
277
+ return makeResult(false, null, {
278
+ code: "TIMEOUT",
279
+ message: `Response not received within ${timeoutSec}s`,
280
+ });
281
+ } catch (err) {
282
+ return this._classifyError(err);
283
+ }
284
+ }
285
+
286
+ async health(sessionId) {
287
+ const binding = this.bindings.get(sessionId);
288
+ if (!binding) {
289
+ return makeResult(true, { bound: false, sessionId });
290
+ }
291
+
292
+ try {
293
+ const result = await this._cdpEvaluate(binding, "document.readyState");
294
+ return makeResult(true, {
295
+ bound: true,
296
+ sessionId,
297
+ provider: binding.provider,
298
+ pageUrl: binding.pageUrl,
299
+ readyState: result.data,
300
+ });
301
+ } catch (err) {
302
+ return makeResult(false, null, {
303
+ code: "TAB_CLOSED",
304
+ message: `Health check failed: ${err.message}`,
305
+ });
306
+ }
307
+ }
308
+
309
+ async recover(sessionId, mode = "rebind") {
310
+ const binding = this.bindings.get(sessionId);
311
+
312
+ switch (mode) {
313
+ case "rebind": {
314
+ // Re-scan for the tab
315
+ if (!binding) return makeResult(false, null, { code: "BIND_FAILED", message: "No previous binding to rebind" });
316
+ return this.attach(sessionId, { provider: binding.provider });
317
+ }
318
+ case "reload": {
319
+ if (!binding) return makeResult(false, null, { code: "TAB_CLOSED", message: "No binding to reload" });
320
+ try {
321
+ await this._cdpCommand(binding, "Page.reload", {});
322
+ await new Promise(r => setTimeout(r, 3000)); // wait for reload
323
+ return makeResult(true, { mode: "reload", sessionId });
324
+ } catch (err) {
325
+ return this._classifyError(err);
326
+ }
327
+ }
328
+ case "reopen": {
329
+ // Detach old binding, try re-attach
330
+ this.bindings.delete(sessionId);
331
+ const provider = binding?.provider || "chatgpt";
332
+ return this.attach(sessionId, { provider });
333
+ }
334
+ default:
335
+ return makeResult(false, null, { code: "SEND_FAILED", message: `Unknown recover mode: ${mode}` });
336
+ }
337
+ }
338
+
339
+ async detach(sessionId) {
340
+ this.bindings.delete(sessionId);
341
+ this.sentTurns.delete(sessionId);
342
+ return makeResult(true, { sessionId, detached: true });
343
+ }
344
+
345
+ // ─── CDP Helpers ───
346
+
347
+ async _cdpEvaluate(binding, expression) {
348
+ return this._cdpCommand(binding, "Runtime.evaluate", {
349
+ expression,
350
+ returnByValue: true,
351
+ }).then(result => {
352
+ const val = result?.result?.value;
353
+ if (val && typeof val === "object" && val.ok === false) {
354
+ return makeResult(false, null, { code: "DOM_CHANGED", message: val.error || "DOM evaluation failed" });
355
+ }
356
+ return makeResult(true, val);
357
+ });
358
+ }
359
+
360
+ async _cdpCommand(binding, method, params = {}) {
361
+ if (!binding.wsUrl) {
362
+ throw Object.assign(new Error("No WebSocket URL for CDP"), { code: "MCP_CHANNEL_CLOSED" });
363
+ }
364
+
365
+ // Use dynamic import for WebSocket (Node 18+ has it globally, or ws package)
366
+ const ws = await this._connectWs(binding.wsUrl);
367
+ const id = ++this._cmdId;
368
+
369
+ return new Promise((resolve, reject) => {
370
+ const timeout = setTimeout(() => {
371
+ ws.close();
372
+ reject(Object.assign(new Error("CDP command timeout"), { code: "TIMEOUT" }));
373
+ }, 10000);
374
+
375
+ ws.onmessage = (event) => {
376
+ try {
377
+ const data = JSON.parse(typeof event === "string" ? event : event.data);
378
+ if (data.id === id) {
379
+ clearTimeout(timeout);
380
+ ws.close();
381
+ if (data.error) {
382
+ reject(Object.assign(new Error(data.error.message), { code: "SEND_FAILED" }));
383
+ } else {
384
+ resolve(data.result);
385
+ }
386
+ }
387
+ } catch { /* ignore parse errors */ }
388
+ };
389
+
390
+ ws.onerror = (err) => {
391
+ clearTimeout(timeout);
392
+ ws.close();
393
+ reject(Object.assign(new Error(err.message || "WebSocket error"), { code: "NETWORK_DISCONNECTED" }));
394
+ };
395
+
396
+ ws.send(JSON.stringify({ id, method, params }));
397
+ });
398
+ }
399
+
400
+ async _connectWs(url) {
401
+ // Node.js 22+ has global WebSocket; fallback to ws package
402
+ if (typeof globalThis.WebSocket !== "undefined") {
403
+ const ws = new globalThis.WebSocket(url);
404
+ await new Promise((resolve, reject) => {
405
+ ws.onopen = resolve;
406
+ ws.onerror = reject;
407
+ });
408
+ return ws;
409
+ }
410
+
411
+ // Try dynamic import of ws
412
+ try {
413
+ const { default: WS } = await import("ws");
414
+ const ws = new WS(url);
415
+ await new Promise((resolve, reject) => {
416
+ ws.on("open", resolve);
417
+ ws.on("error", reject);
418
+ });
419
+ return ws;
420
+ } catch {
421
+ throw Object.assign(new Error("WebSocket not available. Install 'ws' package or use Node 22+."), { code: "MCP_CHANNEL_CLOSED" });
422
+ }
423
+ }
424
+
425
+ _classifyError(err) {
426
+ const code = err.code || "UNKNOWN";
427
+ if (ERROR_CODES[code]) {
428
+ return makeResult(false, null, { code, message: err.message });
429
+ }
430
+ // Classify by message patterns
431
+ if (/ECONNREFUSED|ENOTFOUND|fetch failed/i.test(err.message)) {
432
+ return makeResult(false, null, { code: "NETWORK_DISCONNECTED", message: err.message });
433
+ }
434
+ if (/WebSocket|ws:/i.test(err.message)) {
435
+ return makeResult(false, null, { code: "MCP_CHANNEL_CLOSED", message: err.message });
436
+ }
437
+ if (/target.*closed|page.*crashed/i.test(err.message)) {
438
+ return makeResult(false, null, { code: "BROWSER_CRASHED", message: err.message });
439
+ }
440
+ return makeResult(false, null, { code: "UNKNOWN", message: err.message });
441
+ }
442
+ }
443
+
444
+ // ─── Orchestrated Port (with DegradationStateMachine) ───
445
+
446
+ class OrchestratedBrowserPort {
447
+ constructor({ cdpEndpoints = [], autoResend = true, skipEnabled = false } = {}) {
448
+ this.adapter = new DevToolsMcpAdapter({ cdpEndpoints, autoResend });
449
+ this.machines = new Map(); // sessionId → DegradationStateMachine
450
+ }
451
+
452
+ _getOrCreateMachine(sessionId) {
453
+ if (!this.machines.has(sessionId)) {
454
+ this.machines.set(sessionId, new DegradationStateMachine({
455
+ onRetry: () => makeResult(false, null, { code: "SEND_FAILED", message: "retry pass-through" }),
456
+ onRebind: () => this.adapter.recover(sessionId, "rebind"),
457
+ onReload: () => this.adapter.recover(sessionId, "reload"),
458
+ onFallback: (lastResult) => {
459
+ return makeResult(false, null, {
460
+ code: "TIMEOUT",
461
+ message: "All degradation stages exhausted. Falling back to clipboard mode.",
462
+ });
463
+ },
464
+ }));
465
+ }
466
+ return this.machines.get(sessionId);
467
+ }
468
+
469
+ async attach(sessionId, targetHint) {
470
+ return this.adapter.attach(sessionId, targetHint);
471
+ }
472
+
473
+ /**
474
+ * Send a turn with full degradation pipeline.
475
+ */
476
+ async sendTurnWithDegradation(sessionId, turnId, text) {
477
+ const machine = this._getOrCreateMachine(sessionId);
478
+ return machine.execute(() => this.adapter.sendTurn(sessionId, turnId, text));
479
+ }
480
+
481
+ async waitTurnResult(sessionId, turnId, timeoutSec) {
482
+ return this.adapter.waitTurnResult(sessionId, turnId, timeoutSec);
483
+ }
484
+
485
+ async health(sessionId) {
486
+ return this.adapter.health(sessionId);
487
+ }
488
+
489
+ async recover(sessionId, mode) {
490
+ return this.adapter.recover(sessionId, mode);
491
+ }
492
+
493
+ async detach(sessionId) {
494
+ this.machines.delete(sessionId);
495
+ return this.adapter.detach(sessionId);
496
+ }
497
+
498
+ getDegradationState(sessionId) {
499
+ const machine = this.machines.get(sessionId);
500
+ return machine ? machine.toJSON() : null;
501
+ }
502
+ }
503
+
504
+ export {
505
+ BrowserControlPort,
506
+ DevToolsMcpAdapter,
507
+ OrchestratedBrowserPort,
508
+ loadSelectorConfig,
509
+ };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * DegradationStateMachine — 4-stage graceful degradation for BrowserControlPort
3
+ *
4
+ * States: HEALTHY → RETRYING → REBINDING → RELOADING → FALLBACK → FAILED
5
+ * Time budget (60s SLO): S1(12s) + S2(8s) + S3(18s) + S4(22s spare)
6
+ */
7
+
8
+ const STATES = {
9
+ HEALTHY: "HEALTHY",
10
+ RETRYING: "RETRYING",
11
+ REBINDING: "REBINDING",
12
+ RELOADING: "RELOADING",
13
+ FALLBACK: "FALLBACK",
14
+ FAILED: "FAILED",
15
+ };
16
+
17
+ const STAGE_BUDGETS = {
18
+ RETRYING: { maxAttempts: 2, backoffMs: [2000, 4000], budgetMs: 12000 },
19
+ REBINDING: { maxAttempts: 1, budgetMs: 8000 },
20
+ RELOADING: { maxAttempts: 1, budgetMs: 18000 },
21
+ FALLBACK: { budgetMs: 22000 },
22
+ };
23
+
24
+ const ERROR_CODES = {
25
+ BIND_FAILED: { category: "transient", domain: "browser", retryable: true },
26
+ SEND_FAILED: { category: "transient", domain: "transport", retryable: true },
27
+ TIMEOUT: { category: "transient", domain: "transport", retryable: true },
28
+ DOM_CHANGED: { category: "transient", domain: "dom", retryable: true },
29
+ SESSION_EXPIRED: { category: "permanent", domain: "session", retryable: false },
30
+ TAB_CLOSED: { category: "transient", domain: "browser", retryable: true },
31
+ NETWORK_DISCONNECTED: { category: "transient", domain: "transport", retryable: true },
32
+ MCP_CHANNEL_CLOSED: { category: "transient", domain: "transport", retryable: true },
33
+ BROWSER_CRASHED: { category: "transient", domain: "browser", retryable: true },
34
+ INVALID_SELECTOR_CONFIG: { category: "permanent", domain: "dom", retryable: false },
35
+ };
36
+
37
+ function makeResult(ok, data, error) {
38
+ if (ok) return { ok: true, data: data ?? null };
39
+ const meta = ERROR_CODES[error?.code] || ERROR_CODES.TIMEOUT;
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code: error?.code || "UNKNOWN",
44
+ category: meta.category,
45
+ domain: meta.domain,
46
+ message: error?.message || "Unknown error",
47
+ retryable: meta.retryable,
48
+ },
49
+ };
50
+ }
51
+
52
+ class DegradationStateMachine {
53
+ constructor({ onRetry, onRebind, onReload, onFallback, skipEnabled = false } = {}) {
54
+ this.state = STATES.HEALTHY;
55
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
56
+ this.startTime = null;
57
+ this.lastError = null;
58
+ this.skipEnabled = skipEnabled;
59
+
60
+ // Callbacks for each stage action
61
+ this._onRetry = onRetry || (() => makeResult(false, null, { code: "SEND_FAILED", message: "retry not implemented" }));
62
+ this._onRebind = onRebind || (() => makeResult(false, null, { code: "DOM_CHANGED", message: "rebind not implemented" }));
63
+ this._onReload = onReload || (() => makeResult(false, null, { code: "TAB_CLOSED", message: "reload not implemented" }));
64
+ this._onFallback = onFallback || (() => makeResult(false, null, { code: "TIMEOUT", message: "fallback not implemented" }));
65
+ }
66
+
67
+ reset() {
68
+ this.state = STATES.HEALTHY;
69
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
70
+ this.startTime = null;
71
+ this.lastError = null;
72
+ }
73
+
74
+ get elapsedMs() {
75
+ return this.startTime ? Date.now() - this.startTime : 0;
76
+ }
77
+
78
+ get totalBudgetMs() {
79
+ return 60000;
80
+ }
81
+
82
+ get isTerminal() {
83
+ return this.state === STATES.FAILED || this.state === STATES.FALLBACK;
84
+ }
85
+
86
+ /**
87
+ * Execute a turn with full degradation pipeline.
88
+ * @param {Function} primaryAction - async () => Result. The main action to attempt.
89
+ * @returns {Result} Final result after all degradation attempts.
90
+ */
91
+ async execute(primaryAction) {
92
+ this.startTime = Date.now();
93
+ this.state = STATES.HEALTHY;
94
+ this.stageAttempts = { RETRYING: 0, REBINDING: 0, RELOADING: 0 };
95
+
96
+ // Stage 0: Primary attempt
97
+ const primaryResult = await this._timed(primaryAction);
98
+ if (primaryResult.ok) return primaryResult;
99
+ this.lastError = primaryResult.error;
100
+
101
+ // Check if permanent error — skip to FAILED
102
+ if (primaryResult.error && !primaryResult.error.retryable) {
103
+ this.state = STATES.FAILED;
104
+ return primaryResult;
105
+ }
106
+
107
+ // Stage 1: Retry with backoff
108
+ this.state = STATES.RETRYING;
109
+ const retryResult = await this._stageRetry(primaryAction);
110
+ if (retryResult.ok) { this.state = STATES.HEALTHY; return retryResult; }
111
+ if (this._budgetExceeded()) return this._toFallback(retryResult);
112
+
113
+ // Stage 2: Rebind (DOM re-scan)
114
+ this.state = STATES.REBINDING;
115
+ const rebindResult = await this._stageRebind();
116
+ if (rebindResult.ok) {
117
+ // Rebind succeeded, retry primary once more
118
+ const afterRebind = await this._timed(primaryAction);
119
+ if (afterRebind.ok) { this.state = STATES.HEALTHY; return afterRebind; }
120
+ }
121
+ if (this._budgetExceeded()) return this._toFallback(rebindResult);
122
+
123
+ // Stage 3: Reload/Reopen
124
+ this.state = STATES.RELOADING;
125
+ const reloadResult = await this._stageReload();
126
+ if (reloadResult.ok) {
127
+ // After reload, retry primary once (auto-resend with turn_id idempotency)
128
+ const afterReload = await this._timed(primaryAction);
129
+ if (afterReload.ok) { this.state = STATES.HEALTHY; return afterReload; }
130
+ }
131
+ if (this._budgetExceeded()) return this._toFallback(reloadResult);
132
+
133
+ // Stage 4: Fallback
134
+ return this._toFallback(reloadResult);
135
+ }
136
+
137
+ async _stageRetry(action) {
138
+ const budget = STAGE_BUDGETS.RETRYING;
139
+ let lastResult = null;
140
+ for (let i = 0; i < budget.maxAttempts; i++) {
141
+ if (this._budgetExceeded()) break;
142
+ const delay = budget.backoffMs[i] || budget.backoffMs[budget.backoffMs.length - 1];
143
+ await this._sleep(delay);
144
+ this.stageAttempts.RETRYING++;
145
+ lastResult = await this._timed(action);
146
+ if (lastResult.ok) return lastResult;
147
+ this.lastError = lastResult.error;
148
+ }
149
+ return lastResult || makeResult(false, null, { code: "TIMEOUT", message: "retry exhausted" });
150
+ }
151
+
152
+ async _stageRebind() {
153
+ if (this._budgetExceeded()) return makeResult(false, null, { code: "TIMEOUT", message: "budget exceeded before rebind" });
154
+ this.stageAttempts.REBINDING++;
155
+ return this._timed(this._onRebind);
156
+ }
157
+
158
+ async _stageReload() {
159
+ if (this._budgetExceeded()) return makeResult(false, null, { code: "TIMEOUT", message: "budget exceeded before reload" });
160
+ this.stageAttempts.RELOADING++;
161
+ return this._timed(this._onReload);
162
+ }
163
+
164
+ async _toFallback(lastResult) {
165
+ this.state = STATES.FALLBACK;
166
+ const fallbackResult = await this._onFallback(lastResult);
167
+ if (!fallbackResult?.ok && !this.skipEnabled) {
168
+ this.state = STATES.FAILED;
169
+ }
170
+ return fallbackResult || makeResult(false, null, {
171
+ code: "TIMEOUT",
172
+ message: `All degradation stages exhausted (elapsed: ${this.elapsedMs}ms)`,
173
+ });
174
+ }
175
+
176
+ _budgetExceeded() {
177
+ return this.elapsedMs >= this.totalBudgetMs;
178
+ }
179
+
180
+ async _timed(fn) {
181
+ try {
182
+ return await fn();
183
+ } catch (err) {
184
+ return makeResult(false, null, {
185
+ code: err.code || "UNKNOWN",
186
+ message: err.message || String(err),
187
+ });
188
+ }
189
+ }
190
+
191
+ _sleep(ms) {
192
+ return new Promise(resolve => setTimeout(resolve, ms));
193
+ }
194
+
195
+ toJSON() {
196
+ return {
197
+ state: this.state,
198
+ elapsedMs: this.elapsedMs,
199
+ stageAttempts: { ...this.stageAttempts },
200
+ lastError: this.lastError,
201
+ skipEnabled: this.skipEnabled,
202
+ };
203
+ }
204
+ }
205
+
206
+ export { DegradationStateMachine, STATES, STAGE_BUDGETS, ERROR_CODES, makeResult };
@@ -0,0 +1,20 @@
1
+ {
2
+ "provider": "chatgpt",
3
+ "version": "1.0.0",
4
+ "domains": ["chat.openai.com", "chatgpt.com"],
5
+ "selectors": {
6
+ "inputSelector": "#prompt-textarea",
7
+ "sendButton": "button[data-testid='send-button']",
8
+ "responseSelector": ".markdown.prose",
9
+ "responseContainer": "[data-message-author-role='assistant']",
10
+ "streamingIndicator": ".result-streaming",
11
+ "conversationList": "nav[aria-label='Chat history']"
12
+ },
13
+ "timing": {
14
+ "inputDelayMs": 100,
15
+ "sendDelayMs": 200,
16
+ "pollIntervalMs": 500,
17
+ "streamingTimeoutMs": 45000
18
+ },
19
+ "notes": "ChatGPT DOM selectors - update version when selectors change"
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-devkit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Cross-platform installer and tooling bundle for aigentry-devkit",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,9 +28,7 @@
28
28
  "hud/**",
29
29
  "install.sh",
30
30
  "install.ps1",
31
- "mcp-servers/deliberation/index.js",
32
- "mcp-servers/deliberation/package.json",
33
- "mcp-servers/deliberation/session-monitor.sh",
31
+ "mcp-servers/deliberation/**",
34
32
  "skills/clipboard-image/**",
35
33
  "skills/deliberation/**",
36
34
  "skills/deliberation-executor/**",