@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 +14 -10
- package/bin/aigentry-devkit.js +2 -2
- package/install.ps1 +52 -4
- package/install.sh +60 -13
- package/mcp-servers/deliberation/browser-control-port.js +509 -0
- package/mcp-servers/deliberation/degradation-state-machine.js +206 -0
- package/mcp-servers/deliberation/selectors/chatgpt.json +20 -0
- package/package.json +2 -4
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
|
|
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
|
|
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.
|
|
91
|
+
2. Install skills to `~/.claude/skills/`
|
|
90
92
|
3. Install HUD statusline to `~/.claude/hud/`
|
|
91
|
-
4. Set up 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
|
|
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
|
|
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.
|
|
325
|
-
4.
|
|
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
|
-
├─
|
|
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
|
package/bin/aigentry-devkit.js
CHANGED
|
@@ -15,8 +15,8 @@ function printHelp() {
|
|
|
15
15
|
" aigentry-devkit --help",
|
|
16
16
|
"",
|
|
17
17
|
"Examples:",
|
|
18
|
-
" npx
|
|
19
|
-
" npx
|
|
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
|
-
|
|
99
|
-
Copy-Item -Path (Join-Path $
|
|
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 --
|
|
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 [ -
|
|
72
|
-
|
|
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 "$
|
|
76
|
-
warn "
|
|
89
|
+
if [ ! -d "$skill_dir" ]; then
|
|
90
|
+
warn "Skill source missing: $skill_dir"
|
|
77
91
|
continue
|
|
78
92
|
fi
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
info "
|
|
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" ] || [ "$
|
|
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
|
|
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 --
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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/**",
|