@linxin666/dsh-remote-web-ui 0.2.8 → 0.3.0

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 (86) hide show
  1. package/LICENSE +202 -28
  2. package/README.i18n.yaml +2 -2
  3. package/README.md +51 -15
  4. package/README.zh.md +9 -4
  5. package/lib/client.js +65 -34
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +667 -77
  8. package/lib/mobile.js +874 -64
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/index.d.ts.map +1 -1
  11. package/lib/types/client/index.js +35 -5
  12. package/lib/types/client/remote-channel.d.ts +4 -5
  13. package/lib/types/client/remote-channel.d.ts.map +1 -1
  14. package/lib/types/client/remote-channel.js +13 -28
  15. package/lib/types/http.d.ts +37 -8
  16. package/lib/types/http.d.ts.map +1 -1
  17. package/lib/types/http.js +76 -11
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +40 -5
  20. package/lib/types/mobile/api.d.ts +57 -0
  21. package/lib/types/mobile/api.d.ts.map +1 -1
  22. package/lib/types/mobile/api.js +29 -0
  23. package/lib/types/mobile/markdown.d.ts +7 -1
  24. package/lib/types/mobile/markdown.d.ts.map +1 -1
  25. package/lib/types/mobile/markdown.js +16 -7
  26. package/lib/types/mobile/markdown.test.js +8 -0
  27. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  28. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  29. package/lib/types/mobile/mobile-styles.js +325 -6
  30. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  31. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  32. package/lib/types/mobile/views/ChatView.js +205 -8
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  35. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  36. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  37. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  38. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  39. package/lib/types/mobile-api.d.ts +9 -3
  40. package/lib/types/mobile-api.d.ts.map +1 -1
  41. package/lib/types/mobile-api.js +61 -16
  42. package/lib/types/mobile-pending.d.ts +38 -0
  43. package/lib/types/mobile-pending.d.ts.map +1 -0
  44. package/lib/types/mobile-pending.js +59 -0
  45. package/lib/types/paired-model-catalog.d.ts +17 -0
  46. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  47. package/lib/types/paired-model-catalog.js +252 -0
  48. package/lib/types/pairing.d.ts +13 -4
  49. package/lib/types/pairing.d.ts.map +1 -1
  50. package/lib/types/pairing.js +13 -5
  51. package/lib/types/remote-api.d.ts +13 -4
  52. package/lib/types/remote-api.d.ts.map +1 -1
  53. package/lib/types/remote-api.js +27 -17
  54. package/lib/types/remote-channel-boot.d.ts +26 -0
  55. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  56. package/lib/types/remote-channel-boot.js +128 -0
  57. package/lib/types/remote-channel-rules.d.ts +40 -0
  58. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  59. package/lib/types/remote-channel-rules.js +32 -0
  60. package/lib/types/routes.d.ts.map +1 -1
  61. package/lib/types/routes.js +12 -23
  62. package/lib/types/uuid-polyfill.d.ts +10 -0
  63. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  64. package/lib/types/uuid-polyfill.js +27 -0
  65. package/package.json +1 -1
  66. package/src/client/index.ts +41 -5
  67. package/src/client/remote-channel.ts +18 -28
  68. package/src/http.ts +87 -12
  69. package/src/index.ts +42 -6
  70. package/src/mobile/api.ts +91 -0
  71. package/src/mobile/markdown.test.ts +9 -0
  72. package/src/mobile/markdown.ts +17 -7
  73. package/src/mobile/mobile-styles.ts +326 -6
  74. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  75. package/src/mobile/views/ChatView.tsx +328 -10
  76. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  77. package/src/mobile/views/WorkspaceView.tsx +152 -26
  78. package/src/mobile-api.ts +59 -17
  79. package/src/mobile-pending.ts +88 -0
  80. package/src/paired-model-catalog.ts +224 -0
  81. package/src/pairing.ts +13 -5
  82. package/src/remote-api.ts +34 -18
  83. package/src/remote-channel-boot.ts +131 -0
  84. package/src/remote-channel-rules.ts +62 -0
  85. package/src/routes.ts +13 -24
  86. package/src/uuid-polyfill.ts +28 -0
@@ -1,17 +1,22 @@
1
1
  // @vitest-environment jsdom
2
2
  /** Mobile workspace landing: roster rendering and QR deep-link selection. */
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, render, screen, waitFor } from '@testing-library/react'
4
+ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react'
5
5
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
6
6
  import { mobileWorkspaceTarget } from './App.tsx'
7
7
  import { WorkspaceView } from './WorkspaceView.tsx'
8
8
 
9
9
  vi.mock('../api.ts', () => ({
10
10
  listWorkspaces: vi.fn(),
11
+ listDirectory: vi.fn(),
12
+ createWorkspace: vi.fn(),
11
13
  }))
12
- import { listWorkspaces } from '../api.ts'
14
+ import { listWorkspaces, listDirectory, createWorkspace } from '../api.ts'
13
15
 
14
16
  const listWorkspacesMock = vi.mocked(listWorkspaces)
17
+ const listDirectoryMock = vi.mocked(listDirectory)
18
+ const createWorkspaceMock = vi.mocked(createWorkspace)
19
+
15
20
  const workspaces: WorkspaceRow[] = [
16
21
  {
17
22
  workspaceId: 'ws-1' as never,
@@ -64,3 +69,159 @@ describe('mobile workspace deep link', () => {
64
69
  expect(onPick).not.toHaveBeenCalled()
65
70
  })
66
71
  })
72
+
73
+ describe('mobile workspace creation', () => {
74
+ it('shows new workspace button and navigates to directory browser', async () => {
75
+ listWorkspacesMock.mockResolvedValue(workspaces)
76
+ listDirectoryMock.mockResolvedValue({
77
+ path: '/home/user',
78
+ home: '/home/user',
79
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
80
+ entries: [
81
+ { name: 'projects', path: '/home/user/projects', hidden: false },
82
+ { name: '.config', path: '/home/user/.config', hidden: true }
83
+ ],
84
+ truncated: false
85
+ })
86
+
87
+ const onPick = vi.fn()
88
+ render(<WorkspaceView onPick={onPick} />)
89
+
90
+ // Wait for workspaces to load
91
+ const createBtn = await screen.findByText('+ 新建工作区')
92
+
93
+ // Click create button
94
+ fireEvent.click(createBtn)
95
+
96
+ // Verify directory browser renders
97
+ expect(await screen.findByText('选择目录')).toBeTruthy()
98
+
99
+ // Verify crumbs
100
+ expect(await screen.findByText('user')).toBeTruthy()
101
+
102
+ // Verify entries
103
+ expect(await screen.findByText('📁 projects')).toBeTruthy()
104
+ expect(await screen.findByText('📁 .config')).toBeTruthy()
105
+ })
106
+
107
+ it('navigates into directory and allows breadcrumb navigation', async () => {
108
+ listWorkspacesMock.mockResolvedValue(workspaces)
109
+
110
+ // Initial dir
111
+ listDirectoryMock.mockResolvedValueOnce({
112
+ path: '/home/user',
113
+ home: '/home/user',
114
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
115
+ entries: [{ name: 'projects', path: '/home/user/projects', hidden: false }],
116
+ truncated: false
117
+ })
118
+
119
+ const onPick = vi.fn()
120
+ render(<WorkspaceView onPick={onPick} />)
121
+
122
+ // Open dir browser
123
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
124
+
125
+ // Wait for first dir listing
126
+ const projBtn = await screen.findByText('📁 projects')
127
+
128
+ // Prepare next listing
129
+ listDirectoryMock.mockResolvedValueOnce({
130
+ path: '/home/user/projects',
131
+ home: '/home/user',
132
+ crumbs: [
133
+ { name: '', path: '/', hidden: false },
134
+ { name: 'home', path: '/home', hidden: false },
135
+ { name: 'user', path: '/home/user', hidden: false },
136
+ { name: 'projects', path: '/home/user/projects', hidden: false }
137
+ ],
138
+ entries: [{ name: 'foo', path: '/home/user/projects/foo', hidden: false }],
139
+ truncated: false
140
+ })
141
+
142
+ // Click into folder
143
+ fireEvent.click(projBtn)
144
+
145
+ // Verify new contents load
146
+ expect(await screen.findByText('📁 foo')).toBeTruthy()
147
+
148
+ // Check breadcrumb
149
+ const homeCrumb = screen.getByText('home')
150
+ expect(homeCrumb).toBeTruthy()
151
+
152
+ // Click breadcrumb
153
+ listDirectoryMock.mockResolvedValueOnce({
154
+ path: '/home',
155
+ home: '/home/user',
156
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }],
157
+ entries: [{ name: 'user', path: '/home/user', hidden: false }],
158
+ truncated: false
159
+ })
160
+
161
+ fireEvent.click(homeCrumb)
162
+ expect(await screen.findByText('📁 user')).toBeTruthy()
163
+ })
164
+
165
+ it('creates workspace successfully', async () => {
166
+ listWorkspacesMock.mockResolvedValue(workspaces)
167
+ listDirectoryMock.mockResolvedValue({
168
+ path: '/home/user/projects/foo',
169
+ home: '/home/user',
170
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'foo', path: '/home/user/projects/foo', hidden: false }],
171
+ entries: [],
172
+ truncated: false
173
+ })
174
+
175
+ const newWorkspace = { ...workspaces[0], workspaceId: 'ws-new' as never, title: 'foo' }
176
+ createWorkspaceMock.mockResolvedValue({
177
+ workspace: newWorkspace,
178
+ created: true
179
+ })
180
+
181
+ const onPick = vi.fn()
182
+ render(<WorkspaceView onPick={onPick} />)
183
+
184
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
185
+
186
+ const selectBtn = await screen.findByText('选择此目录')
187
+ fireEvent.click(selectBtn)
188
+
189
+ expect(await screen.findByText('创建中…')).toBeTruthy()
190
+
191
+ await waitFor(() => {
192
+ expect(createWorkspaceMock).toHaveBeenCalledWith('/home/user/projects/foo')
193
+ expect(onPick).toHaveBeenCalledWith(newWorkspace)
194
+ })
195
+ })
196
+
197
+ it('handles errors from directory listing and workspace creation', async () => {
198
+ listWorkspacesMock.mockResolvedValue(workspaces)
199
+ listDirectoryMock.mockRejectedValue(new Error('Permission denied'))
200
+
201
+ const onPick = vi.fn()
202
+ render(<WorkspaceView onPick={onPick} />)
203
+
204
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
205
+
206
+ // Verify error from listing
207
+ expect(await screen.findByText('Permission denied')).toBeTruthy()
208
+
209
+ // Retry with success
210
+ listDirectoryMock.mockResolvedValue({
211
+ path: '/home',
212
+ home: '/home/user',
213
+ crumbs: [],
214
+ entries: [],
215
+ truncated: false
216
+ })
217
+ fireEvent.click(screen.getByText('重试'))
218
+
219
+ const selectBtn = await screen.findByText('选择此目录')
220
+
221
+ // Fail creation
222
+ createWorkspaceMock.mockRejectedValue(new Error('Already exists'))
223
+ fireEvent.click(selectBtn)
224
+
225
+ expect(await screen.findByText('Already exists')).toBeTruthy()
226
+ })
227
+ })
@@ -7,10 +7,125 @@
7
7
 
8
8
  import { useEffect, useState } from 'react'
9
9
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
- import { listWorkspaces } from '../api.ts'
10
+ import { listWorkspaces, listDirectory, createWorkspace, type DirectoryListing, type DirectoryEntry } from '../api.ts'
11
11
  import { errorText } from './App.tsx'
12
12
  import { ThemeToggle } from '../theme-toggle.tsx'
13
13
 
14
+ /** Props for the directory browser. */
15
+ interface DirectoryBrowserProps {
16
+ onCancel(): void
17
+ onPick(workspace: WorkspaceRow): void
18
+ }
19
+
20
+ /**
21
+ * Render the directory browser for creating a new workspace.
22
+ */
23
+ function DirectoryBrowser({ onCancel, onPick }: DirectoryBrowserProps) {
24
+ const [listing, setListing] = useState<DirectoryListing | undefined>(undefined)
25
+ const [error, setError] = useState<string | undefined>(undefined)
26
+ const [creating, setCreating] = useState(false)
27
+ const [path, setPath] = useState<string | undefined>(undefined)
28
+ const [reload, setReload] = useState(0)
29
+
30
+ useEffect(() => {
31
+ let cancelled = false
32
+ setError(undefined)
33
+ setListing(undefined)
34
+ void listDirectory(path).then(
35
+ (res) => {
36
+ if (cancelled) return
37
+ setListing(res)
38
+ },
39
+ (reason: unknown) => {
40
+ if (cancelled) return
41
+ setError(errorText(reason))
42
+ },
43
+ )
44
+ return () => { cancelled = true }
45
+ }, [path, reload])
46
+
47
+ const handleCreate = async () => {
48
+ if (listing === undefined || creating) return
49
+ setCreating(true)
50
+ setError(undefined)
51
+ try {
52
+ const result = await createWorkspace(listing.path)
53
+ onPick(result.workspace)
54
+ } catch (reason: unknown) {
55
+ setCreating(false)
56
+ setError(errorText(reason))
57
+ }
58
+ }
59
+
60
+ return (
61
+ <div className="mobile dir-browser">
62
+ <header className="mobile-header">
63
+ <button type="button" className="mobile-headerAction" onClick={onCancel}>返回</button>
64
+ <h1 className="mobile-title">选择目录</h1>
65
+ </header>
66
+
67
+ {listing !== undefined && (
68
+ <div className="dir-crumbs">
69
+ {listing.crumbs.map((crumb, idx) => (
70
+ <span key={crumb.path}>
71
+ <button
72
+ type="button"
73
+ className="dir-crumb"
74
+ onClick={() => setPath(crumb.path)}
75
+ >
76
+ {crumb.name || '/'}
77
+ </button>
78
+ {idx < listing.crumbs.length - 1 && <span className="dir-crumb-separator">/</span>}
79
+ </span>
80
+ ))}
81
+ </div>
82
+ )}
83
+
84
+ {error !== undefined ? (
85
+ <div className="mobile-empty">
86
+ <p className="mobile-error">{error}</p>
87
+ <button type="button" className="mobile-button" onClick={() => setReload(n => n + 1)}>重试</button>
88
+ </div>
89
+ ) : listing === undefined ? (
90
+ <div className="mobile-empty">
91
+ <p className="mobile-muted">加载中…</p>
92
+ </div>
93
+ ) : (
94
+ <ul className="mobile-list">
95
+ {listing.entries.length === 0 ? (
96
+ <div className="mobile-empty dir-empty">
97
+ <p className="mobile-muted">空目录</p>
98
+ </div>
99
+ ) : (
100
+ listing.entries.map((entry: DirectoryEntry) => (
101
+ <li key={entry.path}>
102
+ <button
103
+ type="button"
104
+ className={`mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`}
105
+ onClick={() => setPath(entry.path)}
106
+ >
107
+ <span className="mobile-rowTitle">📁 {entry.name}</span>
108
+ </button>
109
+ </li>
110
+ ))
111
+ )}
112
+ </ul>
113
+ )}
114
+
115
+ <div className="dir-select">
116
+ <button
117
+ type="button"
118
+ className="mobile-button"
119
+ disabled={listing === undefined || creating}
120
+ onClick={() => void handleCreate()}
121
+ >
122
+ {creating ? '创建中…' : '选择此目录'}
123
+ </button>
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
14
129
  /** Props for the workspace roster. */
15
130
  export interface WorkspaceViewProps {
16
131
  /** Workspace carried by the pairing link; opened after the roster loads. */
@@ -29,6 +144,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
29
144
  const [error, setError] = useState<string | undefined>(undefined)
30
145
  // Bumped by the retry button to re-run the roster fetch effect.
31
146
  const [reload, setReload] = useState(0)
147
+ const [isCreating, setIsCreating] = useState(false)
32
148
 
33
149
  useEffect(() => {
34
150
  let cancelled = false
@@ -52,6 +168,15 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
52
168
  return () => { cancelled = true }
53
169
  }, [initialWorkspaceId, onPick, reload])
54
170
 
171
+ if (isCreating) {
172
+ return (
173
+ <DirectoryBrowser
174
+ onCancel={() => setIsCreating(false)}
175
+ onPick={onPick}
176
+ />
177
+ )
178
+ }
179
+
55
180
  if (error !== undefined) {
56
181
  return (
57
182
  <div className="mobile">
@@ -83,37 +208,38 @@ export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps
83
208
  )
84
209
  }
85
210
 
86
- if (items.length === 0) {
87
- return (
88
- <div className="mobile">
89
- <header className="mobile-header">
90
- <h1 className="mobile-title">工作区</h1>
91
- <ThemeToggle />
92
- </header>
93
- <div className="mobile-empty">
94
- <p className="mobile-muted">暂无工作区</p>
95
- </div>
96
- </div>
97
- )
98
- }
99
-
100
211
  return (
101
212
  <div className="mobile">
102
213
  <header className="mobile-header">
103
214
  <h1 className="mobile-title">工作区</h1>
104
215
  <ThemeToggle />
105
216
  </header>
106
- <ul className="mobile-list">
107
- {items.map(workspace => (
108
- <li key={workspace.workspaceId}>
109
- <button type="button" className="mobile-row" onClick={() => { onPick(workspace) }}>
110
- <span className="mobile-rowTitle">{workspace.title}</span>
111
- <span className="mobile-rowMeta">{workspace.path}</span>
112
- <span className="mobile-chevron">›</span>
113
- </button>
114
- </li>
115
- ))}
116
- </ul>
217
+ {items.length === 0 ? (
218
+ <div className="mobile-empty">
219
+ <p className="mobile-muted">暂无工作区</p>
220
+ </div>
221
+ ) : (
222
+ <ul className="mobile-list">
223
+ {items.map(workspace => (
224
+ <li key={workspace.workspaceId}>
225
+ <button type="button" className="mobile-row" onClick={() => { onPick(workspace) }}>
226
+ <span className="mobile-rowTitle">{workspace.title}</span>
227
+ <span className="mobile-rowMeta">{workspace.path}</span>
228
+ <span className="mobile-chevron">›</span>
229
+ </button>
230
+ </li>
231
+ ))}
232
+ </ul>
233
+ )}
234
+ <div style={{ padding: '16px' }}>
235
+ <button
236
+ type="button"
237
+ className="mobile-button"
238
+ onClick={() => setIsCreating(true)}
239
+ >
240
+ + 新建工作区
241
+ </button>
242
+ </div>
117
243
  </div>
118
244
  )
119
245
  }
package/src/mobile-api.ts CHANGED
@@ -9,9 +9,12 @@
9
9
  * Security model:
10
10
  * - Every request must carry a live paired-device cookie (the same gate
11
11
  * semantic as the LAN fence), enforced before any host call.
12
- * - Only an explicit allowlist of methods is proxied; privileged domains
13
- * (settings, credentials, host actions, goals, subagents, …) are never
14
- * reachable from the phone.
12
+ * - Only an explicit allowlist of methods is proxied ON THIS PREFIX. The
13
+ * allowlist constrains the /m/api proxy alone: the same paired-device
14
+ * cookie also passes the global api/gate, so a paired device is a
15
+ * full-control credential for the host /api surface outside the SDK's
16
+ * loopback-pinned privileged set (settings/credentials/agentPreset/host
17
+ * actions/llm.discoverModels). Pairing is full device trust.
15
18
  * - `session.list` is paged here (the host API returns everything; this
16
19
  * layer slices stable pages) so the phone never transfers the whole list.
17
20
  * - The live mux stream is bridged over Server-Sent Events on the same
@@ -24,6 +27,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
24
27
  import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
25
28
  import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
26
29
  import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
30
+ import type { PendingTracker } from './mobile-pending.ts'
27
31
  import type { PairingService } from './pairing.ts'
28
32
  import { readBoundedJson, writeJson } from './http.ts'
29
33
  import { readCookie } from './gate.ts'
@@ -37,6 +41,8 @@ import { readCookie } from './gate.ts'
37
41
  * device at a time.
38
42
  */
39
43
  const MOBILE_ALLOWLIST = new Set([
44
+ 'host.listDirectory',
45
+ 'workspace.create',
40
46
  'workspace.list',
41
47
  'agentPreset.list',
42
48
  'session.create',
@@ -55,6 +61,8 @@ const MOBILE_ALLOWLIST = new Set([
55
61
  * settings-domain write).
56
62
  */
57
63
  const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
64
+ const MOBILE_PENDING_METHOD = 'mobile.pending'
65
+ const MOBILE_RESPOND_METHOD = 'mobile.respond'
58
66
 
59
67
  /** One session.list page (thin phones load incrementally). */
60
68
  const SESSION_PAGE_SIZE = 20
@@ -87,6 +95,8 @@ export interface MobileApiDeps {
87
95
  service: PairingService
88
96
  /** The host ApiProxy service (injected by the plugin). */
89
97
  apiProxy: ApiProxy
98
+ /** The pending tracker. */
99
+ pendingTracker: PendingTracker
90
100
  /** The resolved mobile composer preference (live per request). */
91
101
  mobileEnterToSend: () => boolean
92
102
  /** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
@@ -147,14 +157,16 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
147
157
  return
148
158
  }
149
159
  const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length)
150
- const local = method === MOBILE_PREFERENCES_METHOD
160
+ const local = method === MOBILE_PREFERENCES_METHOD
161
+ || method === MOBILE_PENDING_METHOD
162
+ || method === MOBILE_RESPOND_METHOD
151
163
  if (!MOBILE_ALLOWLIST.has(method) && !local) {
152
164
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
153
165
  return
154
166
  }
155
167
  let envelope: unknown
156
168
  try {
157
- envelope = await readJsonBody(req)
169
+ envelope = await readBoundedJson(req, 64 * 1024)
158
170
  } catch {
159
171
  writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } })
160
172
  return
@@ -166,11 +178,41 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
166
178
  return
167
179
  }
168
180
  if (local) {
169
- writeJson(res, 200, {
170
- type: 'server-response',
171
- rpcId,
172
- result: { ok: true, value: { mobileEnterToSend: mobileEnterToSend() } },
173
- })
181
+ if (method === MOBILE_PREFERENCES_METHOD) {
182
+ writeJson(res, 200, {
183
+ type: 'server-response',
184
+ rpcId,
185
+ result: { ok: true, value: { mobileEnterToSend: mobileEnterToSend() } },
186
+ })
187
+ } else if (method === MOBILE_PENDING_METHOD) {
188
+ const payload = parsed.payload as any
189
+ writeJson(res, 200, {
190
+ type: 'server-response',
191
+ rpcId,
192
+ result: { ok: true, value: deps.pendingTracker.pending(payload?.sessionId) },
193
+ })
194
+ } else if (method === MOBILE_RESPOND_METHOD) {
195
+ const payload = parsed.payload as any
196
+ try {
197
+ const receipt = await apiProxy.respond({
198
+ type: 'client-response',
199
+ rpcId: RpcId(payload.rpcId),
200
+ result: { ok: true, value: payload.response },
201
+ })
202
+ writeJson(res, 200, {
203
+ type: 'server-response',
204
+ rpcId,
205
+ result: { ok: true, value: receipt },
206
+ })
207
+ } catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error)
209
+ writeJson(res, 200, {
210
+ type: 'server-response',
211
+ rpcId,
212
+ result: { ok: false, error: { code: 'internal', message } },
213
+ })
214
+ }
215
+ }
174
216
  return
175
217
  }
176
218
  try {
@@ -198,8 +240,10 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
198
240
  return
199
241
  }
200
242
  if (!gateOk(req)) {
201
- res.writeHead(403)
202
- res.end('forbidden')
243
+ writeJson(res, 403, {
244
+ ok: false,
245
+ error: { code: 'unpaired', message: 'mobile session is not paired' },
246
+ })
203
247
  return
204
248
  }
205
249
  res.writeHead(200, {
@@ -233,6 +277,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
233
277
  const frames = apiProxy.events.mux({ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`), payload: {} }, controller.signal)
234
278
  for await (const frame of frames) {
235
279
  if (closed) break
280
+ deps.pendingTracker.onFrame(frame as any)
236
281
  res.write(`data: ${JSON.stringify(frame)}\n\n`)
237
282
  }
238
283
  } catch {
@@ -250,11 +295,6 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
250
295
  ]
251
296
  }
252
297
 
253
- /** Read a request body as JSON (bounded). */
254
- async function readJsonBody(req: IncomingMessage): Promise<unknown> {
255
- return readBoundedJson(req, 64 * 1024)
256
- }
257
-
258
298
  /** Dispatch one allowlisted method through the host ApiProxy. */
259
299
  async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string, signal?: AbortSignal): Promise<unknown> {
260
300
  const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
@@ -303,6 +343,8 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
303
343
  result: response.result,
304
344
  })
305
345
  if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
346
+ if (method === 'workspace.create') return wrap(await apiProxy.workspace.create(request as never))
347
+ if (method === 'host.listDirectory') return wrap(await apiProxy.host.listDirectory(request as never, signal ?? new AbortController().signal))
306
348
  if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
307
349
  if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
308
350
  if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
@@ -0,0 +1,88 @@
1
+ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
2
+ import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
3
+
4
+ export interface PendingApproval {
5
+ rpcId: string
6
+ approvalId: string
7
+ toolName: string
8
+ callId?: string
9
+ reason?: string
10
+ }
11
+
12
+ export interface PendingQuestion {
13
+ rpcId: string
14
+ questions: Array<{
15
+ id: string
16
+ question: string
17
+ detail?: string
18
+ header?: string
19
+ options?: Array<{ label: string; description?: string }>
20
+ multiSelect?: boolean
21
+ }>
22
+ }
23
+
24
+ export interface PendingState {
25
+ approvals: PendingApproval[]
26
+ questions: PendingQuestion[]
27
+ }
28
+
29
+ export class PendingTracker {
30
+ private readonly sessions = new Map<string, { approvals: Map<string, PendingApproval>; questions: PendingQuestion[] }>()
31
+
32
+ /** Process one mux frame and update the pending state. */
33
+ onFrame(frame: RpcRequest<MuxFrame>): void {
34
+ const payload = frame.payload
35
+ if (payload.type === 'approval/requested') {
36
+ const state = this._getOrInit(payload.sessionId)
37
+ state.approvals.set(payload.approvalId, {
38
+ rpcId: frame.rpcId,
39
+ approvalId: payload.approvalId,
40
+ toolName: payload.toolName,
41
+ callId: payload.callId,
42
+ reason: payload.reason,
43
+ })
44
+ } else if (payload.type === 'approval/resolved') {
45
+ const state = this.sessions.get(payload.sessionId)
46
+ if (state) {
47
+ state.approvals.delete(payload.approvalId)
48
+ }
49
+ } else if (payload.type === 'question/requested') {
50
+ const state = this._getOrInit(payload.sessionId)
51
+ state.questions.push({
52
+ rpcId: frame.rpcId,
53
+ questions: payload.questions as any,
54
+ })
55
+ } else if (payload.type === 'question/resolved') {
56
+ const state = this.sessions.get(payload.sessionId)
57
+ if (state) {
58
+ state.questions = state.questions.filter(q => q.rpcId !== payload.questionRpcId)
59
+ }
60
+ }
61
+ }
62
+
63
+ private _getOrInit(sessionId: string) {
64
+ let state = this.sessions.get(sessionId)
65
+ if (!state) {
66
+ state = { approvals: new Map(), questions: [] }
67
+ this.sessions.set(sessionId, state)
68
+ }
69
+ return state
70
+ }
71
+
72
+ /** Query pending items for a session. */
73
+ pending(sessionId: string): PendingState {
74
+ const state = this.sessions.get(sessionId)
75
+ if (!state) {
76
+ return { approvals: [], questions: [] }
77
+ }
78
+ return {
79
+ approvals: Array.from(state.approvals.values()),
80
+ questions: state.questions,
81
+ }
82
+ }
83
+
84
+ /** Clear all pending state for a session. */
85
+ clear(sessionId: string): void {
86
+ this.sessions.delete(sessionId)
87
+ }
88
+ }