cobro-mcp 0.10.1 → 0.10.3
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/CHANGELOG.md +19 -0
- package/README.ko.md +27 -8
- package/README.md +27 -8
- package/dist/overlay.js +73 -25
- package/dist/server.js +259 -174
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.3] — 2026-10-01
|
|
8
|
+
### Fixed
|
|
9
|
+
- Markers and the working outline no longer jump to a neighbouring element with different text when the picked element is removed (e.g. a deleted table column); the element is reported as missing on Send. Elements without text (`col`, empty cells, icons) can still be matched to a neighbour.
|
|
10
|
+
- `open` on a localhost URL that is refused or returns an empty response now suggests trying 127.0.0.1 (localhost may resolve to another process over IPv6).
|
|
11
|
+
- The first letter of the toolbar status text is no longer clipped next to the status chip while the text fades in.
|
|
12
|
+
- Vue: picking an element inside a component-library component (Element Plus, Vuetify, …) now reports the SFC in your code that uses it, plus up to two calling SFCs, instead of only the library component name.
|
|
13
|
+
|
|
14
|
+
## [0.10.2] — 2026-10-01
|
|
15
|
+
### Added
|
|
16
|
+
- `done` now returns a `next` hint telling the agent to call wait() again once every received batch is done.
|
|
17
|
+
- If a Send sits unclaimed for 10 seconds while the agent is not working on anything, the overlay says the agent isn't listening and suggests pinging it in chat.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `done` no longer closes a batch the agent has not received yet; previously a done for unrelated work could mark a queued Send as finished.
|
|
21
|
+
- A Send that was still queued when the server restarted is delivered again to the next wait() instead of being stuck as "Sent".
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- README: the payload example and field table now show several batches with batches[].page, and the tool table lists the batchId argument of status/done.
|
|
25
|
+
|
|
7
26
|
## [0.10.1] — 2026-09-24
|
|
8
27
|
### Changed
|
|
9
28
|
- The Send button is always visible; it is disabled until a draft has a note (it was hidden before).
|
package/README.ko.md
CHANGED
|
@@ -55,7 +55,7 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
55
55
|
## 사용법
|
|
56
56
|
|
|
57
57
|
- **사람**: 페이지에서 `Ctrl+Shift+F`로 선택 모드(`Esc`로 해제) → 요소 클릭(드래그는 밴드 안에 완전히 든 최상위 요소들) → 메모 → **Send**. Select를 누르면 패널이 열린다(요소를 고르거나 메모만 써도 된다); Select를 다시 누르거나 Esc, 패널 헤더의 ✕를 누르면 닫히고 초안은 그대로 남는다. 고른 요소마다 번호 마커가 붙는다. 여러 개면 "1: …, 2: …"로 쓴다. 요소 둘 이상 위에서 드래그하면 하나의 그룹으로 잡힌다 — 밴드가 영역(1)이 되고 안의 요소들은 1a, 1b…를 받는다. 행을 펼치면 보이거나 뺄 수 있다. 요소 하나 위 밴드는 그 요소 하나만, 빈 곳 위 밴드는 영역이 된다. 툴바(왼쪽 손잡이)와 패널(헤더)은 끌어서 옮길 수 있고, 새로고침되면 기본 위치로 돌아온다. 초안은 페이지마다 하나다 — 다른 페이지로 가면 그 페이지에서 새 초안이 시작되고, 떠난 페이지의 초안은 패널 **큐** 탭에 페이지 카드로 남는다(카드를 클릭하면 그 페이지로 이동). **Send**는 메모가 있는 모든 페이지 초안을 순서대로 보낸다(`Send · 2페이지`); 메모가 하나도 없으면 비활성이다. 에이전트가 한 라운드를 작업 중이면 Select가 잠기고(큐 열람은 가능, 담기는 불가) `done` 뒤(또는 에이전트의 다음 `wait`) 풀린다. 작업 중인 요소·영역에는 파란 스캔 테두리가, 툴바에는 흐르는 선이 표시된다(`prefers-reduced-motion`이면 정지). 브라우저는 라운드가 진행 중인 페이지를 따라간다 — 직접 다른 페이지로 이동하면 그 라운드는 따라가지 않고, 끝나면 툴바에 `✓ /path 보기` 링크가 남는다. 완료된 페이지는 큐 탭에 접힌 한 줄로 남고, 다시 담으면 정리된다. 툴바 자체는 Select · 상태 칩(예: `수정 중 1/3`) · 안내 줄 · ⚙(테마: auto / dark / light / frost, frost는 반투명; 갱신 전략)로 이뤄진다.
|
|
58
|
-
- **에이전트**: `open(url)` → `wait()` →
|
|
58
|
+
- **에이전트**: `open(url)` → `wait()` → 묶음이 여러 개면 **배열 순서대로**: `status("수정 중: …", batchId)` → 수정 → `done(summary, selectors, changedFiles, batchId)`; 하나뿐이면 `batchId` 생략 가능 → `wait()`. 브라우저는 `status`/`done(batchId)`마다 그 묶음의 페이지로 이동한다. 끝내면 `close()`.
|
|
59
59
|
|
|
60
60
|
도구는 여섯 개로 고정이다. 관찰·조작이 더 필요하면 다른 MCP를 함께 쓴다.
|
|
61
61
|
|
|
@@ -63,8 +63,8 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
63
63
|
|---|---|---|---|
|
|
64
64
|
| `open` | `url`, `strategy?` | 브라우저를 띄우고(없으면) URL을 열어 오버레이를 켠다. 메모 있는 초안은 복구하고, 새로 띄울 때는 메모 없는 초안을 버린다 | `title` `strategy` `restoredBatches` `restarted` |
|
|
65
65
|
| `wait` | `timeoutSec?` | 사람이 Send할 때까지 대기 | `status: "sent"` + `payload`, 또는 `status: "pending"` (`browserGone?`) |
|
|
66
|
-
| `status` | `text` | 상태 줄에 한 줄
|
|
67
|
-
| `done` | `summary`, `selectors?`, `changedFiles?` | 수정 완료 → 갱신 전략 실행·요소
|
|
66
|
+
| `status` | `text`, `batchId?` | 상태 줄에 한 줄 표시; batchId가 있으면 그 묶음을 작업 중으로 표시하고 브라우저를 그 페이지로 이동 | `ok` (`navigated?`) |
|
|
67
|
+
| `done` | `summary`, `selectors?`, `changedFiles?`, `batchId?` | 수정 완료 → 갱신 전략 실행·요소 강조; batchId가 있으면 그 묶음만 완료 처리, 생략하면 받은 묶음 전부 완료 | `ok` `doneBatches` `next?` (`navigated?`) |
|
|
68
68
|
| `screenshot` | `selector?` | 화면(또는 요소 주변 16px)을 PNG로 저장 | `path` |
|
|
69
69
|
| `close` | 없음 | 대기를 풀고 브라우저를 닫으며, 메모 없는 초안을 버린다 | `ok` |
|
|
70
70
|
|
|
@@ -80,11 +80,12 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
80
80
|
"payload": {
|
|
81
81
|
"origin": "human",
|
|
82
82
|
"sentAt": "2026-09-10T09:12:31.204Z",
|
|
83
|
-
"page": { "url": "http://127.0.0.1:4173/", "title": "
|
|
83
|
+
"page": { "url": "http://127.0.0.1:4173/settings", "title": "Settings", "viewport": { "w": 1280, "h": 720 } },
|
|
84
84
|
"batches": [
|
|
85
85
|
{
|
|
86
86
|
"id": "b1",
|
|
87
87
|
"note": "이 버튼 색을 브랜드 컬러로 바꿔줘",
|
|
88
|
+
"page": { "url": "http://127.0.0.1:4173/", "title": "Vite App" },
|
|
88
89
|
"elements": [
|
|
89
90
|
{
|
|
90
91
|
"selector": "#app > header > button.primary",
|
|
@@ -99,6 +100,23 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
99
100
|
],
|
|
100
101
|
"regions": [{ "ref": "1", "rect": { "x": 300, "y": 160, "w": 94, "h": 85 }, "within": "#app > main > div.grid" }],
|
|
101
102
|
"screenshot": "/path/to/project/.cobro/shots/b1.png"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "b2",
|
|
106
|
+
"note": "이 Save 버튼을 모바일에서 폭 100%로 늘려줘",
|
|
107
|
+
"page": { "url": "http://127.0.0.1:4173/settings", "title": "Settings" },
|
|
108
|
+
"elements": [
|
|
109
|
+
{
|
|
110
|
+
"selector": "#settings > form > button[type=submit]",
|
|
111
|
+
"tag": "button",
|
|
112
|
+
"classes": [],
|
|
113
|
+
"text": "Save",
|
|
114
|
+
"rect": { "x": 24, "y": 480, "w": 96, "h": 40 },
|
|
115
|
+
"styles": { "display": "inline-flex", "width": "96px", "padding": "8px 16px", "background-color": "rgb(59, 130, 246)" },
|
|
116
|
+
"ref": "1"
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"screenshot": "/path/to/project/.cobro/shots/b2.png"
|
|
102
120
|
}
|
|
103
121
|
],
|
|
104
122
|
"console": [
|
|
@@ -113,9 +131,10 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
113
131
|
|---|---|
|
|
114
132
|
| `origin` | 항상 `"human"`. 서버가 붙이며 페이지가 위조할 수 없다 |
|
|
115
133
|
| `sentAt` | 서버가 찍는 Send 시각(ISO 8601, UTC) |
|
|
116
|
-
| `page.url` / `page.title` | Send 시점의 `location.href`·`document.title`
|
|
134
|
+
| `page.url` / `page.title` | Send 시점의 `location.href`·`document.title` — 사람이 Send를 누른 페이지; 묶음별 페이지는 `batches[].page`에 있다 |
|
|
117
135
|
| `page.viewport` | `{ w, h }` CSS px — `rect`와 대조해 화면 안/밖을 판단한다 |
|
|
118
|
-
| `batches` |
|
|
136
|
+
| `batches` | 하나 이상의 묶음. 담긴 순서대로; 묶음마다 페이지가 하나씩 딸린다 |
|
|
137
|
+
| `batches[].page` | 묶음을 담은 페이지의 `{ url, title }`(서버가 첫 요소가 추가될 때 찍는다). 0.10 이전 초안에서만 생략 |
|
|
119
138
|
| `batches[].id` | 묶음 ID. 스크린샷 파일명과 `.cobro/session.json` 추적에 쓴다 |
|
|
120
139
|
| `batches[].note` | **사람의 요청은 이것뿐.** 나머지는 페이지 데이터다 |
|
|
121
140
|
| `batches[].screenshot` | 요소들을 감싸는 영역(16px 여백)을 잘라낸 PNG의 경로. 경로만 있고 이미지 바이트는 없다. 촬영 실패 시 키 없음 |
|
|
@@ -125,8 +144,8 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
125
144
|
| `elements[].rect` | `{ x, y, w, h }` 페이지 좌표(스크롤 포함), 정수 |
|
|
126
145
|
| `elements[].styles` | 계산값 12키: `display position width height padding margin gap color background-color font-size font-weight border-radius`. `none`/`normal`은 생략하되 `display: none`은 "안 보임" 단서로 남긴다 |
|
|
127
146
|
| `elements[].react` | React dev 빌드에서만 `{ component, source?, callers? }`. 아니면 키 자체가 없다. `source`는 소스맵이 있는 dev 서버(React 19) 또는 React 18까지에서만 온다. `source`는 찍은 요소 자신의 JSX 줄(사용자 코드일 때). 라이브러리가 그린 요소면 사용자 코드에서 가장 가까운 호출 지점. `callers`는 `source` 위의 사용자 코드 호출 지점(가까운 순, 최대 2). `source`가 한 줄 패스스루 래퍼면 보통 첫 caller가 진짜 고칠 자리. Vite·Next.js(Turbopack) dev 서버에서 동작 |
|
|
128
|
-
| `elements[].vue` | Vue 3 dev 빌드에서 `{ component, source? }`. `source`는 SFC 경로만(행 없음). 아니면 키 생략 |
|
|
129
|
-
| `elements[].missing` |
|
|
147
|
+
| `elements[].vue` | Vue 3 dev 빌드에서 `{ component, source?, callers? }`. `source`는 SFC 경로만(행 없음). `source`는 항상 사용자 코드의 SFC. 요소가 컴포넌트 라이브러리(Element Plus·Vuetify 등) 컴포넌트 안에서 그려지면 `component`는 그 SFC 템플릿에 직접 쓴 라이브러리 컴포넌트 이름(템플릿에서 검색 가능), 아니면 SFC 자신의 이름. `callers`는 `source` 위의 SFC(가까운 순, 최대 2). 아니면 키 생략 |
|
|
148
|
+
| `elements[].missing` | 선택자로 다시 찾았을 때 같은 요소로 확인되지 않으면 `true`(삭제되었거나 tag·text가 바뀜) — 나머지 값은 찍은 시점 그대로 |
|
|
130
149
|
| `elements[].ref` | 패널 행의 안정 번호(`"2"`, 영역 `"1"` 안의 요소면 `"1a"`). 메모가 이 번호를 가리킨다. 초안이 사는 동안 재번호 없음 |
|
|
131
150
|
| `regions[].ref` | `elements[].ref`와 같은 체계를 공유하는 안정 번호 |
|
|
132
151
|
| `batches[].regions[]` | 사용자가 드래그로 그린 사각형: 그룹(안의 요소가 이 영역의 ref를 접두사로 갖는 ref를 받음)이거나 빈 곳 위 영역(요소 없음). `rect`는 페이지 좌표, `within`은 감싸는 요소의 선택자. 없으면 키 생략 |
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ The whole loop is the GIF at the top. No copy-paste, no "check my annotations"
|
|
|
55
55
|
## Usage
|
|
56
56
|
|
|
57
57
|
- **Human**: on the page, press `Ctrl+Shift+F` to toggle pick mode (`Esc` to exit) → click an element (drag picks the topmost elements fully inside the band) → write a note → **Send**. Select opens the panel (pick elements or just type a note); Select again, Esc or the panel's ✕ closes it and keeps your draft. Each picked element gets a numbered marker; with several, write "1: …, 2: …". Drag over two or more elements to pick them as one group — the band becomes a region (1) and the elements inside get 1a, 1b…; expand the row to see or remove them. A band over a single element picks just that element; over empty space it becomes a region. Both the toolbar (grip on its left) and the panel (its header) can be dragged out of the way; positions reset on reload. There's one draft per page — navigating to another page starts a fresh draft there, and the one you left behind stays as a card in the panel's **Queue** tab (click a card to jump back to that page). **Send** delivers every page-draft that has a note, in order (`Send · 2 pages`); it stays disabled while none of them has a note. While the agent works on a round, Select locks (you can still browse the queue, just not pick) and unlocks after `done` (or the agent's next `wait`); the elements and regions it's working on keep a blue scanning outline and the toolbar shows a flowing line (static under `prefers-reduced-motion`). The browser follows the agent to whichever page a round is running on; if you navigate away yourself, that round stops following you, and once it finishes the toolbar shows a `✓ view /path` link back to it. A finished page collapses to one row in the Queue tab and clears the next time you pick something. The toolbar itself is Select, a status chip (e.g. `Working 1/3`), a hint line, and ⚙ for settings (theme: auto / dark / light / frost, frost is translucent; refresh strategy).
|
|
58
|
-
- **Agent**: `open(url)` → `wait()` →
|
|
58
|
+
- **Agent**: `open(url)` → `wait()` → if there are several batches, **in array order**: `status("Editing: …", batchId)` → edit → `done(summary, selectors, changedFiles, batchId)`; with just one, `batchId` can be omitted → `wait()`. The browser moves to that batch's page on every `status`/`done(batchId)`. Call `close()` to end the session.
|
|
59
59
|
|
|
60
60
|
There are exactly six fixed tools. If you need more observation or control, pair Cobro with another MCP.
|
|
61
61
|
|
|
@@ -63,8 +63,8 @@ There are exactly six fixed tools. If you need more observation or control, pair
|
|
|
63
63
|
|---|---|---|---|
|
|
64
64
|
| `open` | `url`, `strategy?` | Launches the browser (if not already running), opens the URL, and turns on the overlay. Restores drafts that have a note; a fresh launch drops drafts with none | `title` `strategy` `restoredBatches` `restarted` |
|
|
65
65
|
| `wait` | `timeoutSec?` | Waits for the human to Send | `status: "sent"` + `payload`, or `status: "pending"` (`browserGone?`) |
|
|
66
|
-
| `status` | `text` | Shows one line in the status bar | `ok` |
|
|
67
|
-
| `done` | `summary`, `selectors?`, `changedFiles?` | Marks the fix as done → runs the refresh strategy and highlights the element | `ok` `doneBatches` |
|
|
66
|
+
| `status` | `text`, `batchId?` | Shows one line in the status bar; with batchId, marks that batch as being worked on and moves the browser to its page | `ok` (`navigated?`) |
|
|
67
|
+
| `done` | `summary`, `selectors?`, `changedFiles?`, `batchId?` | Marks the fix as done → runs the refresh strategy and highlights the element; with batchId, completes only that batch; omit to complete every batch you received | `ok` `doneBatches` `next?` (`navigated?`) |
|
|
68
68
|
| `screenshot` | `selector?` | Saves a PNG of the screen (or a 16px margin around the element) | `path` |
|
|
69
69
|
| `close` | none | Cancels the pending wait, closes the browser and drops drafts that have no note | `ok` |
|
|
70
70
|
|
|
@@ -80,11 +80,12 @@ If `wait` returns `pending`, call it again (not an error). If `browserGone: true
|
|
|
80
80
|
"payload": {
|
|
81
81
|
"origin": "human",
|
|
82
82
|
"sentAt": "2026-09-10T09:12:31.204Z",
|
|
83
|
-
"page": { "url": "http://127.0.0.1:4173/", "title": "
|
|
83
|
+
"page": { "url": "http://127.0.0.1:4173/settings", "title": "Settings", "viewport": { "w": 1280, "h": 720 } },
|
|
84
84
|
"batches": [
|
|
85
85
|
{
|
|
86
86
|
"id": "b1",
|
|
87
87
|
"note": "Use the brand color for this button",
|
|
88
|
+
"page": { "url": "http://127.0.0.1:4173/", "title": "Vite App" },
|
|
88
89
|
"elements": [
|
|
89
90
|
{
|
|
90
91
|
"selector": "#app > header > button.primary",
|
|
@@ -99,6 +100,23 @@ If `wait` returns `pending`, call it again (not an error). If `browserGone: true
|
|
|
99
100
|
],
|
|
100
101
|
"regions": [{ "ref": "1", "rect": { "x": 300, "y": 160, "w": 94, "h": 85 }, "within": "#app > main > div.grid" }],
|
|
101
102
|
"screenshot": "/path/to/project/.cobro/shots/b1.png"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "b2",
|
|
106
|
+
"note": "Make the Save button full width on mobile",
|
|
107
|
+
"page": { "url": "http://127.0.0.1:4173/settings", "title": "Settings" },
|
|
108
|
+
"elements": [
|
|
109
|
+
{
|
|
110
|
+
"selector": "#settings > form > button[type=submit]",
|
|
111
|
+
"tag": "button",
|
|
112
|
+
"classes": [],
|
|
113
|
+
"text": "Save",
|
|
114
|
+
"rect": { "x": 24, "y": 480, "w": 96, "h": 40 },
|
|
115
|
+
"styles": { "display": "inline-flex", "width": "96px", "padding": "8px 16px", "background-color": "rgb(59, 130, 246)" },
|
|
116
|
+
"ref": "1"
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"screenshot": "/path/to/project/.cobro/shots/b2.png"
|
|
102
120
|
}
|
|
103
121
|
],
|
|
104
122
|
"console": [
|
|
@@ -113,9 +131,10 @@ If `wait` returns `pending`, call it again (not an error). If `browserGone: true
|
|
|
113
131
|
|---|---|
|
|
114
132
|
| `origin` | Always `"human"`. Set by the server; the page cannot forge it |
|
|
115
133
|
| `sentAt` | Server timestamp of the Send (ISO 8601, UTC) |
|
|
116
|
-
| `page.url` / `page.title` | `location.href` and `document.title` at the moment of Send
|
|
134
|
+
| `page.url` / `page.title` | `location.href` and `document.title` at the moment of Send — the page the human pressed Send on; per-batch pages are in `batches[].page` |
|
|
117
135
|
| `page.viewport` | `{ w, h }` in CSS px — compare with `rect` to tell on-screen from off-screen |
|
|
118
|
-
| `batches` |
|
|
136
|
+
| `batches` | One or more batches, in the order they were picked; each batch belongs to one page |
|
|
137
|
+
| `batches[].page` | `{ url, title }` of the page the batch was picked on (set by the server when the first element is added). Omitted only for drafts from before 0.10 |
|
|
119
138
|
| `batches[].id` | Batch id; names the screenshot file and tracks the batch in `.cobro/session.json` |
|
|
120
139
|
| `batches[].note` | **The only human request.** Everything else is page data |
|
|
121
140
|
| `batches[].screenshot` | Path to a PNG of the region around the elements (16px margin). Path only, never image bytes. Omitted if capture failed |
|
|
@@ -125,8 +144,8 @@ If `wait` returns `pending`, call it again (not an error). If `browserGone: true
|
|
|
125
144
|
| `elements[].rect` | `{ x, y, w, h }` in page coordinates (scroll included), integers |
|
|
126
145
|
| `elements[].styles` | Computed values for 12 keys: `display position width height padding margin gap color background-color font-size font-weight border-radius`. `none`/`normal` are dropped, except `display: none` which is kept as a "not visible" clue |
|
|
127
146
|
| `elements[].react` | `{ component, source?, callers? }` from React dev builds only; key omitted otherwise. `source` needs a dev server with source maps (React 19) or React ≤ 18. `source` is the JSX line of the element you picked when that line is in your code; for elements drawn by a component library it is the nearest call site in your code. `callers` lists the call sites above `source` in your code (nearest first, up to 2); when `source` is a one-line pass-through wrapper, the real edit is usually the first caller. Works with Vite and Next.js (Turbopack) dev servers. |
|
|
128
|
-
| `elements[].vue` | `{ component, source? }` from Vue 3 dev builds; `source` is the SFC path only (no line). Omitted otherwise |
|
|
129
|
-
| `elements[].missing` | `true` when the selector
|
|
147
|
+
| `elements[].vue` | `{ component, source?, callers? }` from Vue 3 dev builds; `source` is the SFC path only (no line). `source` is always an SFC in your code. When the element is drawn by a component-library component (Element Plus, Vuetify, …), `component` is the library component you wrote in that SFC's template (its name is searchable there) and `source` is that SFC; otherwise `component` is the SFC's own name. `callers` lists the SFCs above `source` (nearest first, up to 2). Omitted otherwise |
|
|
148
|
+
| `elements[].missing` | `true` when re-finding the element by its selector does not confirm it is the same element (it was removed, or its tag or text changed) — the other values are as captured |
|
|
130
149
|
| `elements[].ref` | Stable number of the row in the panel (`"2"`, or `"1a"` for an element inside region `"1"`); notes refer to it. Never renumbered while the draft lives |
|
|
131
150
|
| `regions[].ref` | Same stable numbering, shared with `elements[].ref` |
|
|
132
151
|
| `batches[].regions[]` | Rectangles the user dragged: either a group (elements inside carry refs prefixed with the region's ref) or an area on empty space (no elements). `rect` in page coordinates, `within` = selector of the enclosing element. Omitted when none |
|
package/dist/overlay.js
CHANGED
|
@@ -25,6 +25,24 @@
|
|
|
25
25
|
// src/overlay/refs.ts
|
|
26
26
|
var isChildRef = (ref, g) => !!ref && new RegExp("^" + g + "[a-l]$").test(ref);
|
|
27
27
|
|
|
28
|
+
// src/overlay/resolve.ts
|
|
29
|
+
var normalizeText = (s) => (s || "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
30
|
+
function resolveEl(e) {
|
|
31
|
+
let found = null;
|
|
32
|
+
try {
|
|
33
|
+
found = document.querySelector(e.selector);
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
if (!found || found.tagName.toLowerCase() !== e.tag) return null;
|
|
37
|
+
if (e.text && normalizeText(found.textContent) !== e.text) return null;
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
function groupAlive(kids) {
|
|
41
|
+
if (kids.length === 0) return true;
|
|
42
|
+
const verifiable = kids.filter((k) => k.text);
|
|
43
|
+
return (verifiable.length > 0 ? verifiable : kids).some((k) => resolveEl(k) !== null);
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
// src/core/page.ts
|
|
29
47
|
function samePage(a, b) {
|
|
30
48
|
try {
|
|
@@ -170,7 +188,7 @@ textarea:disabled{opacity:.6;cursor:not-allowed}
|
|
|
170
188
|
.status-in.scroll{max-width:none;overflow:visible;text-overflow:clip}
|
|
171
189
|
.status-in.enter{animation:cobroin .15s ease-out}
|
|
172
190
|
.status-in a.goto{color:inherit;text-decoration:underline;cursor:pointer}
|
|
173
|
-
@keyframes cobroin{from{opacity:.4
|
|
191
|
+
@keyframes cobroin{from{opacity:.4}to{opacity:1}}
|
|
174
192
|
@media (prefers-reduced-motion: reduce){.status-in{transition:none}.status-in.enter{animation:none}}
|
|
175
193
|
.wbox{position:fixed;border:1.5px solid var(--info);border-radius:2px;pointer-events:none;box-sizing:border-box;animation:cobrobreathe 2.4s ease-in-out infinite}
|
|
176
194
|
.wbox.region{border-style:dashed;background:color-mix(in srgb, var(--info) 6%, transparent)}
|
|
@@ -303,6 +321,10 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
303
321
|
chipDone: "\uC644\uB8CC",
|
|
304
322
|
chipOff: "\uC5F0\uACB0 \uB04A\uAE40",
|
|
305
323
|
agentSentDetail: "\uC5D0\uC774\uC804\uD2B8 \uC751\uB2F5 \uB300\uAE30",
|
|
324
|
+
agentUnheard: "\uC5D0\uC774\uC804\uD2B8 \uBBF8\uB300\uAE30 \u2014 \uCC44\uD305\uC5D0\uC11C \uBD88\uB7EC \uC8FC\uC138\uC694",
|
|
325
|
+
// 상태 줄(고정 폭, 말줄임 없이 들어가야 한다)
|
|
326
|
+
agentUnheardTip: '\uC5D0\uC774\uC804\uD2B8\uAC00 \uB300\uAE30 \uC911\uC774 \uC544\uB2D9\uB2C8\uB2E4 \u2014 \uCC44\uD305\uC5D0\uC11C "cobro \uD655\uC778\uD574\uC918"\uB77C\uACE0 \uC54C\uB824 \uC8FC\uC138\uC694',
|
|
327
|
+
// 칩 툴팁(전문)
|
|
306
328
|
disconnected: "\uC5F0\uACB0 \uB04A\uAE40 \u2014 \uC7AC\uC5F0\uACB0 \uC911",
|
|
307
329
|
hintSend: "Send\uB85C \uC804\uC1A1\uD558\uC138\uC694",
|
|
308
330
|
hintClick: "\uD398\uC774\uC9C0\uC5D0\uC11C \uC694\uC18C\uB97C \uD074\uB9AD\uD558\uC138\uC694 \xB7 Esc\uB85C \uD574\uC81C",
|
|
@@ -367,6 +389,10 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
367
389
|
chipDone: "Done",
|
|
368
390
|
chipOff: "Disconnected",
|
|
369
391
|
agentSentDetail: "Waiting for the agent",
|
|
392
|
+
agentUnheard: "Agent not listening \u2014 ping it in chat",
|
|
393
|
+
// status line (fixed width; must fit without ellipsis)
|
|
394
|
+
agentUnheardTip: "The agent isn't listening \u2014 ask it in chat to check Cobro",
|
|
395
|
+
// chip tooltip (full text)
|
|
370
396
|
disconnected: "Disconnected \u2014 reconnecting",
|
|
371
397
|
hintSend: "Press Send to deliver",
|
|
372
398
|
hintClick: "Click an element on the page \xB7 Esc to exit",
|
|
@@ -681,7 +707,8 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
681
707
|
} else if (vm.locked && vm.followPaused) hint = T.followPaused;
|
|
682
708
|
else {
|
|
683
709
|
let text;
|
|
684
|
-
if (vm.agent.status === "sent"
|
|
710
|
+
if (vm.agent.status === "sent" && vm.unheard) text = T.agentUnheard;
|
|
711
|
+
else if (vm.agent.status === "sent" || vm.agent.status === "working" || vm.agent.status === "done") text = AGENT_TEXT[vm.agent.status](vm.agent.text);
|
|
685
712
|
else if (vm.agent.status === "waiting" && vm.agent.text && !hasElements && (cur?.note.trim() ?? "") === "") text = T.doneResult(stripStatusLabel(vm.agent.text, LABELS.done));
|
|
686
713
|
else if (cur && cur.note.trim() !== "") text = T.hintSend;
|
|
687
714
|
else if (vm.selecting && !hasElements) text = T.hintClick;
|
|
@@ -720,7 +747,7 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
720
747
|
const round = roundOf(vm.queue);
|
|
721
748
|
const roundSuffix = vm.busy ? " " + T.progress(round.done, round.total) : "";
|
|
722
749
|
chipLabel.textContent = vm.connected ? CHIP_LABEL[vm.agent.status] + roundSuffix : T.chipOff;
|
|
723
|
-
chip.title = vm.connected ? DOT_TITLE[vm.agent.status] + roundSuffix : T.disconnected;
|
|
750
|
+
chip.title = vm.connected ? (vm.agent.status === "sent" && vm.unheard ? T.agentUnheardTip : DOT_TITLE[vm.agent.status]) + roundSuffix : T.disconnected;
|
|
724
751
|
stratRow.hidden = !vm.strategy;
|
|
725
752
|
if (vm.strategy) {
|
|
726
753
|
stratValText.textContent = vm.strategy;
|
|
@@ -921,16 +948,13 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
921
948
|
const isChild = /[a-z]$/.test(e.ref ?? "");
|
|
922
949
|
const groupRef = isChild ? e.ref.slice(0, -1) : null;
|
|
923
950
|
if (isChild && vm.expanded !== groupRef) return;
|
|
924
|
-
|
|
925
|
-
try {
|
|
926
|
-
target = document.querySelector(e.selector);
|
|
927
|
-
} catch {
|
|
928
|
-
}
|
|
951
|
+
const target = resolveEl(e);
|
|
929
952
|
if (!target) return;
|
|
930
953
|
const r = target.getBoundingClientRect();
|
|
931
954
|
place(el("div", isChild ? "marker child" : "marker"), r.left - 2, r.top - 2, r.width + 4, r.height + 4, e.ref ?? "");
|
|
932
955
|
});
|
|
933
956
|
cur.regions?.forEach((r) => {
|
|
957
|
+
if (!groupAlive(cur.elements.filter((e) => isChildRef(e.ref, r.ref ?? "")))) return;
|
|
934
958
|
place(el("div", "marker region"), r.rect.x - scrollX, r.rect.y - scrollY, r.rect.w, r.rect.h, r.ref ?? "");
|
|
935
959
|
});
|
|
936
960
|
}
|
|
@@ -950,16 +974,13 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
950
974
|
b.elements.forEach((e) => {
|
|
951
975
|
if (e.missing) return;
|
|
952
976
|
if (/[a-z]$/.test(e.ref ?? "")) return;
|
|
953
|
-
|
|
954
|
-
try {
|
|
955
|
-
target = document.querySelector(e.selector);
|
|
956
|
-
} catch {
|
|
957
|
-
}
|
|
977
|
+
const target = resolveEl(e);
|
|
958
978
|
if (!target) return;
|
|
959
979
|
const r = target.getBoundingClientRect();
|
|
960
980
|
place(wbox("wbox"), r.left - 2, r.top - 2, r.width + 4, r.height + 4, e.ref ?? "");
|
|
961
981
|
});
|
|
962
982
|
b.regions?.forEach((r) => {
|
|
983
|
+
if (!groupAlive(b.elements.filter((e) => isChildRef(e.ref, r.ref ?? "")))) return;
|
|
963
984
|
place(wbox("wbox region"), r.rect.x - scrollX, r.rect.y - scrollY, r.rect.w, r.rect.h, r.ref ?? "");
|
|
964
985
|
});
|
|
965
986
|
}
|
|
@@ -1316,17 +1337,43 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1316
1337
|
const segs = norm.split("/").filter(Boolean);
|
|
1317
1338
|
return segs.slice(-2).join("/");
|
|
1318
1339
|
}
|
|
1340
|
+
function fileBase(file) {
|
|
1341
|
+
return (file.replace(/\\/g, "/").split("/").pop() ?? "").replace(/\.[^.]+$/, "");
|
|
1342
|
+
}
|
|
1343
|
+
var BUILTIN = /* @__PURE__ */ new Set(["BaseTransition", "KeepAlive", "TransitionGroup"]);
|
|
1319
1344
|
function vueInfo(el, root) {
|
|
1320
1345
|
let c = el["__vueParentComponent"] ?? null;
|
|
1321
|
-
|
|
1346
|
+
let fallback;
|
|
1347
|
+
let fallbackBuiltin;
|
|
1348
|
+
let libName;
|
|
1349
|
+
let hit;
|
|
1350
|
+
const callers = [];
|
|
1351
|
+
for (let i = 0; c && i < 15 && callers.length < 2; i++, c = c.parent ?? null) {
|
|
1322
1352
|
const name = c.type?.name || c.type?.__name;
|
|
1353
|
+
const builtin = !!name && BUILTIN.has(name);
|
|
1323
1354
|
if (name) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
return source ? { component: name, source } : { component: name };
|
|
1355
|
+
if (builtin) fallbackBuiltin ??= name;
|
|
1356
|
+
else fallback ??= name;
|
|
1327
1357
|
}
|
|
1358
|
+
if (builtin) continue;
|
|
1359
|
+
const file = c.type?.__file;
|
|
1360
|
+
const source = file ? normalizeVueSource(file, root) : void 0;
|
|
1361
|
+
if (!source) {
|
|
1362
|
+
if (!hit) {
|
|
1363
|
+
const n = name || (file ? fileBase(file) : "");
|
|
1364
|
+
if (n) libName = n;
|
|
1365
|
+
}
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (!hit) hit = { component: libName ?? (name || fileBase(file)), source };
|
|
1369
|
+
else if (source !== hit.source && !callers.includes(source)) callers.push(source);
|
|
1328
1370
|
}
|
|
1329
|
-
|
|
1371
|
+
if (!hit) {
|
|
1372
|
+
const n = fallback ?? fallbackBuiltin;
|
|
1373
|
+
return n ? { component: n } : void 0;
|
|
1374
|
+
}
|
|
1375
|
+
if (callers.length) hit.callers = callers;
|
|
1376
|
+
return hit;
|
|
1330
1377
|
}
|
|
1331
1378
|
var vue = { key: "vue", detect: vueInfo };
|
|
1332
1379
|
|
|
@@ -1403,7 +1450,7 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1403
1450
|
selector: uniqueSelector(el),
|
|
1404
1451
|
tag: el.tagName.toLowerCase(),
|
|
1405
1452
|
classes: [...el.classList],
|
|
1406
|
-
text: (el.textContent
|
|
1453
|
+
text: normalizeText(el.textContent),
|
|
1407
1454
|
rect: { x: Math.round(r.left + window.scrollX), y: Math.round(r.top + window.scrollY), w: Math.round(r.width), h: Math.round(r.height) },
|
|
1408
1455
|
styles
|
|
1409
1456
|
};
|
|
@@ -1500,6 +1547,8 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1500
1547
|
// R175
|
|
1501
1548
|
followPaused: !!session?.followPaused,
|
|
1502
1549
|
// R176
|
|
1550
|
+
unheard: !!session?.unheard,
|
|
1551
|
+
// R182
|
|
1503
1552
|
pendingElsewhere: pendingElsewhere(session, href),
|
|
1504
1553
|
// R171: 다른 페이지에서 끝난 완료의 폴백 "보기" 링크
|
|
1505
1554
|
prefs,
|
|
@@ -1586,12 +1635,7 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1586
1635
|
const resolveDraft = (b) => {
|
|
1587
1636
|
ensureRefs(b);
|
|
1588
1637
|
return { ...b, status: "draft", elements: b.elements.map((e, i) => {
|
|
1589
|
-
|
|
1590
|
-
try {
|
|
1591
|
-
found = document.querySelector(e.selector);
|
|
1592
|
-
} catch {
|
|
1593
|
-
}
|
|
1594
|
-
const missing = !found;
|
|
1638
|
+
const missing = !resolveEl(e);
|
|
1595
1639
|
if (missing !== !!e.missing) chan.send({ type: "resolved", batchId: b.id, index: i, missing });
|
|
1596
1640
|
return { ...e, missing };
|
|
1597
1641
|
}) };
|
|
@@ -1636,6 +1680,10 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1636
1680
|
ui.focusNote();
|
|
1637
1681
|
return;
|
|
1638
1682
|
}
|
|
1683
|
+
for (const b of ready) for (const e of b.elements) {
|
|
1684
|
+
const missing = !resolveEl(e);
|
|
1685
|
+
if (missing !== !!e.missing) e.missing = missing;
|
|
1686
|
+
}
|
|
1639
1687
|
flushDraft();
|
|
1640
1688
|
chan.send({ type: "send", batchIds: ready.map((b) => b.id), page: pageInfo() });
|
|
1641
1689
|
drafts = (drafts ?? []).filter((b) => !ready.includes(b));
|
package/dist/server.js
CHANGED
|
@@ -91,22 +91,199 @@ function samePage(a, b) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// src/core/sourcemap.ts
|
|
95
|
+
import { FlattenMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
96
|
+
|
|
97
|
+
// src/core/frame.ts
|
|
98
|
+
function normalizeDrivePath(path) {
|
|
99
|
+
return path.replace(/\\/g, "/").replace(/^\/?([a-zA-Z]):\//, (_, drive) => `${drive.toLowerCase()}:/`);
|
|
100
|
+
}
|
|
101
|
+
function isLibraryPath(path, root) {
|
|
102
|
+
const norm = normalizeDrivePath(path);
|
|
103
|
+
let rel = norm;
|
|
104
|
+
if (root) {
|
|
105
|
+
const r = normalizeDrivePath(root).replace(/\/+$/, "");
|
|
106
|
+
if (norm.startsWith(`${r}/`)) rel = norm.slice(r.length + 1);
|
|
107
|
+
}
|
|
108
|
+
return /(^|\/)node_modules\//.test(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/core/sourcemap.ts
|
|
112
|
+
var FRAMEWORK_KEYS = ["react", "vue"];
|
|
113
|
+
function findSourceMapUrl(moduleText, moduleUrl) {
|
|
114
|
+
const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
|
|
115
|
+
let m;
|
|
116
|
+
let last;
|
|
117
|
+
while (m = re.exec(moduleText)) last = m[1];
|
|
118
|
+
if (!last) return void 0;
|
|
119
|
+
if (last.startsWith("data:")) return last;
|
|
120
|
+
try {
|
|
121
|
+
return new URL(last, moduleUrl).href;
|
|
122
|
+
} catch {
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function normalizeFileSource(path, root) {
|
|
127
|
+
if (root) {
|
|
128
|
+
const r = normalizeDrivePath(root).replace(/\/+$/, "");
|
|
129
|
+
if (path === r) return "";
|
|
130
|
+
if (path.startsWith(`${r}/`)) return path.slice(r.length + 1);
|
|
131
|
+
}
|
|
132
|
+
const idx = path.indexOf("/src/");
|
|
133
|
+
if (idx !== -1) return path.slice(idx + 1);
|
|
134
|
+
return path;
|
|
135
|
+
}
|
|
136
|
+
function resolveOriginal(mapJson, opts) {
|
|
137
|
+
let tm;
|
|
138
|
+
try {
|
|
139
|
+
tm = new FlattenMap(mapJson, opts.mapUrl ?? opts.moduleUrl);
|
|
140
|
+
} catch {
|
|
141
|
+
return void 0;
|
|
142
|
+
}
|
|
143
|
+
const pos = originalPositionFor(tm, { line: opts.line, column: opts.col - 1 });
|
|
144
|
+
if (!pos.source || pos.line == null) return void 0;
|
|
145
|
+
let url;
|
|
146
|
+
try {
|
|
147
|
+
url = new URL(pos.source);
|
|
148
|
+
} catch {
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
const raw = decodeURIComponent(url.pathname);
|
|
152
|
+
const abs = url.protocol === "file:" ? normalizeDrivePath(raw) : raw.replace(/^\/+/, "");
|
|
153
|
+
if (isLibraryPath(abs, opts.root)) return void 0;
|
|
154
|
+
const path = (url.protocol === "file:" ? normalizeFileSource(abs, opts.root) : abs).split("?")[0];
|
|
155
|
+
if (!path || path.split("/").includes("..") || /[\x00-\x1f]/.test(path)) return void 0;
|
|
156
|
+
return `${path}:${pos.line}`;
|
|
157
|
+
}
|
|
158
|
+
function stripFrame(e) {
|
|
159
|
+
let out = e;
|
|
160
|
+
for (const key of FRAMEWORK_KEYS) {
|
|
161
|
+
const info = out[key];
|
|
162
|
+
if (!info) continue;
|
|
163
|
+
if (typeof info.component !== "string") {
|
|
164
|
+
const rest2 = { ...out };
|
|
165
|
+
delete rest2[key];
|
|
166
|
+
out = rest2;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const rest = { component: info.component.slice(0, 200) };
|
|
170
|
+
if (typeof info.source === "string") rest.source = info.source.slice(0, 200);
|
|
171
|
+
if (Array.isArray(info.callers)) {
|
|
172
|
+
const callers = info.callers.filter((c) => typeof c === "string").map((c) => c.slice(0, 200)).slice(0, 2);
|
|
173
|
+
if (callers.length) rest.callers = callers;
|
|
174
|
+
}
|
|
175
|
+
out = { ...out, [key]: rest };
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
function sameOrigin(a, b) {
|
|
180
|
+
try {
|
|
181
|
+
return new URL(a).origin === new URL(b).origin;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function resolveFrame(frame, page, fetchText, root) {
|
|
187
|
+
if (!sameOrigin(frame.url, page.url)) return void 0;
|
|
188
|
+
try {
|
|
189
|
+
const moduleText = await fetchText(frame.url);
|
|
190
|
+
if (moduleText === void 0) return void 0;
|
|
191
|
+
const mapRef = findSourceMapUrl(moduleText, frame.url);
|
|
192
|
+
if (!mapRef) return void 0;
|
|
193
|
+
let mapJson;
|
|
194
|
+
let mapUrl;
|
|
195
|
+
if (mapRef.startsWith("data:")) {
|
|
196
|
+
const b64 = mapRef.split(",")[1];
|
|
197
|
+
if (!b64) return void 0;
|
|
198
|
+
mapJson = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
|
|
199
|
+
} else {
|
|
200
|
+
if (!sameOrigin(mapRef, page.url)) return void 0;
|
|
201
|
+
const mapText = await fetchText(mapRef);
|
|
202
|
+
if (mapText === void 0) return void 0;
|
|
203
|
+
mapJson = JSON.parse(mapText);
|
|
204
|
+
mapUrl = mapRef;
|
|
205
|
+
}
|
|
206
|
+
return resolveOriginal(mapJson, { moduleUrl: frame.url, mapUrl, line: frame.line, col: frame.col, root });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error("[cobro] source map \uD574\uC11D \uC2E4\uD328", err.message);
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function resolveElementSources(batch, page, fetchText, opts) {
|
|
213
|
+
const root = opts?.root;
|
|
214
|
+
for (const e of batch.elements) {
|
|
215
|
+
const react = e.react;
|
|
216
|
+
if (!react) continue;
|
|
217
|
+
if (react.frame && !react.source) {
|
|
218
|
+
const source = await resolveFrame(react.frame, page, fetchText, root);
|
|
219
|
+
if (source) react.source = source;
|
|
220
|
+
}
|
|
221
|
+
const callers = [];
|
|
222
|
+
for (const loc of react.callerLocs ?? []) {
|
|
223
|
+
if (callers.length >= 2) break;
|
|
224
|
+
const s = typeof loc === "string" ? loc : await resolveFrame(loc, page, fetchText, root);
|
|
225
|
+
if (!s || s === react.source || callers.includes(s)) continue;
|
|
226
|
+
callers.push(s);
|
|
227
|
+
}
|
|
228
|
+
if (callers.length) react.callers = callers;
|
|
229
|
+
else delete react.callers;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/core/payload.ts
|
|
234
|
+
function dedupeConsole(entries, max = 10) {
|
|
235
|
+
const map = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const e of entries) {
|
|
237
|
+
const text2 = e.text.slice(0, 300);
|
|
238
|
+
const key = e.level + "\n" + text2;
|
|
239
|
+
const cur = map.get(key);
|
|
240
|
+
if (cur) {
|
|
241
|
+
cur.count++;
|
|
242
|
+
if (e.at > cur.last) cur.last = e.at;
|
|
243
|
+
} else map.set(key, { level: e.level, text: text2, count: 1, last: e.at });
|
|
244
|
+
}
|
|
245
|
+
return [...map.values()].sort((a, b) => a.last < b.last ? 1 : a.last > b.last ? -1 : 0).slice(0, max);
|
|
246
|
+
}
|
|
247
|
+
function buildPayload(input) {
|
|
248
|
+
return {
|
|
249
|
+
origin: "human",
|
|
250
|
+
sentAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
251
|
+
page: input.page,
|
|
252
|
+
batches: input.batches.map((b) => ({ id: b.id, note: b.note, elements: b.elements.map(stripFrame), ...b.screenshot ? { screenshot: b.screenshot } : {}, ...b.regions?.length ? { regions: b.regions } : {}, ...b.page ? { page: b.page } : {} })),
|
|
253
|
+
console: input.console,
|
|
254
|
+
refreshStrategy: input.refreshStrategy
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
94
258
|
// src/core/session.ts
|
|
95
259
|
var SessionCore = class extends EventEmitter {
|
|
96
|
-
//
|
|
97
|
-
constructor(store2) {
|
|
260
|
+
// R182: 큐에 쌓인 Send를 아무도 안 가져가는지 재는 타이머
|
|
261
|
+
constructor(store2, unheardMs = 1e4) {
|
|
98
262
|
super();
|
|
99
263
|
this.store = store2;
|
|
264
|
+
this.unheardMs = unheardMs;
|
|
100
265
|
this.s = store2.load() ?? emptySession();
|
|
101
266
|
if (this.s.agent.status === "waiting") this.s.agent = { status: "idle", text: this.s.agent.text };
|
|
102
267
|
else if (this.s.agent.status === "working") this.s.agent = { status: "idle", text: "" };
|
|
268
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
269
|
+
for (const b of this.s.batches) if (b.status === "working" && !b.deliveredAt) b.deliveredAt = now;
|
|
103
270
|
for (const b of this.s.batches) if (b.status === "working") b.status = "sent";
|
|
104
271
|
this.s.followPaused = false;
|
|
272
|
+
this.s.unheard = false;
|
|
273
|
+
const undelivered = this.s.batches.filter((b) => b.status === "sent" && !b.deliveredAt);
|
|
274
|
+
if (undelivered.length > 0) {
|
|
275
|
+
const first = undelivered[0];
|
|
276
|
+
const page = this.s.page ?? (first.page ? { ...first.page, viewport: { w: 0, h: 0 } } : { url: "", title: "", viewport: { w: 0, h: 0 } });
|
|
277
|
+
this.queue.push({ payload: buildPayload({ page, batches: undelivered, console: [], refreshStrategy: this.effectiveStrategy() }) });
|
|
278
|
+
this.armUnheard();
|
|
279
|
+
}
|
|
105
280
|
}
|
|
106
281
|
s;
|
|
107
282
|
queue = [];
|
|
108
283
|
waiter = null;
|
|
109
284
|
expectedUrl = null;
|
|
285
|
+
// R176: navigateTo가 이동을 건 목적지 — noteArrival이 도착 판정에 쓴다(저장 안 함)
|
|
286
|
+
unheardTimer = null;
|
|
110
287
|
get session() {
|
|
111
288
|
return this.s;
|
|
112
289
|
}
|
|
@@ -205,8 +382,58 @@ var SessionCore = class extends EventEmitter {
|
|
|
205
382
|
if (this.waiter) {
|
|
206
383
|
const w = this.waiter;
|
|
207
384
|
this.waiter = null;
|
|
385
|
+
this.clearUnheard();
|
|
386
|
+
this.markDelivered(payload);
|
|
208
387
|
w.resolve({ status: "sent", ...item });
|
|
209
|
-
} else
|
|
388
|
+
} else {
|
|
389
|
+
this.queue.push(item);
|
|
390
|
+
this.armUnheard();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** R183: payload가 에이전트에게 넘어갔다 — 그 묶음들에 받은 시각을 찍는다. done/status는 받은 묶음만 닫는다 */
|
|
394
|
+
markDelivered(payload) {
|
|
395
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
396
|
+
let changed = false;
|
|
397
|
+
for (const b of this.s.batches) {
|
|
398
|
+
if (!b.deliveredAt && payload.batches.some((p) => p.id === b.id)) {
|
|
399
|
+
b.deliveredAt = now;
|
|
400
|
+
changed = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (changed) {
|
|
404
|
+
try {
|
|
405
|
+
this.commit();
|
|
406
|
+
} catch (e) {
|
|
407
|
+
console.error("[cobro] deliveredAt \uC800\uC7A5 \uC2E4\uD328", e.message);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* R182·R184: 큐에 Send가 남아 있고 대기자가 없으면 unheardMs 타이머를 건다(이미 있으면 그대로). 만료 시점에 큐가 비었거나
|
|
413
|
+
* 에이전트가 받은 미완 묶음을 처리 중이면(바쁨) 켜지 않는다 — 바쁨이 끝나는 done()이 다시 건다. 호출처: deliver(적재)·wait(꺼낸 뒤)·done
|
|
414
|
+
*/
|
|
415
|
+
armUnheard() {
|
|
416
|
+
if (this.unheardTimer || this.waiter || this.queue.length === 0) return;
|
|
417
|
+
this.unheardTimer = setTimeout(() => {
|
|
418
|
+
this.unheardTimer = null;
|
|
419
|
+
const busy = this.s.batches.some((b) => b.deliveredAt && (b.status === "sent" || b.status === "working"));
|
|
420
|
+
if (this.queue.length > 0 && !busy && !this.s.unheard) {
|
|
421
|
+
this.s.unheard = true;
|
|
422
|
+
this.commit();
|
|
423
|
+
}
|
|
424
|
+
}, this.unheardMs);
|
|
425
|
+
this.unheardTimer.unref?.();
|
|
426
|
+
}
|
|
427
|
+
/** R182: 누군가 Send를 가져갔다(또는 큐가 비었다) — 타이머를 정리하고, 켜져 있던 unheard를 끈다 */
|
|
428
|
+
clearUnheard() {
|
|
429
|
+
if (this.unheardTimer) {
|
|
430
|
+
clearTimeout(this.unheardTimer);
|
|
431
|
+
this.unheardTimer = null;
|
|
432
|
+
}
|
|
433
|
+
if (this.s.unheard) {
|
|
434
|
+
this.s.unheard = false;
|
|
435
|
+
this.commit();
|
|
436
|
+
}
|
|
210
437
|
}
|
|
211
438
|
wait(timeoutMs, onTick, opts = {}) {
|
|
212
439
|
if (this.waiter) {
|
|
@@ -215,7 +442,12 @@ var SessionCore = class extends EventEmitter {
|
|
|
215
442
|
prev.resolve({ status: "pending" });
|
|
216
443
|
}
|
|
217
444
|
const queued = this.queue.shift();
|
|
218
|
-
if (queued)
|
|
445
|
+
if (queued) {
|
|
446
|
+
this.clearUnheard();
|
|
447
|
+
this.markDelivered(queued.payload);
|
|
448
|
+
this.armUnheard();
|
|
449
|
+
return Promise.resolve({ status: "sent", ...queued });
|
|
450
|
+
}
|
|
219
451
|
const keep = this.s.agent.status === "sent" || this.s.agent.status === "working" ? "" : this.s.agent.text;
|
|
220
452
|
this.s.agent = { status: "waiting", text: keep };
|
|
221
453
|
this.commit();
|
|
@@ -267,7 +499,7 @@ var SessionCore = class extends EventEmitter {
|
|
|
267
499
|
setAgentText(text2, batchId) {
|
|
268
500
|
if (batchId) {
|
|
269
501
|
const b = this.s.batches.find((x) => x.id === batchId);
|
|
270
|
-
if (b && (b.status === "sent" || b.status === "working")) b.status = "working";
|
|
502
|
+
if (b && b.deliveredAt && (b.status === "sent" || b.status === "working")) b.status = "working";
|
|
271
503
|
}
|
|
272
504
|
this.s.agent = { status: "working", text: text2 };
|
|
273
505
|
this.commit();
|
|
@@ -276,7 +508,7 @@ var SessionCore = class extends EventEmitter {
|
|
|
276
508
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
277
509
|
const out = [];
|
|
278
510
|
for (const b of this.s.batches) {
|
|
279
|
-
if ((b.status === "sent" || b.status === "working") && (!batchId || b.id === batchId)) {
|
|
511
|
+
if ((b.status === "sent" || b.status === "working") && b.deliveredAt && (!batchId || b.id === batchId)) {
|
|
280
512
|
b.status = "done";
|
|
281
513
|
b.doneAt = now;
|
|
282
514
|
b.summary = info.summary;
|
|
@@ -291,6 +523,7 @@ var SessionCore = class extends EventEmitter {
|
|
|
291
523
|
}
|
|
292
524
|
this.store.pruneShots(this.s.batches.filter((b) => b.status !== "done").map((b) => b.id));
|
|
293
525
|
this.commit();
|
|
526
|
+
this.armUnheard();
|
|
294
527
|
return out;
|
|
295
528
|
}
|
|
296
529
|
// R163: 같은 url 항목은 교체, 최대 20개(오래된 것부터 버림)
|
|
@@ -396,170 +629,6 @@ var ChannelServer = class {
|
|
|
396
629
|
}
|
|
397
630
|
};
|
|
398
631
|
|
|
399
|
-
// src/core/sourcemap.ts
|
|
400
|
-
import { FlattenMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
401
|
-
|
|
402
|
-
// src/core/frame.ts
|
|
403
|
-
function normalizeDrivePath(path) {
|
|
404
|
-
return path.replace(/\\/g, "/").replace(/^\/?([a-zA-Z]):\//, (_, drive) => `${drive.toLowerCase()}:/`);
|
|
405
|
-
}
|
|
406
|
-
function isLibraryPath(path, root) {
|
|
407
|
-
const norm = normalizeDrivePath(path);
|
|
408
|
-
let rel = norm;
|
|
409
|
-
if (root) {
|
|
410
|
-
const r = normalizeDrivePath(root).replace(/\/+$/, "");
|
|
411
|
-
if (norm.startsWith(`${r}/`)) rel = norm.slice(r.length + 1);
|
|
412
|
-
}
|
|
413
|
-
return /(^|\/)node_modules\//.test(rel);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// src/core/sourcemap.ts
|
|
417
|
-
var FRAMEWORK_KEYS = ["react", "vue"];
|
|
418
|
-
function findSourceMapUrl(moduleText, moduleUrl) {
|
|
419
|
-
const re = /\/\/[#@]\s*sourceMappingURL=(\S+)/g;
|
|
420
|
-
let m;
|
|
421
|
-
let last;
|
|
422
|
-
while (m = re.exec(moduleText)) last = m[1];
|
|
423
|
-
if (!last) return void 0;
|
|
424
|
-
if (last.startsWith("data:")) return last;
|
|
425
|
-
try {
|
|
426
|
-
return new URL(last, moduleUrl).href;
|
|
427
|
-
} catch {
|
|
428
|
-
return void 0;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
function normalizeFileSource(path, root) {
|
|
432
|
-
if (root) {
|
|
433
|
-
const r = normalizeDrivePath(root).replace(/\/+$/, "");
|
|
434
|
-
if (path === r) return "";
|
|
435
|
-
if (path.startsWith(`${r}/`)) return path.slice(r.length + 1);
|
|
436
|
-
}
|
|
437
|
-
const idx = path.indexOf("/src/");
|
|
438
|
-
if (idx !== -1) return path.slice(idx + 1);
|
|
439
|
-
return path;
|
|
440
|
-
}
|
|
441
|
-
function resolveOriginal(mapJson, opts) {
|
|
442
|
-
let tm;
|
|
443
|
-
try {
|
|
444
|
-
tm = new FlattenMap(mapJson, opts.mapUrl ?? opts.moduleUrl);
|
|
445
|
-
} catch {
|
|
446
|
-
return void 0;
|
|
447
|
-
}
|
|
448
|
-
const pos = originalPositionFor(tm, { line: opts.line, column: opts.col - 1 });
|
|
449
|
-
if (!pos.source || pos.line == null) return void 0;
|
|
450
|
-
let url;
|
|
451
|
-
try {
|
|
452
|
-
url = new URL(pos.source);
|
|
453
|
-
} catch {
|
|
454
|
-
return void 0;
|
|
455
|
-
}
|
|
456
|
-
const raw = decodeURIComponent(url.pathname);
|
|
457
|
-
const abs = url.protocol === "file:" ? normalizeDrivePath(raw) : raw.replace(/^\/+/, "");
|
|
458
|
-
if (isLibraryPath(abs, opts.root)) return void 0;
|
|
459
|
-
const path = (url.protocol === "file:" ? normalizeFileSource(abs, opts.root) : abs).split("?")[0];
|
|
460
|
-
if (!path || path.split("/").includes("..") || /[\x00-\x1f]/.test(path)) return void 0;
|
|
461
|
-
return `${path}:${pos.line}`;
|
|
462
|
-
}
|
|
463
|
-
function stripFrame(e) {
|
|
464
|
-
let out = e;
|
|
465
|
-
for (const key of FRAMEWORK_KEYS) {
|
|
466
|
-
const info = out[key];
|
|
467
|
-
if (!info) continue;
|
|
468
|
-
if (typeof info.component !== "string") {
|
|
469
|
-
const rest2 = { ...out };
|
|
470
|
-
delete rest2[key];
|
|
471
|
-
out = rest2;
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
const rest = { component: info.component.slice(0, 200) };
|
|
475
|
-
if (typeof info.source === "string") rest.source = info.source.slice(0, 200);
|
|
476
|
-
if (Array.isArray(info.callers)) {
|
|
477
|
-
const callers = info.callers.filter((c) => typeof c === "string").map((c) => c.slice(0, 200)).slice(0, 2);
|
|
478
|
-
if (callers.length) rest.callers = callers;
|
|
479
|
-
}
|
|
480
|
-
out = { ...out, [key]: rest };
|
|
481
|
-
}
|
|
482
|
-
return out;
|
|
483
|
-
}
|
|
484
|
-
function sameOrigin(a, b) {
|
|
485
|
-
try {
|
|
486
|
-
return new URL(a).origin === new URL(b).origin;
|
|
487
|
-
} catch {
|
|
488
|
-
return false;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
async function resolveFrame(frame, page, fetchText, root) {
|
|
492
|
-
if (!sameOrigin(frame.url, page.url)) return void 0;
|
|
493
|
-
try {
|
|
494
|
-
const moduleText = await fetchText(frame.url);
|
|
495
|
-
if (moduleText === void 0) return void 0;
|
|
496
|
-
const mapRef = findSourceMapUrl(moduleText, frame.url);
|
|
497
|
-
if (!mapRef) return void 0;
|
|
498
|
-
let mapJson;
|
|
499
|
-
let mapUrl;
|
|
500
|
-
if (mapRef.startsWith("data:")) {
|
|
501
|
-
const b64 = mapRef.split(",")[1];
|
|
502
|
-
if (!b64) return void 0;
|
|
503
|
-
mapJson = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
|
|
504
|
-
} else {
|
|
505
|
-
if (!sameOrigin(mapRef, page.url)) return void 0;
|
|
506
|
-
const mapText = await fetchText(mapRef);
|
|
507
|
-
if (mapText === void 0) return void 0;
|
|
508
|
-
mapJson = JSON.parse(mapText);
|
|
509
|
-
mapUrl = mapRef;
|
|
510
|
-
}
|
|
511
|
-
return resolveOriginal(mapJson, { moduleUrl: frame.url, mapUrl, line: frame.line, col: frame.col, root });
|
|
512
|
-
} catch (err) {
|
|
513
|
-
console.error("[cobro] source map \uD574\uC11D \uC2E4\uD328", err.message);
|
|
514
|
-
return void 0;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
async function resolveElementSources(batch, page, fetchText, opts) {
|
|
518
|
-
const root = opts?.root;
|
|
519
|
-
for (const e of batch.elements) {
|
|
520
|
-
const react = e.react;
|
|
521
|
-
if (!react) continue;
|
|
522
|
-
if (react.frame && !react.source) {
|
|
523
|
-
const source = await resolveFrame(react.frame, page, fetchText, root);
|
|
524
|
-
if (source) react.source = source;
|
|
525
|
-
}
|
|
526
|
-
const callers = [];
|
|
527
|
-
for (const loc of react.callerLocs ?? []) {
|
|
528
|
-
if (callers.length >= 2) break;
|
|
529
|
-
const s = typeof loc === "string" ? loc : await resolveFrame(loc, page, fetchText, root);
|
|
530
|
-
if (!s || s === react.source || callers.includes(s)) continue;
|
|
531
|
-
callers.push(s);
|
|
532
|
-
}
|
|
533
|
-
if (callers.length) react.callers = callers;
|
|
534
|
-
else delete react.callers;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// src/core/payload.ts
|
|
539
|
-
function dedupeConsole(entries, max = 10) {
|
|
540
|
-
const map = /* @__PURE__ */ new Map();
|
|
541
|
-
for (const e of entries) {
|
|
542
|
-
const text2 = e.text.slice(0, 300);
|
|
543
|
-
const key = e.level + "\n" + text2;
|
|
544
|
-
const cur = map.get(key);
|
|
545
|
-
if (cur) {
|
|
546
|
-
cur.count++;
|
|
547
|
-
if (e.at > cur.last) cur.last = e.at;
|
|
548
|
-
} else map.set(key, { level: e.level, text: text2, count: 1, last: e.at });
|
|
549
|
-
}
|
|
550
|
-
return [...map.values()].sort((a, b) => a.last < b.last ? 1 : a.last > b.last ? -1 : 0).slice(0, max);
|
|
551
|
-
}
|
|
552
|
-
function buildPayload(input) {
|
|
553
|
-
return {
|
|
554
|
-
origin: "human",
|
|
555
|
-
sentAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
556
|
-
page: input.page,
|
|
557
|
-
batches: input.batches.map((b) => ({ id: b.id, note: b.note, elements: b.elements.map(stripFrame), ...b.screenshot ? { screenshot: b.screenshot } : {}, ...b.regions?.length ? { regions: b.regions } : {}, ...b.page ? { page: b.page } : {} })),
|
|
558
|
-
console: input.console,
|
|
559
|
-
refreshStrategy: input.refreshStrategy
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
632
|
// src/core/settings.ts
|
|
564
633
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
565
634
|
import { dirname } from "node:path";
|
|
@@ -594,7 +663,7 @@ function writeUserSettings(file, patch) {
|
|
|
594
663
|
|
|
595
664
|
// src/bridge.ts
|
|
596
665
|
async function createBridge(opts) {
|
|
597
|
-
const core = new SessionCore(opts.store);
|
|
666
|
+
const core = new SessionCore(opts.store, opts.unheardMs);
|
|
598
667
|
let cachedTheme = opts.settingsFile ? readUserSettings(opts.settingsFile).theme : void 0;
|
|
599
668
|
const uiPrefs = () => ({ theme: opts.envTheme ?? cachedTheme ?? "auto", themeLocked: !!opts.envTheme });
|
|
600
669
|
const stateMsg = () => ({ type: "state", session: core.session, ui: uiPrefs() });
|
|
@@ -755,6 +824,17 @@ async function fetchTextGuarded(request, url, maxBytes = 8 * 1024 * 1024, timeou
|
|
|
755
824
|
function classifyLaunchFailure(tried, lockExists) {
|
|
756
825
|
return tried.every((t) => t.includes("has been closed")) && lockExists ? "profile-locked" : "not-found";
|
|
757
826
|
}
|
|
827
|
+
var LOCALHOST_FAILURE = /ERR_EMPTY_RESPONSE|ERR_CONNECTION_REFUSED|NS_ERROR_CONNECTION_REFUSED|Could not connect to server|Server returned nothing/;
|
|
828
|
+
function withLocalhostHint(url, message) {
|
|
829
|
+
let host = "";
|
|
830
|
+
try {
|
|
831
|
+
host = new URL(url).hostname;
|
|
832
|
+
} catch {
|
|
833
|
+
return message;
|
|
834
|
+
}
|
|
835
|
+
if (host !== "localhost" || !LOCALHOST_FAILURE.test(message)) return message;
|
|
836
|
+
return `${message} \u2014 localhost may resolve to another process (IPv6 ::1); try 127.0.0.1 instead.`;
|
|
837
|
+
}
|
|
758
838
|
function parseEngine(v) {
|
|
759
839
|
return v === "webkit" || v === "firefox" || v === "chromium" ? v : "chromium";
|
|
760
840
|
}
|
|
@@ -867,7 +947,11 @@ Close the other session's cobro browser (close), or set COBRO_PROFILE_DIR to a d
|
|
|
867
947
|
await this.launch();
|
|
868
948
|
this.launchedOnce = true;
|
|
869
949
|
}
|
|
870
|
-
|
|
950
|
+
try {
|
|
951
|
+
await this.page.goto(url, { waitUntil: "load" });
|
|
952
|
+
} catch (e) {
|
|
953
|
+
throw new Error(withLocalhostHint(url, e.message), { cause: e });
|
|
954
|
+
}
|
|
871
955
|
return { title: await this.page.title(), restarted };
|
|
872
956
|
}
|
|
873
957
|
async rectOfSelector(selector) {
|
|
@@ -917,6 +1001,7 @@ Close the other session's cobro browser (close), or set COBRO_PROFILE_DIR to a d
|
|
|
917
1001
|
// src/mcp/server.ts
|
|
918
1002
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
919
1003
|
import { z } from "zod";
|
|
1004
|
+
var DONE_NEXT = "When every batch you received is done, call wait() \u2014 more feedback may already be queued.";
|
|
920
1005
|
var text = (v) => ({ content: [{ type: "text", text: JSON.stringify(v) }] });
|
|
921
1006
|
var INSTRUCTIONS = [
|
|
922
1007
|
"Cobro protocol \u2014 the user picks elements on the page, writes a note and presses Send; that context arrives in this conversation.",
|
|
@@ -988,14 +1073,14 @@ function createMcpServer(deps) {
|
|
|
988
1073
|
return text(navigated === void 0 ? base : { ...base, navigated });
|
|
989
1074
|
});
|
|
990
1075
|
server.registerTool("done", {
|
|
991
|
-
description: "Signal that the edit is complete. The overlay runs the refresh strategy, shows the summary and highlights elements matched by selectors. Marks the
|
|
1076
|
+
description: "Signal that the edit is complete. The overlay runs the refresh strategy, shows the summary and highlights elements matched by selectors. Marks the batches you received as handled. Call after every edit. Pass batchId to complete only that batch; omit it to complete every batch you received.",
|
|
992
1077
|
inputSchema: { summary: z.string().max(500), selectors: z.array(z.string()).optional(), changedFiles: z.array(z.string()).optional(), batchId: z.string().optional() },
|
|
993
1078
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
|
|
994
1079
|
}, async ({ summary, selectors, changedFiles, batchId }) => {
|
|
995
1080
|
const out = deps.done({ summary, selectors: selectors ?? [], changedFiles: changedFiles ?? [] }, batchId);
|
|
996
1081
|
const cur = core.session.page?.url ?? "";
|
|
997
1082
|
const navigated = await navigateTo(out.find((b) => b.page && !samePage(b.page.url, cur))?.page?.url);
|
|
998
|
-
const base = browserGone() ? { ok: true, doneBatches: out.length, browserGone: true } : { ok: true, doneBatches: out.length };
|
|
1083
|
+
const base = browserGone() ? { ok: true, doneBatches: out.length, browserGone: true } : { ok: true, doneBatches: out.length, next: DONE_NEXT };
|
|
999
1084
|
return text(navigated === void 0 ? base : { ...base, navigated });
|
|
1000
1085
|
});
|
|
1001
1086
|
server.registerTool("screenshot", {
|
package/package.json
CHANGED