dsh-coding-sidebar 1.0.4 → 1.0.6

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/lib/index.js CHANGED
@@ -3971,6 +3971,15 @@ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, ge
3971
3971
  throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
3972
3972
  }
3973
3973
  },
3974
+ "cdp.targets": async () => {
3975
+ const res = await fetch("http://127.0.0.1:9223/json/list", { signal: AbortSignal.timeout(3e3) });
3976
+ if (!res.ok) throw new SidebarError("cdp-down", `browser host unreachable (${res.status})`, 502);
3977
+ return { targets: (await res.json()).filter((t) => t.type === "page").map((t) => ({
3978
+ id: t.id,
3979
+ url: t.url,
3980
+ title: t.title
3981
+ })) };
3982
+ },
3974
3983
  "browser.probe": async (payload) => {
3975
3984
  const raw = requireString(payload, "url");
3976
3985
  let parsed;
@@ -0,0 +1 @@
1
+ export declare function LiveView(): React.JSX.Element;
@@ -253,6 +253,14 @@ export declare const api: {
253
253
  /** Probe a URL's response headers (the sidebar browser's embeddability
254
254
  * check; see the host's browser.probe route). */
255
255
  browserProbe: (url: string, signal?: AbortSignal) => Promise<BrowserProbeResult>;
256
+ /** Agent 浏览器宿主的 page target 列表(host 代理 CDP /json/list)。 */
257
+ cdpTargets: (signal?: AbortSignal) => Promise<{
258
+ targets: Array<{
259
+ id: string;
260
+ url: string;
261
+ title: string;
262
+ }>;
263
+ }>;
256
264
  /** External open for the file tree's "open with" menu: reveal a path in
257
265
  * the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
258
266
  * zed://, custom editors) to its registered handler. The host launches
@@ -244,6 +244,12 @@ export declare const zh: {
244
244
  browserBack: string;
245
245
  browserForward: string;
246
246
  browserStart: string;
247
+ browserLive: string;
248
+ browserLiveFree: string;
249
+ browserLiveConnecting: string;
250
+ browserLiveDown: string;
251
+ browserLiveTargetNone: string;
252
+ browserLiveFollowLatest: string;
247
253
  browserBlockedScheme: string;
248
254
  browserBlockedLoopback: string;
249
255
  browserInvalid: string;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts';
8
8
  /** Machine-readable error codes of the sidebar API. */
9
- export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
9
+ export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
10
10
  /** One API failure with its wire code and HTTP status. */
11
11
  export declare class SidebarError extends Error {
12
12
  readonly code: SidebarErrorCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-coding-sidebar",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes the betterSidebar service for other plugins to register sidebar tabs and file viewers. KCoder-maintained fork of DSH-better-sidebar 0.17.2 (bottom panel removed, upstream-decoupled release line).",
5
5
  "type": "module",
6
6
  "repository": {
@@ -16,7 +16,7 @@
16
16
  * address-bar navigations (in-frame link clicks are cross-origin and
17
17
  * invisible — a documented limitation).
18
18
  */
19
- import { useEffect, useState } from 'react'
19
+ import { useEffect, useRef, useState } from 'react'
20
20
  import {
21
21
  IconChevronLeftOutline14,
22
22
  IconChevronRightOutline14,
@@ -24,12 +24,13 @@ import {
24
24
  IconRefreshOutline14,
25
25
  IconWarningOutline16,
26
26
  } from '@deepseek-ai/dsh-client-ui-primitives'
27
- import { VscLinkExternal } from 'react-icons/vsc'
27
+ import { VscLinkExternal, VscRemoteExplorer } from 'react-icons/vsc'
28
28
  import { api } from './api.ts'
29
29
  import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
30
30
  import { patchTab } from './state.ts'
31
31
  import { SandboxStatusBar } from './SandboxStatusBar.tsx'
32
32
  import { t } from './locales.ts'
33
+ import { LiveView } from './LiveView.tsx'
33
34
  import type { TabComponentProps } from './service.ts'
34
35
  import css from './sidebar.module.css'
35
36
 
@@ -80,7 +81,7 @@ export function iframeSandboxFor(url: string | undefined, allowedLoopback: strin
80
81
  }
81
82
 
82
83
  export function BrowserView(props: TabComponentProps) {
83
- const { store, tab } = props
84
+ const { store, tab, ctx } = props
84
85
  // The current address (initialized from the persisted tab.path so a
85
86
  // reload restores the visited page).
86
87
  const [url, setUrl] = useState<string | undefined>(tab.path)
@@ -101,6 +102,9 @@ export function BrowserView(props: TabComponentProps) {
101
102
  const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
102
103
  /** The user asked to load the refused site anyway (keeps the plain iframe). */
103
104
  const [forceEmbed, setForceEmbed] = useState(false)
105
+ /** Agent 实况模式(CDP screencast):开启后本 tab 只显示 agent 无头
106
+ * 浏览器的实况画面,地址栏/iframe 暂停。会话态开关,不持久化。 */
107
+ const [live, setLive] = useState(false)
104
108
 
105
109
  // Probe every navigation (address bar, history, restored path): when the
106
110
  // target forbids embedding, show the reason + open-in-browser instead of
@@ -117,6 +121,32 @@ export function BrowserView(props: TabComponentProps) {
117
121
  return () => { cancelled = true }
118
122
  }, [url])
119
123
 
124
+ // Agent 实况自动切换(2026-09-12):非实况态轮询浏览器宿主的 page
125
+ // target——出现 agent 页面(非 about: 空页)即自动开实况并把浏览器 tab
126
+ // 顶到前台。一次性:首次自动切换后不再抢控制权(用户手动关掉实况
127
+ // 也不会被再次强制打开)。
128
+ const autoRaised = useRef(false)
129
+ useEffect(() => {
130
+ if (live || autoRaised.current) return
131
+ const timer = setInterval(async () => {
132
+ if (autoRaised.current) return
133
+ try {
134
+ const { targets: list } = await api.cdpTargets()
135
+ const agentPages = list.filter((t) => t.url !== '' && !t.url.startsWith('about:'))
136
+ const had = autoRaised.current
137
+ if (agentPages.length > 0 && !had) {
138
+ autoRaised.current = true
139
+ setLive(true)
140
+ try {
141
+ const better = (ctx as unknown as { betterSidebar?: { openTab?: (r: { type: string }) => unknown } }).betterSidebar
142
+ better?.openTab?.({ type: 'browser' })
143
+ } catch { /* 顶起失败:实况仍已在 tab 内容里生效 */ }
144
+ }
145
+ } catch { /* 宿主未起:静默等下次轮询 */ }
146
+ }, 3000)
147
+ return () => { clearInterval(timer) }
148
+ }, [live, ctx])
149
+
120
150
  const persist = (nextUrl: string): void => {
121
151
  let host = nextUrl
122
152
  try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
@@ -212,6 +242,16 @@ export function BrowserView(props: TabComponentProps) {
212
242
  >
213
243
  <IconLinkOutline14 />
214
244
  </button>
245
+ <button
246
+ type="button"
247
+ className={css.iconButton}
248
+ aria-label={t('browserLive')}
249
+ title={t('browserLive')}
250
+ aria-pressed={live}
251
+ onClick={() => { setLive(value => !value) }}
252
+ >
253
+ <VscRemoteExplorer size={15} />
254
+ </button>
215
255
  <button
216
256
  type="button"
217
257
  className={css.iconButton}
@@ -233,7 +273,9 @@ export function BrowserView(props: TabComponentProps) {
233
273
  onUnlock={() => { setLocalUnlock(true) }}
234
274
  onRestore={() => { setLocalUnlock(false) }}
235
275
  />
236
- {url === undefined ? (
276
+ {live ? (
277
+ <LiveView />
278
+ ) : url === undefined ? (
237
279
  <div className={css.browserStart}>{t('browserStart')}</div>
238
280
  ) : embedBlocked !== null && !forceEmbed ? (
239
281
  <BrowserEmbedBlocked
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Agent 浏览器实况(CDP screencast + 输入回传,2026-09-12 方案 2)。
3
+ *
4
+ * 数据面:KCoder 桌面端 browser-host 维护一个无头 Chromium(固定 CDP
5
+ * 转发地址 127.0.0.1:9223,与 playwright MCP `--cdp-endpoint` 共用)。
6
+ * 本组件作为第二个 CDP 客户端:
7
+ * - target 列表经宿主代理 `/sidebar/api`(CDP HTTP 无 CORS 头);
8
+ * - screencast/输入走 renderer 直连 `ws://127.0.0.1:9223/devtools/page/<id>`
9
+ * (WebSocket 不受 CORS 约束,宿主未设 CSP)。
10
+ *
11
+ * 连接模型:`attachedId`(用户显式选择)优先,否则自动跟随「最新创建的
12
+ * page target」;连接按 effectiveId 建立与重建,列表仅在内容变化时更新
13
+ * state(JSON 比对),避免轮询引起重连抖动。
14
+ *
15
+ * 交互:screencast 帧绘到 canvas;指针/滚轮按 canvas→viewport 比例回传
16
+ * (Input.dispatchMouseEvent);可打印字符经 Input.insertText,控制键走
17
+ * rawKeyDown/keyUp。视口统一 1280×800(Emulation.setDeviceMetricsOverride),
18
+ * canvas 等比缩放显示。
19
+ */
20
+ import { useEffect, useMemo, useRef, useState } from 'react'
21
+ import { api } from './api.ts'
22
+ import { t } from './locales.ts'
23
+
24
+ /** 与 KCoder desktop/main/browser-host.ts 的 BROWSER_HOST_PORT 一致。 */
25
+ const CDP_WS = 'ws://127.0.0.1:9223'
26
+ /** 实况视口(CDP 侧强制,帧与输入坐标以此为基准)。 */
27
+ const VIEW_W = 1280
28
+ const VIEW_H = 800
29
+
30
+ type Target = { id: string; url: string; title: string }
31
+ type Status = 'connecting' | 'live' | 'down' | 'idle'
32
+
33
+ export function LiveView(): React.JSX.Element {
34
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
35
+ const wsRef = useRef<WebSocket | null>(null)
36
+ const [status, setStatus] = useState<Status>('connecting')
37
+ const [targets, setTargets] = useState<Target[]>([])
38
+ const [hostDown, setHostDown] = useState(false)
39
+ /** 用户显式钉住的 target(null = 自动跟随最新)。 */
40
+ const [pinnedId, setPinnedId] = useState<string | null>(null)
41
+ /** 连接代数:手动重连/换 target 时 +1。 */
42
+ const [epoch, setEpoch] = useState(0)
43
+
44
+ /* target 列表轮询(3s):内容变化才更新 state,避免无谓重连 */
45
+ useEffect(() => {
46
+ let alive = true
47
+ const poll = async (): Promise<void> => {
48
+ try {
49
+ const { targets: list } = await api.cdpTargets()
50
+ if (!alive) return
51
+ setHostDown(false)
52
+ setTargets((prev) => (JSON.stringify(prev) === JSON.stringify(list) ? prev : list))
53
+ } catch {
54
+ if (alive) setHostDown(true)
55
+ }
56
+ }
57
+ void poll()
58
+ const timer = setInterval(() => { void poll() }, 3000)
59
+ return () => { alive = false; clearInterval(timer) }
60
+ }, [])
61
+
62
+ /** 实际附加的 target:id 钉住且存活 → 它;否则自动跟随最新页。 */
63
+ const effectiveId = useMemo<string | null>(() => {
64
+ if (pinnedId !== null && targets.some((tg) => tg.id === pinnedId)) return pinnedId
65
+ return targets.length > 0 ? targets[targets.length - 1]!.id : null
66
+ }, [pinnedId, targets])
67
+
68
+ /* 连接与 screencast(按 effectiveId 建立,变化即重连) */
69
+ useEffect(() => {
70
+ if (effectiveId === null) { setStatus('idle'); return }
71
+ let cancelled = false
72
+ let socket: WebSocket | null = null
73
+ let msgId = 0
74
+
75
+ setStatus('connecting')
76
+ socket = new WebSocket(`${CDP_WS}/devtools/page/${effectiveId}`)
77
+ wsRef.current = socket
78
+
79
+ const send = (method: string, params?: Record<string, unknown>): void => {
80
+ if (socket !== null && socket.readyState === WebSocket.OPEN) {
81
+ socket.send(JSON.stringify({ id: ++msgId, method, params: params ?? {} }))
82
+ }
83
+ }
84
+
85
+ const draw = (data: string): void => {
86
+ const el = canvasRef.current
87
+ if (el === null || cancelled) return
88
+ const img = new Image()
89
+ img.onload = () => {
90
+ if (cancelled) return
91
+ el.width = img.naturalWidth
92
+ el.height = img.naturalHeight
93
+ el.getContext('2d')?.drawImage(img, 0, 0)
94
+ }
95
+ img.src = `data:image/jpeg;base64,${data}`
96
+ }
97
+
98
+ socket.onopen = () => {
99
+ if (cancelled) return
100
+ setStatus('live')
101
+ send('Page.enable')
102
+ send('Runtime.enable')
103
+ send('Emulation.setDeviceMetricsOverride', {
104
+ width: VIEW_W, height: VIEW_H, deviceScaleFactor: 1, mobile: false,
105
+ })
106
+ send('Page.startScreencast', {
107
+ format: 'jpeg', quality: 62, maxWidth: VIEW_W, maxHeight: VIEW_H, everyNthFrame: 1,
108
+ })
109
+ }
110
+ socket.onmessage = (event) => {
111
+ const msg = JSON.parse(event.data as string) as {
112
+ id?: number
113
+ method?: string
114
+ params?: { data?: string; screencastFrameId?: string }
115
+ }
116
+ if (msg.method === 'Page.screencastFrame') {
117
+ const p = msg.params ?? {}
118
+ if (typeof p.data === 'string') draw(p.data)
119
+ if (typeof p.screencastFrameId === 'string') {
120
+ send('Page.screencastFrameAck', { screencastFrameId: p.screencastFrameId })
121
+ }
122
+ }
123
+ }
124
+ socket.onclose = () => { if (!cancelled) setStatus('connecting') }
125
+ socket.onerror = () => { if (!cancelled) setStatus('down') }
126
+
127
+ return () => {
128
+ cancelled = true
129
+ socket?.close()
130
+ wsRef.current = null
131
+ }
132
+ }, [effectiveId])
133
+
134
+ /* 输入回传:坐标按 canvas 显示比例换算到 1280×800 视口 */
135
+ const sendInput = (method: string, params: Record<string, unknown>): void => {
136
+ wsRef.current?.send(JSON.stringify({ id: Math.floor(Math.random() * 1e9), method, params }))
137
+ }
138
+ const scaleOf = (el: HTMLCanvasElement): number => {
139
+ const shown = el.getBoundingClientRect().width
140
+ return shown > 0 ? VIEW_W / shown : 1
141
+ }
142
+ const posOf = (e: { clientX: number; clientY: number; currentTarget: HTMLCanvasElement }): { x: number; y: number } => {
143
+ const rect = e.currentTarget.getBoundingClientRect()
144
+ const k = scaleOf(e.currentTarget)
145
+ return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k }
146
+ }
147
+ const posOfWheel = (e: React.WheelEvent<HTMLCanvasElement>): { x: number; y: number } => {
148
+ const rect = e.currentTarget.getBoundingClientRect()
149
+ const k = scaleOf(e.currentTarget)
150
+ return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k }
151
+ }
152
+ const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>): void => {
153
+ const p = posOf(e)
154
+ sendInput('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', clickCount: 1 })
155
+ }
156
+ const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>): void => {
157
+ const p = posOf(e)
158
+ sendInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p.x, y: p.y, button: 'left', clickCount: 1 })
159
+ }
160
+ const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>): void => {
161
+ if (e.buttons === 0) return
162
+ const p = posOf(e)
163
+ sendInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y, button: 'left' })
164
+ }
165
+ const onWheel = (e: React.WheelEvent<HTMLCanvasElement>): void => {
166
+ const p = posOf(e)
167
+ sendInput('Input.dispatchMouseEvent', { type: 'mouseWheel', x: p.x, y: p.y, deltaX: e.deltaX, deltaY: e.deltaY })
168
+ }
169
+ const onKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>): void => {
170
+ const vk: Record<string, number> = {
171
+ Enter: 13, Backspace: 8, Tab: 9, Escape: 27,
172
+ ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
173
+ }
174
+ const code = vk[e.key]
175
+ if (code !== undefined) {
176
+ sendInput('Input.dispatchKeyEvent', { type: 'rawKeyDown', windowsVirtualKeyCode: code, key: e.key })
177
+ sendInput('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: code, key: e.key })
178
+ return
179
+ }
180
+ if (e.key.length === 1) sendInput('Input.insertText', { text: e.key })
181
+ }
182
+
183
+ const statusText = hostDown || status === 'down'
184
+ ? t('browserLiveDown')
185
+ : status === 'live'
186
+ ? (targets.find((tg) => tg.id === effectiveId)?.url ?? t('browserLiveTargetNone'))
187
+ : effectiveId === null
188
+ ? t('browserLiveTargetNone')
189
+ : t('browserLiveConnecting')
190
+
191
+ return (
192
+ <div className="browserLive">
193
+ <div className="browserLiveStatus">
194
+ <button type="button" onClick={() => { setEpoch(e => e + 1) }}>{t('refresh')}</button>
195
+ <select
196
+ className="browserLiveTarget"
197
+ value={effectiveId ?? ''}
198
+ onChange={(e) => { setPinnedId(e.target.value === '' ? null : e.target.value) }}
199
+ >
200
+ <option value="">{t('browserLiveFollowLatest')}</option>
201
+ {targets.map((tg) => (
202
+ <option key={tg.id} value={tg.id}>{tg.title || tg.url}</option>
203
+ ))}
204
+ </select>
205
+ <span>{statusText}</span>
206
+ </div>
207
+ {effectiveId !== null && status !== 'idle'
208
+ ? (
209
+ <canvas
210
+ ref={canvasRef}
211
+ className="browserLiveCanvas"
212
+ width={VIEW_W}
213
+ height={VIEW_H}
214
+ tabIndex={0}
215
+ onPointerDown={onPointerDown}
216
+ onPointerUp={onPointerUp}
217
+ onPointerMove={onPointerMove}
218
+ onWheel={onWheel}
219
+ onKeyDown={onKeyDown}
220
+ />
221
+ )
222
+ : (
223
+ <div className="browserStart">
224
+ {hostDown ? t('browserLiveDown') : t('browserLiveTargetNone')}
225
+ </div>
226
+ )}
227
+ </div>
228
+ )
229
+ }
package/src/client/api.ts CHANGED
@@ -316,6 +316,9 @@ export const api = {
316
316
  * check; see the host's browser.probe route). */
317
317
  browserProbe: (url: string, signal?: AbortSignal) =>
318
318
  call<BrowserProbeResult>('browser.probe', { url }, signal),
319
+ /** Agent 浏览器宿主的 page target 列表(host 代理 CDP /json/list)。 */
320
+ cdpTargets: (signal?: AbortSignal) =>
321
+ call<{ targets: Array<{ id: string; url: string; title: string }> }>('cdp.targets', {}, signal),
319
322
  /** External open for the file tree's "open with" menu: reveal a path in
320
323
  * the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
321
324
  * zed://, custom editors) to its registered handler. The host launches
@@ -245,6 +245,12 @@ export const zh = {
245
245
  browserBack: '后退',
246
246
  browserForward: '前进',
247
247
  browserStart: '输入网址开始浏览(沙箱模式)',
248
+ browserLive: 'Agent 实况',
249
+ browserLiveFree: '自由浏览',
250
+ browserLiveConnecting: '连接 agent 浏览器宿主…',
251
+ browserLiveDown: '宿主不可达:确认 KCoder 已启动(端口 9223)',
252
+ browserLiveTargetNone: '暂无打开的页面——agent 发起浏览后此处实时显示',
253
+ browserLiveFollowLatest: '跟随最新页面',
248
254
  browserBlockedScheme: '已阻止:仅支持 http/https 链接',
249
255
  browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
250
256
  browserInvalid: '无效的网址',
@@ -575,6 +581,12 @@ export const en: Record<keyof typeof zh, string> = {
575
581
  browserBack: 'Back',
576
582
  browserForward: 'Forward',
577
583
  browserStart: 'Enter a URL to start browsing (sandbox mode)',
584
+ browserLive: 'Agent live',
585
+ browserLiveFree: 'Free browse',
586
+ browserLiveConnecting: 'connecting to the agent browser host…',
587
+ browserLiveDown: 'host unreachable: make sure KCoder is running (port 9223)',
588
+ browserLiveTargetNone: 'No open pages yet — they appear here live once the agent browses',
589
+ browserLiveFollowLatest: 'Follow latest',
578
590
  browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
579
591
  browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
580
592
  browserInvalid: 'Invalid URL',
@@ -2362,3 +2362,35 @@ body[data-dsh-sidebar-dragging] .panel {
2362
2362
  0%, 60% { background: var(--dsw-alias-interactive-bg-hover); }
2363
2363
  100% { background: transparent; }
2364
2364
  }
2365
+
2366
+ /* ═══ Agent 浏览器实况(CDP screencast,2026-09-12 方案 2)═══ */
2367
+ .browserLive {
2368
+ position: absolute;
2369
+ inset: 0;
2370
+ display: flex;
2371
+ flex-direction: column;
2372
+ gap: 6px;
2373
+ padding: 8px 10px;
2374
+ overflow: hidden;
2375
+ }
2376
+ .browserLiveStatus {
2377
+ display: flex;
2378
+ align-items: center;
2379
+ gap: 8px;
2380
+ font-size: 12px;
2381
+ opacity: 0.75;
2382
+ }
2383
+ .browserLiveCanvas {
2384
+ flex: 1;
2385
+ min-height: 0;
2386
+ width: 100%;
2387
+ object-fit: contain;
2388
+ border: 1px solid var(--dsw-alias-border-l, rgba(128, 128, 128, 0.25));
2389
+ border-radius: 8px;
2390
+ background: #fff;
2391
+ cursor: crosshair;
2392
+ }
2393
+ .browserLiveTarget {
2394
+ max-width: 260px;
2395
+ font-size: 12px;
2396
+ }
package/src/index.ts CHANGED
@@ -517,6 +517,16 @@ function buildApi(
517
517
  // probe is display-only (headers back to the caller), restricted to
518
518
  // http(s) non-loopback URLs with a hard timeout, and gated by the same
519
519
  // trust fence as every other route — a cross-site page cannot reach it.
520
+ 'cdp.targets': async () => {
521
+ // Agent 浏览器宿主(KCoder 桌面端维护,固定 127.0.0.1:9223)的
522
+ // page target 列表。host 侧代理:CDP 端点无 CORS 头,renderer 直连
523
+ // /json/list 会被拦,WS(screencast/输入)则由 renderer 直连。
524
+ // 端口/地址是编译期常量,无用户输入,无 SSRF 面。
525
+ const res = await fetch('http://127.0.0.1:9223/json/list', { signal: AbortSignal.timeout(3000) })
526
+ if (!res.ok) throw new SidebarError('cdp-down', `browser host unreachable (${res.status})`, 502)
527
+ const list = (await res.json()) as Array<{ id: string; type: string; title: string; url: string }>
528
+ return { targets: list.filter((t) => t.type === 'page').map((t) => ({ id: t.id, url: t.url, title: t.title })) }
529
+ },
520
530
  'browser.probe': async (payload) => {
521
531
  const raw = requireString(payload, 'url')
522
532
  let parsed: URL
package/src/wire.ts CHANGED
@@ -18,6 +18,7 @@ export type SidebarErrorCode =
18
18
  | 'pty-error'
19
19
  | 'pty-deps-missing'
20
20
  | 'job-error'
21
+ | 'cdp-down'
21
22
  | 'sidechat-error'
22
23
  | 'subagents-unavailable'
23
24
  | 'settings-rejected'