cobro-mcp 0.10.0 → 0.10.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/CHANGELOG.md +17 -0
- package/README.ko.md +28 -8
- package/README.md +28 -8
- package/dist/overlay.js +24 -7
- package/dist/server.js +243 -173
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.2] — 2026-10-01
|
|
8
|
+
### Added
|
|
9
|
+
- `done` now returns a `next` hint telling the agent to call wait() again once every received batch is done.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `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.
|
|
14
|
+
- A Send that was still queued when the server restarted is delivered again to the next wait() instead of being stuck as "Sent".
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
## [0.10.1] — 2026-09-24
|
|
20
|
+
### Changed
|
|
21
|
+
- The Send button is always visible; it is disabled until a draft has a note (it was hidden before).
|
|
22
|
+
- The toolbar's "view /path" link no longer repeats "Done" next to the Done chip.
|
|
23
|
+
|
|
7
24
|
## [0.10.0] — 2026-09-24
|
|
8
25
|
### Added
|
|
9
26
|
- Batches carry the page they were picked on (batches[].page), and status/done accept an optional batchId so an agent can work through several batches in order.
|
package/README.ko.md
CHANGED
|
@@ -54,8 +54,8 @@ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
|
|
|
54
54
|
|
|
55
55
|
## 사용법
|
|
56
56
|
|
|
57
|
-
- **사람**: 페이지에서 `Ctrl+Shift+F`로 선택 모드(`Esc`로 해제) → 요소 클릭(드래그는 밴드 안에 완전히 든 최상위 요소들) → 메모 → **Send**. Select를 누르면 패널이 열린다(요소를 고르거나 메모만 써도 된다); Select를 다시 누르거나 Esc, 패널 헤더의 ✕를 누르면 닫히고 초안은 그대로 남는다.
|
|
58
|
-
- **에이전트**: `open(url)` → `wait()` →
|
|
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()` → 묶음이 여러 개면 **배열 순서대로**: `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의 경로. 경로만 있고 이미지 바이트는 없다. 촬영 실패 시 키 없음 |
|
|
@@ -165,7 +184,8 @@ window.addEventListener('cobro:done', (e) => { const { summary, changedFiles, se
|
|
|
165
184
|
|
|
166
185
|
- React 컴포넌트 이름은 dev 빌드에서만 온다. `source`(파일:행)는 dev 서버의 소스맵(React 19) 또는 `_debugSource`(React 18까지)로 복원한다 — 소스맵 없는 프로덕션 빌드는 컴포넌트 이름만 온다. 컴포넌트 라이브러리 안 요소는 라이브러리 내부가 아니라 사용자가 쓴 JSX 위치가 온다.
|
|
167
186
|
- iframe 미지원(최상위 문서만). 네이티브 modal `<dialog>`가 열려 있는 동안은 오버레이가 가려진다(라이브러리 모달은 무관).
|
|
168
|
-
- `done`의 요소 강조는 best-effort
|
|
187
|
+
- `done`의 요소 강조는 best-effort다(리로드 뒤 재생된다).
|
|
188
|
+
- 비활성화된 컨트롤(예: disabled 버튼)은 클릭을 받지 않아 클릭 픽이 부모 요소로 잡힌다 — 대신 드래그로 밴드를 씌운다.
|
|
169
189
|
- 선택 모드 단축키 `Ctrl+Shift+F`는 바꿀 수 없다. WebKit 빌드는 실제 Safari와 폰트·스크롤바가 다르다.
|
|
170
190
|
- 브라우저 프로필은 한 번에 한 세션만 쓴다 — 다른 세션이 쓰는 중이면 `open`이 "프로필 사용 중"으로 실패한다(`COBRO_PROFILE_DIR`로 따로 지정 가능).
|
|
171
191
|
|
package/README.md
CHANGED
|
@@ -54,8 +54,8 @@ The whole loop is the GIF at the top. No copy-paste, no "check my annotations"
|
|
|
54
54
|
|
|
55
55
|
## Usage
|
|
56
56
|
|
|
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.
|
|
58
|
-
- **Agent**: `open(url)` → `wait()` →
|
|
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()` → 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 |
|
|
@@ -165,7 +184,8 @@ window.addEventListener('cobro:done', (e) => { const { summary, changedFiles, se
|
|
|
165
184
|
|
|
166
185
|
- React component name comes from dev builds. `source` (file:line) is recovered from the dev server's source maps (React 19) or from `_debugSource` (React ≤ 18); production builds without maps report the component only. For component libraries: the location is the JSX call site in your code, not the library's internals.
|
|
167
186
|
- No iframe support (top-level document only). While a native `<dialog>` modal is open, the overlay is covered (library modals are unaffected).
|
|
168
|
-
- Element highlighting on `done` is best-effort
|
|
187
|
+
- Element highlighting on `done` is best-effort (it replays after a reload).
|
|
188
|
+
- A disabled control (e.g. a disabled button) does not receive clicks, so click-picking lands on its parent; drag a band over it instead.
|
|
169
189
|
- The pick-mode shortcut `Ctrl+Shift+F` cannot be changed. The WebKit build differs from real Safari in fonts and scrollbars.
|
|
170
190
|
- The browser profile is used by one session at a time — if another session is using it, `open` fails with "profile in use" (point `COBRO_PROFILE_DIR` elsewhere to work around it).
|
|
171
191
|
|
package/dist/overlay.js
CHANGED
|
@@ -240,7 +240,7 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
240
240
|
.row .sends{color:var(--fg-5);font-size:11px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
241
241
|
.row .send{color:var(--fg-on-accent);background:var(--accent);border-color:var(--accent);font-weight:700;flex:none;white-space:nowrap}
|
|
242
242
|
.row .send:disabled{opacity:.4;cursor:not-allowed}
|
|
243
|
-
.row .foot{font-size:11px;color:var(--fg-4)}
|
|
243
|
+
.row .foot{font-size:11px;color:var(--fg-4);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
244
244
|
.row .foot.ok{color:var(--ok)}
|
|
245
245
|
.marker{position:fixed;border:2px solid var(--accent);border-radius:2px;pointer-events:none;box-sizing:border-box}
|
|
246
246
|
.marker.region{border-style:dashed;background:color-mix(in srgb, var(--accent) 6%, transparent)}
|
|
@@ -303,6 +303,10 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
303
303
|
chipDone: "\uC644\uB8CC",
|
|
304
304
|
chipOff: "\uC5F0\uACB0 \uB04A\uAE40",
|
|
305
305
|
agentSentDetail: "\uC5D0\uC774\uC804\uD2B8 \uC751\uB2F5 \uB300\uAE30",
|
|
306
|
+
agentUnheard: "\uC5D0\uC774\uC804\uD2B8 \uBBF8\uB300\uAE30 \u2014 \uCC44\uD305\uC5D0\uC11C \uBD88\uB7EC \uC8FC\uC138\uC694",
|
|
307
|
+
// 상태 줄(고정 폭, 말줄임 없이 들어가야 한다)
|
|
308
|
+
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',
|
|
309
|
+
// 칩 툴팁(전문)
|
|
306
310
|
disconnected: "\uC5F0\uACB0 \uB04A\uAE40 \u2014 \uC7AC\uC5F0\uACB0 \uC911",
|
|
307
311
|
hintSend: "Send\uB85C \uC804\uC1A1\uD558\uC138\uC694",
|
|
308
312
|
hintClick: "\uD398\uC774\uC9C0\uC5D0\uC11C \uC694\uC18C\uB97C \uD074\uB9AD\uD558\uC138\uC694 \xB7 Esc\uB85C \uD574\uC81C",
|
|
@@ -325,9 +329,11 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
325
329
|
tipDrag: "\uD234\uBC14 \uC774\uB3D9",
|
|
326
330
|
sends: "\uC120\uD0DD\uC790 \xB7 \uC2A4\uD0C0\uC77C \xB7 \uC2A4\uD06C\uB9B0\uC0F7 \xB7 \uCF58\uC194",
|
|
327
331
|
tipSend: "\uC120\uD0DD\uD55C \uC694\uC18C\uC640 \uBA54\uBAA8\uB97C \uC5D0\uC774\uC804\uD2B8\uC5D0 \uC804\uC1A1",
|
|
332
|
+
tipSendLocked: "\uC5D0\uC774\uC804\uD2B8\uAC00 \uC218\uC815 \uC911 \u2014 \uC644\uB8CC \uD6C4 \uBCF4\uB0BC \uC218 \uC788\uC5B4\uC694",
|
|
333
|
+
tipSendNote: "\uBA54\uBAA8\uB97C \uC801\uC73C\uBA74 \uBCF4\uB0BC \uC218 \uC788\uC5B4\uC694",
|
|
328
334
|
tipRemove: "\uC774 \uC694\uC18C \uBE7C\uAE30",
|
|
329
335
|
cartNoNote: "\uBA54\uBAA8 \uC5C6\uC74C",
|
|
330
|
-
doneElsewhere: (path) => `\u2713
|
|
336
|
+
doneElsewhere: (path) => `\u2713 ${path} \uBCF4\uAE30`,
|
|
331
337
|
tabHere: "\uC774 \uD398\uC774\uC9C0",
|
|
332
338
|
tabQueue: "\uD050",
|
|
333
339
|
herePage: "\uC9C0\uAE08 \uBCF4\uACE0 \uC788\uB294 \uD398\uC774\uC9C0",
|
|
@@ -365,6 +371,10 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
365
371
|
chipDone: "Done",
|
|
366
372
|
chipOff: "Disconnected",
|
|
367
373
|
agentSentDetail: "Waiting for the agent",
|
|
374
|
+
agentUnheard: "Agent not listening \u2014 ping it in chat",
|
|
375
|
+
// status line (fixed width; must fit without ellipsis)
|
|
376
|
+
agentUnheardTip: "The agent isn't listening \u2014 ask it in chat to check Cobro",
|
|
377
|
+
// chip tooltip (full text)
|
|
368
378
|
disconnected: "Disconnected \u2014 reconnecting",
|
|
369
379
|
hintSend: "Press Send to deliver",
|
|
370
380
|
hintClick: "Click an element on the page \xB7 Esc to exit",
|
|
@@ -387,9 +397,11 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
387
397
|
tipDrag: "Move toolbar",
|
|
388
398
|
sends: "Sends selector \xB7 styles \xB7 shot \xB7 console",
|
|
389
399
|
tipSend: "Send the selected elements and note to the agent",
|
|
400
|
+
tipSendLocked: "Agent is working \u2014 you can send after done",
|
|
401
|
+
tipSendNote: "Write a note to send",
|
|
390
402
|
tipRemove: "Remove this element",
|
|
391
403
|
cartNoNote: "No note",
|
|
392
|
-
doneElsewhere: (path) => `\u2713
|
|
404
|
+
doneElsewhere: (path) => `\u2713 view ${path}`,
|
|
393
405
|
tabHere: "This page",
|
|
394
406
|
tabQueue: "Queue",
|
|
395
407
|
herePage: "The page you're on",
|
|
@@ -677,7 +689,8 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
677
689
|
} else if (vm.locked && vm.followPaused) hint = T.followPaused;
|
|
678
690
|
else {
|
|
679
691
|
let text;
|
|
680
|
-
if (vm.agent.status === "sent"
|
|
692
|
+
if (vm.agent.status === "sent" && vm.unheard) text = T.agentUnheard;
|
|
693
|
+
else if (vm.agent.status === "sent" || vm.agent.status === "working" || vm.agent.status === "done") text = AGENT_TEXT[vm.agent.status](vm.agent.text);
|
|
681
694
|
else if (vm.agent.status === "waiting" && vm.agent.text && !hasElements && (cur?.note.trim() ?? "") === "") text = T.doneResult(stripStatusLabel(vm.agent.text, LABELS.done));
|
|
682
695
|
else if (cur && cur.note.trim() !== "") text = T.hintSend;
|
|
683
696
|
else if (vm.selecting && !hasElements) text = T.hintClick;
|
|
@@ -716,7 +729,7 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
716
729
|
const round = roundOf(vm.queue);
|
|
717
730
|
const roundSuffix = vm.busy ? " " + T.progress(round.done, round.total) : "";
|
|
718
731
|
chipLabel.textContent = vm.connected ? CHIP_LABEL[vm.agent.status] + roundSuffix : T.chipOff;
|
|
719
|
-
chip.title = vm.connected ? DOT_TITLE[vm.agent.status] + roundSuffix : T.disconnected;
|
|
732
|
+
chip.title = vm.connected ? (vm.agent.status === "sent" && vm.unheard ? T.agentUnheardTip : DOT_TITLE[vm.agent.status]) + roundSuffix : T.disconnected;
|
|
720
733
|
stratRow.hidden = !vm.strategy;
|
|
721
734
|
if (vm.strategy) {
|
|
722
735
|
stratValText.textContent = vm.strategy;
|
|
@@ -870,8 +883,8 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
870
883
|
}
|
|
871
884
|
const readyDrafts = vm.drafts.filter((b) => b.note.trim());
|
|
872
885
|
const row = el("div", "row");
|
|
886
|
+
const send = el("button", "send", readyDrafts.length >= 2 ? T.sendPages(readyDrafts.length) : "Send \u2192");
|
|
873
887
|
if (readyDrafts.length > 0) {
|
|
874
|
-
const send = el("button", "send", readyDrafts.length >= 2 ? T.sendPages(readyDrafts.length) : "Send \u2192");
|
|
875
888
|
send.title = T.tipSend;
|
|
876
889
|
send.onclick = () => {
|
|
877
890
|
closePop();
|
|
@@ -879,6 +892,8 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
879
892
|
};
|
|
880
893
|
row.append(el("span", "sends", T.sends), send);
|
|
881
894
|
} else {
|
|
895
|
+
send.disabled = true;
|
|
896
|
+
send.title = vm.locked ? T.tipSendLocked : T.tipSendNote;
|
|
882
897
|
let footText;
|
|
883
898
|
let footOk = false;
|
|
884
899
|
if (vm.busy) footText = vm.followPaused ? T.footPaused(round.done, round.total) : T.footBusy(round.done, round.total);
|
|
@@ -886,7 +901,7 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
|
|
|
886
901
|
footText = T.footAllDone(doneList.length);
|
|
887
902
|
footOk = true;
|
|
888
903
|
} else footText = T.footHint;
|
|
889
|
-
row.append(el("span", `foot${footOk ? " ok" : ""}`, footText));
|
|
904
|
+
row.append(el("span", `foot${footOk ? " ok" : ""}`, footText), send);
|
|
890
905
|
}
|
|
891
906
|
panel.append(row);
|
|
892
907
|
for (const id of [...textareas.keys()]) if (!vm.drafts.some((b) => b.id === id)) textareas.delete(id);
|
|
@@ -1494,6 +1509,8 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
|
|
|
1494
1509
|
// R175
|
|
1495
1510
|
followPaused: !!session?.followPaused,
|
|
1496
1511
|
// R176
|
|
1512
|
+
unheard: !!session?.unheard,
|
|
1513
|
+
// R182
|
|
1497
1514
|
pendingElsewhere: pendingElsewhere(session, href),
|
|
1498
1515
|
// R171: 다른 페이지에서 끝난 완료의 폴백 "보기" 링크
|
|
1499
1516
|
prefs,
|
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() });
|
|
@@ -917,6 +986,7 @@ Close the other session's cobro browser (close), or set COBRO_PROFILE_DIR to a d
|
|
|
917
986
|
// src/mcp/server.ts
|
|
918
987
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
919
988
|
import { z } from "zod";
|
|
989
|
+
var DONE_NEXT = "When every batch you received is done, call wait() \u2014 more feedback may already be queued.";
|
|
920
990
|
var text = (v) => ({ content: [{ type: "text", text: JSON.stringify(v) }] });
|
|
921
991
|
var INSTRUCTIONS = [
|
|
922
992
|
"Cobro protocol \u2014 the user picks elements on the page, writes a note and presses Send; that context arrives in this conversation.",
|
|
@@ -988,14 +1058,14 @@ function createMcpServer(deps) {
|
|
|
988
1058
|
return text(navigated === void 0 ? base : { ...base, navigated });
|
|
989
1059
|
});
|
|
990
1060
|
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
|
|
1061
|
+
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
1062
|
inputSchema: { summary: z.string().max(500), selectors: z.array(z.string()).optional(), changedFiles: z.array(z.string()).optional(), batchId: z.string().optional() },
|
|
993
1063
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
|
|
994
1064
|
}, async ({ summary, selectors, changedFiles, batchId }) => {
|
|
995
1065
|
const out = deps.done({ summary, selectors: selectors ?? [], changedFiles: changedFiles ?? [] }, batchId);
|
|
996
1066
|
const cur = core.session.page?.url ?? "";
|
|
997
1067
|
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 };
|
|
1068
|
+
const base = browserGone() ? { ok: true, doneBatches: out.length, browserGone: true } : { ok: true, doneBatches: out.length, next: DONE_NEXT };
|
|
999
1069
|
return text(navigated === void 0 ? base : { ...base, navigated });
|
|
1000
1070
|
});
|
|
1001
1071
|
server.registerTool("screenshot", {
|
package/package.json
CHANGED