cobro-mcp 0.10.1 → 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 CHANGED
@@ -4,6 +4,18 @@ 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
+
7
19
  ## [0.10.1] — 2026-09-24
8
20
  ### Changed
9
21
  - 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의 경로. 경로만 있고 이미지 바이트는 없다. 촬영 실패 시 키 없음 |
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 |
package/dist/overlay.js CHANGED
@@ -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",
@@ -367,6 +371,10 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
367
371
  chipDone: "Done",
368
372
  chipOff: "Disconnected",
369
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)
370
378
  disconnected: "Disconnected \u2014 reconnecting",
371
379
  hintSend: "Press Send to deliver",
372
380
  hintClick: "Click an element on the page \xB7 Esc to exit",
@@ -681,7 +689,8 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
681
689
  } else if (vm.locked && vm.followPaused) hint = T.followPaused;
682
690
  else {
683
691
  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);
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);
685
694
  else if (vm.agent.status === "waiting" && vm.agent.text && !hasElements && (cur?.note.trim() ?? "") === "") text = T.doneResult(stripStatusLabel(vm.agent.text, LABELS.done));
686
695
  else if (cur && cur.note.trim() !== "") text = T.hintSend;
687
696
  else if (vm.selecting && !hasElements) text = T.hintClick;
@@ -720,7 +729,7 @@ div.queue{display:flex;flex-direction:column;gap:8px;max-height:min(45vh,320px);
720
729
  const round = roundOf(vm.queue);
721
730
  const roundSuffix = vm.busy ? " " + T.progress(round.done, round.total) : "";
722
731
  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;
732
+ chip.title = vm.connected ? (vm.agent.status === "sent" && vm.unheard ? T.agentUnheardTip : DOT_TITLE[vm.agent.status]) + roundSuffix : T.disconnected;
724
733
  stratRow.hidden = !vm.strategy;
725
734
  if (vm.strategy) {
726
735
  stratValText.textContent = vm.strategy;
@@ -1500,6 +1509,8 @@ ${Math.round(r.width)}\xD7${Math.round(r.height)}`;
1500
1509
  // R175
1501
1510
  followPaused: !!session?.followPaused,
1502
1511
  // R176
1512
+ unheard: !!session?.unheard,
1513
+ // R182
1503
1514
  pendingElsewhere: pendingElsewhere(session, href),
1504
1515
  // R171: 다른 페이지에서 끝난 완료의 폴백 "보기" 링크
1505
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
- // 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() });
@@ -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 sent batch as handled. Call after every edit. Pass batchId to complete only that batch; omit it to complete every sent batch.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cobro-mcp",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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",