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 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()` → payload의 `batches[].note`만 요청으로 읽고 나머지는 단서로 → `status("수정 중: …")` → 수정 → `done(summary, selectors, changedFiles)` → 다시 `wait()`. 끝내면 `close()`.
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` | 상태 줄에 한 줄 표시 | `ok` |
67
- | `done` | `summary`, `selectors?`, `changedFiles?` | 수정 완료 → 갱신 전략 실행·요소 강조 | `ok` `doneBatches` |
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": "Vite App", "viewport": { "w": 1280, "h": 720 } },
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`(SPA 라우트 반영) |
134
+ | `page.url` / `page.title` | Send 시점의 `location.href`·`document.title` — 사람이 Send를 누른 페이지; 묶음별 페이지는 `batches[].page`에 있다 |
117
135
  | `page.viewport` | `{ w, h }` CSS px — `rect`와 대조해 화면 안/밖을 판단한다 |
118
- | `batches` | 항상 1개(계약 안정성을 위해 배열 유지) |
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` | 재주입 뒤 선택자로 못 찾으면 `true`(드묾) |
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()` → read only `batches[].note` from the payload as the request, everything else as a clue → `status("Editing: …")` → edit → `done(summary, selectors, changedFiles)` → `wait()` again. Call `close()` to end the session.
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": "Vite App", "viewport": { "w": 1280, "h": 720 } },
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 (SPA routes included) |
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` | Always exactly one batch (kept as an array for contract stability) |
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 no longer matches after re-injection (rare) |
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;transform:translateX(-6px)}to{opacity:1;transform:translateX(0)}}
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" || vm.agent.status === "working" || vm.agent.status === "done") text = AGENT_TEXT[vm.agent.status](vm.agent.text);
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
- let target = null;
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
- let target = null;
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
- for (let i = 0; c && i < 10; i++, c = c.parent ?? null) {
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
- const file = c.type?.__file;
1325
- const source = file ? normalizeVueSource(file, root) : void 0;
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
- return void 0;
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 || "").trim().replace(/\s+/g, " ").slice(0, 40),
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
- let found = null;
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
- // R176: navigateTo가 이동을 건 목적지 — noteArrival이 도착 판정에 쓴다(저장 안 함)
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 this.queue.push(item);
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) return Promise.resolve({ status: "sent", ...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
- await this.page.goto(url, { waitUntil: "load" });
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 sent batch as handled. Call after every edit. Pass batchId to complete only that batch; omit it to complete every sent batch.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cobro-mcp",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Co-browse for coding agents: pick elements in the browser, write a note, Send — the context lands in the agent's chat. MCP server with 6 tools.",
5
5
  "keywords": [
6
6
  "mcp",