dsh-browser-plus 0.0.0-stage → 0.5.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +119 -0
  5. package/README.md +118 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/client/index.js +185 -0
  13. package/cordis.patch.yml +41 -0
  14. package/docs/MIGRATION.md +48 -0
  15. package/docs/README.md +22 -0
  16. package/docs/SOAK-CHECKLIST.md +98 -0
  17. package/docs/architecture.md +88 -0
  18. package/docs/tool-reference.md +126 -0
  19. package/docs/user-guide.md +137 -0
  20. package/docs/why-browser.md +45 -0
  21. package/lib/browser/runtime.d.ts +238 -0
  22. package/lib/browser/runtime.js +330 -0
  23. package/lib/browser/types.d.ts +758 -0
  24. package/lib/browser/types.js +18 -0
  25. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  26. package/lib/browser-electron/auth-cookies.js +83 -0
  27. package/lib/browser-electron/chrome-state.d.ts +211 -0
  28. package/lib/browser-electron/chrome-state.js +12 -0
  29. package/lib/browser-electron/entry.d.ts +73 -0
  30. package/lib/browser-electron/entry.js +65 -0
  31. package/lib/browser-electron/fingerprint.d.ts +29 -0
  32. package/lib/browser-electron/fingerprint.js +42 -0
  33. package/lib/browser-electron/host-main.d.ts +19 -0
  34. package/lib/browser-electron/host-main.js +2691 -0
  35. package/lib/browser-electron/icon.d.ts +11 -0
  36. package/lib/browser-electron/icon.js +23 -0
  37. package/lib/browser-electron/page-chrome.d.ts +21 -0
  38. package/lib/browser-electron/page-chrome.js +2269 -0
  39. package/lib/browser-electron/provider.d.ts +767 -0
  40. package/lib/browser-electron/provider.js +2825 -0
  41. package/lib/browser-electron/remote-host.d.ts +145 -0
  42. package/lib/browser-electron/remote-host.js +993 -0
  43. package/lib/browser-electron/task-summary.d.ts +2 -0
  44. package/lib/browser-electron/task-summary.js +12 -0
  45. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  46. package/lib/browser-electron/task-thumbnail.js +9 -0
  47. package/lib/browser-electron/write-guard.d.ts +41 -0
  48. package/lib/browser-electron/write-guard.js +123 -0
  49. package/lib/client.js +185 -0
  50. package/lib/command-browser/index.d.ts +20 -0
  51. package/lib/command-browser/index.js +35 -0
  52. package/lib/http-browser/index.d.ts +28 -0
  53. package/lib/http-browser/index.js +110 -0
  54. package/lib/index.d.ts +27 -0
  55. package/lib/index.js +25 -0
  56. package/lib/task-todos/index.d.ts +25 -0
  57. package/lib/task-todos/index.js +100 -0
  58. package/lib/tool-browser/index.d.ts +31 -0
  59. package/lib/tool-browser/index.js +2026 -0
  60. package/package.json +120 -4
  61. package/screenshots.json +3 -0
  62. package/scripts/build-client.mjs +20 -0
  63. package/scripts/build-icons.mjs +80 -0
  64. package/scripts/capture-window.ps1 +79 -0
  65. package/scripts/crop-image.ps1 +20 -0
  66. package/scripts/smoke-browser-tools.mjs +2051 -0
  67. package/scripts/smoke-chrome-world.mjs +65 -0
  68. package/scripts/smoke-electron-host.mjs +50 -0
  69. package/scripts/test-orb-drag.mjs +83 -0
  70. package/src/browser/runtime.ts +506 -0
  71. package/src/browser/types.ts +741 -0
  72. package/src/browser-electron/auth-cookies.ts +125 -0
  73. package/src/browser-electron/chrome-state.ts +192 -0
  74. package/src/browser-electron/entry.ts +125 -0
  75. package/src/browser-electron/fingerprint.ts +45 -0
  76. package/src/browser-electron/host-main.ts +2526 -0
  77. package/src/browser-electron/icon.ts +26 -0
  78. package/src/browser-electron/page-chrome.ts +2281 -0
  79. package/src/browser-electron/provider.ts +3366 -0
  80. package/src/browser-electron/remote-host.ts +1051 -0
  81. package/src/browser-electron/task-summary.ts +10 -0
  82. package/src/browser-electron/task-thumbnail.ts +17 -0
  83. package/src/browser-electron/write-guard.ts +134 -0
  84. package/src/command-browser/index.ts +61 -0
  85. package/src/http-browser/index.ts +139 -0
  86. package/src/index.ts +65 -0
  87. package/src/task-todos/index.ts +114 -0
  88. package/src/tool-browser/index.ts +2071 -0
  89. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,10 @@
1
+ /** Return a page-safe task location without path, query, or fragment data. */
2
+ export function taskSummaryUrl(raw: string): string {
3
+ if (raw === '') return ''
4
+ try {
5
+ const origin = new URL(raw).origin
6
+ return origin === 'null' ? '' : origin
7
+ } catch {
8
+ return ''
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ export const TASK_THUMBNAIL_WIDTH = 288
2
+ export const TASK_THUMBNAIL_JPEG_QUALITY = 58
3
+ export const MAX_TASK_THUMBNAIL_BYTES = 180 * 1024
4
+
5
+ export interface ThumbnailImage {
6
+ resize(options: { width: number }): {
7
+ toJPEG(quality: number): Buffer
8
+ }
9
+ }
10
+
11
+ export function taskThumbnailDataUrl(image: ThumbnailImage): string | undefined {
12
+ const jpeg = image.resize({ width: TASK_THUMBNAIL_WIDTH }).toJPEG(TASK_THUMBNAIL_JPEG_QUALITY)
13
+
14
+ if (jpeg.length === 0 || jpeg.length > MAX_TASK_THUMBNAIL_BYTES) return undefined
15
+
16
+ return `data:image/jpeg;base64,${jpeg.toString('base64')}`
17
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Write-path admission for browser-produced files (screenshots, downloads).
3
+ *
4
+ * The provider — not the host — owns this guard so one implementation covers
5
+ * the self-hosted Electron host and desktop-shell handles alike, and so it can
6
+ * be exercised without Electron. Roots are resolved through the deepest
7
+ * existing ancestor, which keeps a symlinked or `..`-laden target from
8
+ * escaping a root it only appears to sit inside.
9
+ * @module dsh-browser-plus/browser-electron/write-guard
10
+ */
11
+
12
+ import { realpathSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { basename, dirname, join, resolve, sep } from 'node:path'
15
+ import { BrowserError } from '../browser/types.ts'
16
+
17
+ /**
18
+ * Roots a browser write may target when the config does not name any: the
19
+ * DSH workspace (the process working directory) and the OS temp directory.
20
+ * This mirrors the DSH file sandbox's "write inside the workspace" boundary.
21
+ */
22
+ export function defaultWriteRoots(): string[] {
23
+ return [process.cwd(), tmpdir()]
24
+ }
25
+
26
+ /** Windows paths compare case-insensitively; POSIX paths do not. */
27
+ function comparable(value: string): string {
28
+ return process.platform === 'win32' ? value.toLowerCase() : value
29
+ }
30
+
31
+ /**
32
+ * Resolve symlinks on the deepest ancestor that exists, then re-append the
33
+ * segments that do not. A save target usually does not exist yet, so the
34
+ * target itself cannot be realpath'd — but its parent usually can.
35
+ */
36
+ function realpathOfNearestAncestor(target: string): string {
37
+ let current = target
38
+ const tail: string[] = []
39
+ for (;;) {
40
+ try {
41
+ const real = realpathSync.native(current)
42
+ return tail.length === 0 ? real : join(real, ...[...tail].reverse())
43
+ } catch {
44
+ const parent = dirname(current)
45
+ // Reached the filesystem root without finding anything real: fall back
46
+ // to the lexical path rather than denying a legitimate write.
47
+ if (parent === current) return target
48
+ tail.push(basename(current))
49
+ current = parent
50
+ }
51
+ }
52
+ }
53
+
54
+ /** True when `candidate` is `root` itself or lives beneath it. */
55
+ function within(candidate: string, root: string): boolean {
56
+ if (candidate === root) return true
57
+ return candidate.startsWith(root.endsWith(sep) ? root : root + sep)
58
+ }
59
+
60
+ /** Real, comparable forms of both sides; roots are normalized exactly once per call. */
61
+ function resolvedPair(candidate: string, roots: readonly string[]): { target: string; roots: string[] } {
62
+ return {
63
+ target: comparable(realpathOfNearestAncestor(resolve(candidate))),
64
+ roots: roots.map(root => comparable(realpathOfNearestAncestor(resolve(root)))),
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Whether a path would be admitted by {@link resolveWritePath}, without
70
+ * throwing. Exposed for focused tests and for callers that want to probe.
71
+ * @param candidate - the path to test.
72
+ * @param roots - the allowed roots.
73
+ */
74
+ export function isWithinRoots(candidate: string, roots: readonly string[]): boolean {
75
+ const { target, roots: resolved } = resolvedPair(candidate, roots)
76
+ return resolved.some(root => within(target, root))
77
+ }
78
+
79
+ /**
80
+ * Resolve a write target and admit it only when it lands inside one of the
81
+ * allowed roots. Returns the absolute path to write.
82
+ * @param savePath - the caller-supplied path.
83
+ * @param roots - the allowed roots; an empty list denies every write.
84
+ * @throws BrowserError `BROWSER_WRITE_PATH_DENIED` when the path is unusable or outside every root.
85
+ */
86
+ export function resolveWritePath(savePath: string, roots: readonly string[]): string {
87
+ if (typeof savePath !== 'string' || savePath.trim() === '') {
88
+ throw new BrowserError('browser: refusing to write without a save path', 'BROWSER_WRITE_PATH_DENIED')
89
+ }
90
+ const absolute = resolve(savePath)
91
+ if (!isWithinRoots(absolute, roots)) {
92
+ // This message reaches the model context and, through a task's error field,
93
+ // the page itself — so it never names an absolute path.
94
+ const hint = roots.length === 0 ? ' (none configured)' : ''
95
+ throw new BrowserError(
96
+ `browser: refusing to write "${savePath}" outside the allowed roots${hint}; `
97
+ + 'add the directory to the browser-electron "writeRoots" config to allow it',
98
+ 'BROWSER_WRITE_PATH_DENIED',
99
+ )
100
+ }
101
+ return absolute
102
+ }
103
+
104
+ /**
105
+ * Resolve a file the browser is about to hand to a page and admit it only when
106
+ * it lands inside one of the allowed roots. Unlike a write target the file must
107
+ * already exist, so the path itself is realpath'd: a symlink to a permitted
108
+ * file is admitted, a symlink that leaves the roots is not.
109
+ * @param filePath - the caller-supplied path.
110
+ * @param roots - the allowed roots; an empty list denies every read.
111
+ * @throws BrowserError `BROWSER_READ_PATH_DENIED` when the path is unusable, missing, or outside every root.
112
+ */
113
+ export function resolveReadPath(filePath: string, roots: readonly string[]): string {
114
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
115
+ throw new BrowserError('browser: refusing to read without a file path', 'BROWSER_READ_PATH_DENIED')
116
+ }
117
+ const absolute = resolve(filePath)
118
+ let real: string
119
+ try {
120
+ real = realpathSync.native(absolute)
121
+ } catch {
122
+ throw new BrowserError(`browser: refusing to read "${filePath}": the file does not exist`, 'BROWSER_READ_PATH_DENIED')
123
+ }
124
+ const allowed = roots.map(root => comparable(realpathOfNearestAncestor(resolve(root))))
125
+ if (!allowed.some(root => within(comparable(real), root))) {
126
+ const hint = roots.length === 0 ? ' (none configured)' : ''
127
+ throw new BrowserError(
128
+ `browser: refusing to read "${filePath}" outside the allowed roots${hint}; `
129
+ + 'add the directory to the browser-electron "readRoots" config to allow it',
130
+ 'BROWSER_READ_PATH_DENIED',
131
+ )
132
+ }
133
+ return absolute
134
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Human-facing `/browser` command: puts the shared browser window on screen
3
+ * without going through the model.
4
+ *
5
+ * The window is normally created as a side effect of the first browser tool
6
+ * call, so a human who wants to browse (or take over) before any agent call had
7
+ * no way to raise it. `/browser` is that way in: it spawns the host and shows
8
+ * the window when nothing is open, and raises the existing window otherwise.
9
+ *
10
+ * The command registry is declared structurally instead of imported from
11
+ * `@deepseek-ai/dsh-commands`: the registry is a peer service the profile
12
+ * provides, and depending on that package only for its types would make this
13
+ * row fail to load wherever the service exists but the dependency does not.
14
+ * @module dsh-browser-plus/command-browser
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+
19
+ /** The invocation the dispatcher hands a handler. */
20
+ interface CommandInvocation {
21
+ /** Everything after the command name, verbatim. */
22
+ readonly rawInput: string
23
+ }
24
+
25
+ /** What the dispatching UI renders for one settled execution. */
26
+ type CommandOutcome =
27
+ | { readonly kind: 'success'; readonly text?: string }
28
+ | { readonly kind: 'error'; readonly text: string }
29
+
30
+ /** One definition accepted by the registry. */
31
+ interface CommandDefinition {
32
+ readonly name: string
33
+ readonly description: string
34
+ readonly handler: (invocation: CommandInvocation) => Promise<CommandOutcome> | CommandOutcome
35
+ }
36
+
37
+ /** The registry this plugin registers into, satisfied by `@deepseek-ai/dsh-commands`. */
38
+ interface CommandRegistry {
39
+ register(definition: CommandDefinition): void
40
+ }
41
+
42
+ export const name = 'browser-command'
43
+ export const inject = ['browser', 'commands']
44
+
45
+ /** Register the command. */
46
+ export function apply(ctx: Context, _config: unknown = {}): void {
47
+ const commands = (ctx as unknown as { readonly commands: CommandRegistry }).commands
48
+ commands.register({
49
+ name: 'browser',
50
+ description: '打开并前置共享浏览器窗口',
51
+ handler: async () => {
52
+ try {
53
+ await ctx.browser.ensureWindowVisible()
54
+ return { kind: 'success', text: '浏览器窗口已打开。' }
55
+ } catch (error) {
56
+ const detail = error instanceof Error ? error.message : String(error)
57
+ return { kind: 'error', text: `打开浏览器窗口失败:${detail}` }
58
+ }
59
+ },
60
+ })
61
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The HTTP bridge the browser panel talks to.
3
+ *
4
+ * The panel lives in the Web GUI, the window lives in the DSH process, and a
5
+ * client bundle has no way to call a server-side plugin method directly: the
6
+ * generated Remote/Typert surface exists for product packages, not for a
7
+ * third-party plugin built with plain tsc. The DSH web server, however, is a
8
+ * service this plugin can register routes on, and the GUI is served from that
9
+ * same origin — so one POST is the whole bridge.
10
+ *
11
+ * Routes (all JSON, no-store):
12
+ * POST /api/dsh-browser-plus/open -> { ok: true } once the window is up
13
+ * GET /api/dsh-browser-plus/status -> { ok: true, tasks: number }
14
+ *
15
+ * The `webServer` service is declared structurally rather than imported from
16
+ * `@deepseek-ai/dsh-host-webserver`: this row must load wherever the service
17
+ * exists, without taking a build-time dependency on the package that owns it.
18
+ * @module dsh-browser-plus/http-browser
19
+ */
20
+
21
+ import type { Context } from '@deepseek-ai/cordis'
22
+
23
+ /** Absolute path of the "open the window" endpoint. */
24
+ export const OPEN_PATH = '/api/dsh-browser-plus/open'
25
+ /** Absolute path of the status endpoint. */
26
+ export const STATUS_PATH = '/api/dsh-browser-plus/status'
27
+
28
+ /** The request fields these handlers read. */
29
+ interface WebRequest {
30
+ readonly method?: string
31
+ readonly headers?: Record<string, string | string[] | undefined>
32
+ }
33
+
34
+ /** The response surface these handlers use (a subset of `ServerResponse`). */
35
+ interface WebResponse {
36
+ statusCode: number
37
+ setHeader(name: string, value: string): void
38
+ end(chunk?: string): void
39
+ }
40
+
41
+ /** One route registration. */
42
+ interface WebRoute {
43
+ readonly kind: 'exact' | 'prefix'
44
+ readonly path: string
45
+ readonly handler: (req: WebRequest, res: WebResponse) => void | Promise<void>
46
+ }
47
+
48
+ /** The route registry this plugin registers into. */
49
+ interface WebServerService {
50
+ register(route: WebRoute): () => void
51
+ }
52
+
53
+ export const name = 'browser-http'
54
+ export const inject = ['browser', 'webServer']
55
+
56
+ /** Write one JSON response. */
57
+ function sendJson(res: WebResponse, status: number, payload: unknown): void {
58
+ res.statusCode = status
59
+ res.setHeader('content-type', 'application/json; charset=utf-8')
60
+ res.setHeader('cache-control', 'no-store')
61
+ res.end(JSON.stringify(payload))
62
+ }
63
+
64
+ /** Read one request header as a single string. */
65
+ function headerValue(req: WebRequest, name: string): string {
66
+ const raw = req.headers?.[name]
67
+ return Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? '')
68
+ }
69
+
70
+ /**
71
+ * Refuse a request that a page on another site made.
72
+ *
73
+ * These two endpoints are unauthenticated and only open a window / report a task
74
+ * count — but any web page can POST to localhost without a preflight, so without
75
+ * this check a random site could pop our browser window open. The panel's own
76
+ * fetch is same-origin, so its Origin always matches the Host we were reached on.
77
+ */
78
+ function fromAnotherSite(req: WebRequest): boolean {
79
+ const origin = headerValue(req, 'origin')
80
+ if (origin === '') return false
81
+ try {
82
+ const parsed = new URL(origin)
83
+ const host = headerValue(req, 'host')
84
+ return host === '' || parsed.host !== host
85
+ } catch {
86
+ return true
87
+ }
88
+ }
89
+
90
+ /** One line for the panel to show; never a stack. */
91
+ function describe(error: unknown): string {
92
+ return error instanceof Error ? error.message : String(error)
93
+ }
94
+
95
+ /** Register the browser-panel bridge. */
96
+ export function apply(ctx: Context, _config: unknown = {}): void {
97
+ const server = (ctx as unknown as { readonly webServer?: WebServerService }).webServer
98
+ if (server === undefined) return
99
+ ctx.effect(() => server.register({
100
+ kind: 'exact',
101
+ path: OPEN_PATH,
102
+ handler: async (req, res) => {
103
+ if (req.method !== 'POST') {
104
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
105
+ return
106
+ }
107
+ if (fromAnotherSite(req)) {
108
+ sendJson(res, 403, { ok: false, error: 'cross-site request refused' })
109
+ return
110
+ }
111
+ try {
112
+ await ctx.browser.ensureWindowVisible()
113
+ sendJson(res, 200, { ok: true })
114
+ } catch (error) {
115
+ sendJson(res, 500, { ok: false, error: describe(error) })
116
+ }
117
+ },
118
+ }), 'dsh-browser-plus:open route')
119
+ ctx.effect(() => server.register({
120
+ kind: 'exact',
121
+ path: STATUS_PATH,
122
+ handler: async (req, res) => {
123
+ if (req.method !== 'GET') {
124
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
125
+ return
126
+ }
127
+ if (fromAnotherSite(req)) {
128
+ sendJson(res, 403, { ok: false, error: 'cross-site request refused' })
129
+ return
130
+ }
131
+ try {
132
+ const tasks = await ctx.browser.listTasks()
133
+ sendJson(res, 200, { ok: true, tasks: tasks.length })
134
+ } catch (error) {
135
+ sendJson(res, 500, { ok: false, error: describe(error) })
136
+ }
137
+ },
138
+ }), 'dsh-browser-plus:status route')
139
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * dsh-browser-plus plugin entry: aggregates the shared-browser capability
3
+ * pieces. The cordis.patch.yml rows reference subpath exports:
4
+ * - `dsh-browser-plus/browser` -> the ctx.browser seam (Service)
5
+ * - `dsh-browser-plus/browser-electron` -> the Electron CDP provider
6
+ * - `dsh-browser-plus/tool-browser` -> the model-facing browser_* tools
7
+ * This root entry only re-exports for programmatic use; the loader rows are
8
+ * the composition surface.
9
+ *
10
+ * It IS also mounted as a row of its own — an empty one — because the client
11
+ * module system only scans Loader rows whose specifier is an exact package root
12
+ * (`exactPackageSpecifier` in `@deepseek-ai/dsh-client-modules` returns
13
+ * undefined for a subpath). Without a root row the package's `dsh.client`
14
+ * declaration is never read and the browser panel never reaches the GUI, no
15
+ * matter how many subpath rows the bundle patch adds.
16
+ * @module dsh-browser-plus
17
+ */
18
+
19
+ /** Plugin name for the root row. */
20
+ export const name = 'browser-plus'
21
+
22
+ /** The root row has no behaviour of its own; it exists to carry the package identity. */
23
+ export function apply(): void {}
24
+
25
+ export { BrowserError } from './browser/types.ts'
26
+ export type {
27
+ BrowserChallenge,
28
+ BrowserContentFormat,
29
+ BrowserControlOwner,
30
+
31
+ BrowserContentRequest,
32
+ BrowserContentResult,
33
+ BrowserDragRequest,
34
+ BrowserDragResult,
35
+ BrowserExecuteRequest,
36
+ BrowserPointerResult,
37
+ BrowserPointerTarget,
38
+ BrowserExecuteResult,
39
+ BrowserFillField,
40
+ BrowserFillRequest,
41
+ BrowserFillResult,
42
+ BrowserHandoffState,
43
+ BrowserNavigateRequest,
44
+ BrowserOpenRequest,
45
+ BrowserProvider,
46
+ BrowserRefRequest,
47
+ BrowserScreenshotRequest,
48
+ BrowserScrollIntoViewRequest,
49
+ BrowserScrollRequest,
50
+ BrowserScrollResult,
51
+ BrowserScreenshotResult,
52
+ BrowserSessionId,
53
+ BrowserSnapshotElement,
54
+ BrowserSnapshotResult,
55
+ BrowserTab,
56
+ BrowserTaskInfo,
57
+ BrowserTaskStatus,
58
+ BrowserTaskUpdate,
59
+ BrowserTypeRequest,
60
+ ExportedCookie,
61
+ } from './browser/types.ts'
62
+ export { BrowserRuntime } from './browser/runtime.ts'
63
+ export { ElectronBrowserProvider } from './browser-electron/provider.ts'
64
+ export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts'
65
+ export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts'
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Bridge between the Agent's todo list and the shared browser window.
3
+ *
4
+ * DSH keeps each Agent's plan as a session projection (`todos`, written by
5
+ * `todo_write` and folded from `todo/write` events). The floating orb in the
6
+ * browser window renders that plan, but the browser host is a child process that
7
+ * can only see what the parent hands it — so this row subscribes to the
8
+ * projection change feed and mirrors the plan into the browser provider.
9
+ *
10
+ * Every dependency here is optional on purpose. A deployment without the
11
+ * projection registry, or without the todo tool mounted at all, must still get a
12
+ * working browser: the orb then simply has no plan to show and falls back to the
13
+ * task's own status and last browser action.
14
+ * @module dsh-browser-plus/task-todos
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+
19
+ /** One entry as this bridge forwards it (the provider validates again). */
20
+ interface TodoItem {
21
+ readonly content: string
22
+ readonly status: 'pending' | 'in_progress' | 'completed'
23
+ }
24
+
25
+ /** The browser seam surface this bridge uses. */
26
+ interface TodoSink {
27
+ pushTaskTodos?(taskKey: string, todos: readonly TodoItem[]): Promise<void>
28
+ }
29
+
30
+ /** Minimal shape of `ctx.sessionProjections` (kept structural). */
31
+ interface ProjectionRegistry {
32
+ onChanged(listener: (session: { readonly id?: unknown }, key: string, value: unknown, seq: number) => void): () => void
33
+ }
34
+
35
+ /** Cordis plugin name used by loader diagnostics. */
36
+ export const name = 'browser-task-todos'
37
+
38
+ /** The browser seam; the projection registry is injected optionally below. */
39
+ export const inject = ['browser']
40
+
41
+ /** Keep at most this many entries — the orb shows a short list, not a backlog. */
42
+ const MAX_ITEMS = 40
43
+
44
+ /** Keep one entry short enough to read in a popover. */
45
+ const MAX_CONTENT = 160
46
+
47
+ /**
48
+ * Normalize whatever the projection holds into the orb's three states. Anything
49
+ * unrecognized becomes `pending` rather than being dropped: a plan line the UI
50
+ * cannot classify is still a line the human asked to see.
51
+ * @param value - the projection's raw `todos` value.
52
+ * @returns the entries to forward.
53
+ */
54
+ function normalize(value: unknown): TodoItem[] {
55
+ if (!Array.isArray(value)) return []
56
+ const items: TodoItem[] = []
57
+ for (const raw of value) {
58
+ const entry = raw as { content?: unknown; status?: unknown }
59
+ if (typeof entry?.content !== 'string') continue
60
+ const content = entry.content.trim()
61
+ if (content === '') continue
62
+ const status = entry.status === 'completed' || entry.status === 'in_progress' ? entry.status : 'pending'
63
+ items.push({ content: content.slice(0, MAX_CONTENT), status })
64
+ if (items.length >= MAX_ITEMS) break
65
+ }
66
+ return items
67
+ }
68
+
69
+ /**
70
+ * Register the bridge.
71
+ * @param ctx - plugin context carrying the browser seam.
72
+ */
73
+ export function apply(ctx: Context): void {
74
+ // 只在**失败**路径上留话:这条链横跨「会话投影 → 桥接行 → provider → 宿主 → 页面」,
75
+ // 任一环断掉在页面上都只表现为「球上没有清单」,正常路径一声不响。
76
+ const trace = (line: string): void => { try { process.stderr.write('[browser-task-todos] ' + line + '\n') } catch { /* no stderr */ } }
77
+ const browser = (ctx as unknown as { readonly browser?: TodoSink }).browser
78
+ if (browser === undefined || typeof browser.pushTaskTodos !== 'function') {
79
+ trace('skip: the browser seam has no pushTaskTodos')
80
+ return
81
+ }
82
+ const push = browser.pushTaskTodos.bind(browser)
83
+ // `ctx.inject` keeps the row loadable when the registry is absent: the
84
+ // callback simply never runs and the browser keeps working without a plan.
85
+ ctx.inject(['sessionProjections'], (scoped: Context) => {
86
+ const registry = (scoped as unknown as { readonly sessionProjections?: ProjectionRegistry }).sessionProjections
87
+ if (registry === undefined || typeof registry.onChanged !== 'function') {
88
+ trace('sessionProjections arrived without onChanged')
89
+ return
90
+ }
91
+ // 每个会话最后一份清单。宿主重启会丢掉它内存里那份,而桥接只在**变化**时推 ——
92
+ // 存一份就能在「这个会话又发生了什么」时顺手补推(见下面的 !isTodos 分支)。
93
+ const lastTodos = new Map<string, readonly TodoItem[]>()
94
+ const off = registry.onChanged((session, key, value) => {
95
+ const taskKey = typeof session?.id === 'string' ? session.id : ''
96
+ if (taskKey === '') return
97
+ if (key !== 'todos') {
98
+ // 该会话的**其它**投影变了 —— 说明它活着,把手里那份计划补推一次。
99
+ // 这覆盖「DSH 重启后 / 宿主重启后球上一直空着」:只要会话再有任何活动,清单就回来。
100
+ const known = lastTodos.get(taskKey)
101
+ if (known !== undefined) void push(taskKey, known).catch(() => undefined)
102
+ return
103
+ }
104
+ // The tool layer keys browser tasks by the calling Agent's id, which is the
105
+ // session id — so a plan written by an Agent lands on that Agent's task.
106
+ const todos = normalize(value)
107
+ lastTodos.set(taskKey, todos)
108
+ void push(taskKey, todos).catch(() => undefined)
109
+ })
110
+ // 监听器挂在 registry 自己的 fiber 上,插件被卸载/重载时要自己摘掉,
111
+ // 否则会残留一条绑在旧 seam 上的推送(review 报的 L5)。
112
+ if (typeof off === 'function') ctx.effect(() => off)
113
+ })
114
+ }