@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.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 (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. package/src/web-route.js +116 -0
package/index.js ADDED
@@ -0,0 +1,360 @@
1
+ import { homedir } from 'node:os'
2
+ import { dirname, join } from 'node:path'
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { createApprovalHook } from './src/approval-hook.js'
6
+ import { createUserQuestionHook } from './src/user-question-hook.js'
7
+ import { createDeviceStore } from './src/devices.js'
8
+ import { createPairing } from './src/pairing.js'
9
+ import { createPocketServer } from './src/server.js'
10
+ import { TaskEventHub, watchTaskCompletions } from './src/task-events.js'
11
+ import { TaskFallbackTracker } from './src/task-fallback.js'
12
+ import { loadOrCreateToken } from './src/token.js'
13
+ import { zipSync, strToU8 } from 'fflate'
14
+ import { permissionOps, presetOps, commandOps, skillOps, filesOps, jobsOps, exportOps, terminalOps } from './src/extra-ops.js'
15
+ import {
16
+ searchOps, workspaceOps, feedbackOps, fileReferenceOps, sessionReferenceOps,
17
+ attachmentOps, workspaceChangesOps, uploadOps, userQuestionOps, directoryOps, goalOps,
18
+ } from './src/parity-ops.js'
19
+ import {
20
+ modelsSettingsOps, pluginsOps, pluginSettingsOps, pluginInventoryOps, scheduleOps, credentialsOps,
21
+ } from './src/settings-ops.js'
22
+ import { registerPairRoute } from './src/web-route.js'
23
+ import { registerCompletionRoutes } from './src/completion-route.js'
24
+ import { createManageApi, pairingString } from './src/manage.js'
25
+ import { startIrohSidecar } from './src/iroh.js'
26
+ import { loadTransport, saveTransport, normalizeTransport } from './src/transport-pref.js'
27
+ import { listAdvertiseNetworks, pickBindHost, selectNetwork } from './src/advertise-host.js'
28
+ import { loadOrCreateInstanceId } from './src/instance-id.js'
29
+ import { startMdnsAdvertise } from './src/mdns-advertise.js'
30
+ import { createOfficePreviewOps } from './src/office-preview.js'
31
+
32
+ export const name = 'dsh-plugin-pocket'
33
+ // webServer is optional: Cordis `inject` entries are all required, so the
34
+ // loopback pairing route is mounted through a nested ctx.inject(['webServer'])
35
+ // that only runs when (and while) a webServer service exists.
36
+ export const inject = ['sessionController']
37
+
38
+ const SESSION_EVENTS = ['added', 'removed', 'status', 'activity', 'error']
39
+ const PKG = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'))
40
+
41
+ /**
42
+ * @param {import('@deepseek-ai/cordis').Context} ctx
43
+ * @param {{host?: string, port?: number, token?: string, dataDir?: string, pair?: boolean, iroh?: boolean, hostBinary?: string, transport?: string, pluginUiPaths?: string[]}} [config]
44
+ */
45
+ export function apply(ctx, config = {}) {
46
+ const log = (...a) => console.log('[pocket]', ...a)
47
+ const dataDir = config.dataDir ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'pocket')
48
+ const { token, created, path } = loadOrCreateToken(dataDir, config.token)
49
+ const devices = createDeviceStore({ dataDir, ownerToken: token })
50
+ const pairing = createPairing()
51
+ const listeners = new Set()
52
+
53
+ // Phone path is host:port WS only (Tailscale or LAN). Config may lock transport=tailscale.
54
+ let remoteTransport = loadTransport(dataDir, config.transport)
55
+ const configLocksTransport = Boolean(normalizeTransport(config.transport))
56
+
57
+ const port = config.port ?? 7690
58
+ const bindHost = pickBindHost(config.host)
59
+ const instanceId = loadOrCreateInstanceId(dataDir)
60
+ /** @type {{ stop: () => Promise<void>, deviceName: string } | null} */
61
+ let mdns = null
62
+ const getNetworks = () => {
63
+ const address = server.address()
64
+ return address && typeof address === 'object'
65
+ ? listAdvertiseNetworks(config.host, address.address)
66
+ : []
67
+ }
68
+ const taskHub = new TaskEventHub()
69
+ /** @type {TaskFallbackTracker | null} */
70
+ let taskFallback = null
71
+ const server = createPocketServer({
72
+ sessionController: ctx.sessionController, token, host: bindHost, port, devices, pairing,
73
+ getSubagents: () => ctx.get('subagents'),
74
+ info: {
75
+ plugin: PKG.name,
76
+ version: PKG.version,
77
+ instanceId,
78
+ // Phone VersionCompatibility requires this floor; bump together with app minSdk policy.
79
+ minimumAppVersion: '0.1.0',
80
+ },
81
+ onSessionEvent: (fn) => { listeners.add(fn); return () => listeners.delete(fn) },
82
+ onSessionLifecycle: (kind, args) => {
83
+ if (!taskFallback) return
84
+ if (kind === 'status') taskFallback.onStatus(args[0], Boolean(args[1]))
85
+ else if (kind === 'activity') taskFallback.markActivity(args[0], typeof args[1] === 'number' ? args[1] : undefined)
86
+ else if (kind === 'removed') taskFallback.forget(args[0])
87
+ },
88
+ })
89
+ taskHub.add({ broadcastTaskEvent: (event) => server.broadcastTaskEvent(event) })
90
+ taskFallback = new TaskFallbackTracker({
91
+ onTaskCompleted: (event) => taskHub.broadcast(event),
92
+ isRootSession: (sessionId) => server.isRootSession(sessionId),
93
+ hasPendingQuestion: (sessionId) => server.hasPendingUserQuestionForSession(sessionId),
94
+ })
95
+ // Adapted from saya-ch/dsh-mobile (Apache-2.0): exact root turn/end completions.
96
+ const disposeTaskEvents = watchTaskCompletions(ctx, {
97
+ onTaskCompleted: (event) => {
98
+ taskFallback?.markAnnounced(event.sessionId)
99
+ taskHub.broadcast(event)
100
+ },
101
+ log: (event, fields) => log(`task ${event}`, fields),
102
+ })
103
+ for (const kind of SESSION_EVENTS) {
104
+ ctx.on(`api-session/${kind}`, (...args) => { for (const fn of listeners) fn(kind, args) })
105
+ }
106
+ ctx.inject(['sessionProjections'], (c) => {
107
+ c.effect(() => c.sessionProjections.onChanged((session, key, value, seq) => {
108
+ server.broadcastProjection(session.id, key, value, seq)
109
+ }))
110
+ })
111
+ // prepend: run outside the web UI forwarder so we can race it.
112
+ ctx.on('approval/request', createApprovalHook(server, { log }), { prepend: true })
113
+
114
+ /** Optional legacy desktop iroh sidecar — off unless config.iroh === true. Not on the phone path. */
115
+ let iroh = null
116
+
117
+ const getTransport = () => remoteTransport
118
+ const setTransportMode = (mode) => {
119
+ if (configLocksTransport) {
120
+ throw new Error(`transport is locked by cordis config (${config.transport})`)
121
+ }
122
+ remoteTransport = saveTransport(dataDir, mode)
123
+ log(`transport set to ${remoteTransport}`)
124
+ return remoteTransport
125
+ }
126
+
127
+ const openPairing = ({ type, host, port }) => {
128
+ const mode = remoteTransport
129
+ const w = pairing.open()
130
+ const line = pairingString({ code: w.code, host, port, transport: mode })
131
+ log(`pairing (${type}, ${Math.round(w.expiresInSec / 60)} min): ${line}`)
132
+ }
133
+ const manage = createManageApi({
134
+ getNetworks, port, devices, pairing, server, openPairing,
135
+ getTransport,
136
+ setTransport: setTransportMode,
137
+ instanceId,
138
+ getMdns: () => mdns !== null,
139
+ })
140
+ ctx.on('permission-presets/catalog-changed', () => server.broadcastPermissionCatalogChanged())
141
+ ctx.inject(['permissionPresets', 'agents'], (c) => {
142
+ c.effect(() => {
143
+ const unregister = server.registerOps(permissionOps(c.permissionPresets, c.agents))
144
+ server.broadcastPermissionCatalogChanged()
145
+ return () => {
146
+ unregister()
147
+ server.broadcastPermissionCatalogChanged()
148
+ }
149
+ })
150
+ })
151
+ ctx.inject(['agentPresets'], (c) => {
152
+ c.effect(() => server.registerOps(presetOps(c.agentPresets)))
153
+ c.inject(['settingsController'], (settingsCtx) => {
154
+ settingsCtx.effect(() => server.registerOps({
155
+ 'presets.settings': presetOps(settingsCtx.agentPresets, settingsCtx.settingsController)['presets.settings'],
156
+ }))
157
+ })
158
+ })
159
+ ctx.inject(['commands', 'agents'], (c) => {
160
+ c.effect(() => server.registerOps(commandOps(c.commands, c.agents, c.get?.('agentPresets'))))
161
+ c.inject(['agentPresets'], (p) => {
162
+ p.effect(() => server.registerOps(commandOps(p.commands, p.agents, p.agentPresets)))
163
+ })
164
+ })
165
+ ctx.inject(['sessionSkillCatalog'], (c) => {
166
+ c.effect(() => server.registerOps(skillOps(c.sessionSkillCatalog, {
167
+ skills: c.get?.('skills'),
168
+ workingDirectory: c.get?.('workingDirectory'),
169
+ agentPresets: c.get?.('agentPresets'),
170
+ })))
171
+ c.inject(['skills', 'workingDirectory', 'agentPresets'], (full) => {
172
+ full.effect(() => server.registerOps(skillOps(full.sessionSkillCatalog, {
173
+ skills: full.skills,
174
+ workingDirectory: full.workingDirectory,
175
+ agentPresets: full.agentPresets,
176
+ })))
177
+ })
178
+ })
179
+ ctx.inject(['workspaceFiles', 'sessions'], (c) => {
180
+ c.effect(() => server.registerOps(filesOps(c.workspaceFiles, c.sessions, c.get?.('sandboxPolicy'))))
181
+ })
182
+ ctx.inject(['officeToPdf', 'workspaceFiles', 'sessions', 'sandboxPolicy'], (c) => {
183
+ c.effect(() => {
184
+ const preview = createOfficePreviewOps(c)
185
+ const unregister = server.registerOps(preview.ops)
186
+ return () => { preview.dispose(); unregister() }
187
+ })
188
+ })
189
+ ctx.inject(['jobController'], (c) => { c.effect(() => server.registerOps(jobsOps(c.jobController))) })
190
+ ctx.inject(['terminalController', 'agents'], (c) => {
191
+ c.effect(() => server.registerOps(terminalOps(c.terminalController, c.agents)))
192
+ })
193
+ server.registerOps(exportOps(ctx.sessionController, { zipSync, strToU8 }))
194
+ server.registerOps(searchOps(ctx.sessionController))
195
+ server.registerOps(attachmentOps(ctx.sessionController))
196
+ ctx.inject(['workspaceChanges'], (c) => {
197
+ c.effect(() => server.registerOps(workspaceChangesOps(c.workspaceChanges)))
198
+ })
199
+ ctx.inject(['workspaceController'], (c) => {
200
+ c.effect(() => {
201
+ const unregister = server.registerOps(workspaceOps(c.workspaceController))
202
+ const ac = new AbortController()
203
+ void (async () => {
204
+ try {
205
+ for await (const frame of c.workspaceController.follow(ac.signal)) {
206
+ if (ac.signal.aborted) break
207
+ server.broadcastEvent('workspace.changed', { frame })
208
+ }
209
+ } catch (error) {
210
+ if (!ac.signal.aborted) log('workspace follow failed', String(error?.message ?? error))
211
+ }
212
+ })()
213
+ return () => { ac.abort(); unregister() }
214
+ })
215
+ })
216
+ ctx.inject(['directoryPickerController', 'directoryPicker'], (c) => {
217
+ c.effect(() => server.registerOps(directoryOps(c.directoryPickerController, c.directoryPicker)))
218
+ })
219
+ ctx.inject(['goals', 'agents'], (c) => {
220
+ c.effect(() => server.registerOps(goalOps(c.goals, c.agents)))
221
+ })
222
+ ctx.on('goal/activation-changed', (payload) => server.broadcastEvent('goal.activation', payload))
223
+ ctx.on('schedule/changed', () => server.broadcastEvent('schedule.changed'))
224
+ ctx.on('settings/document-updated', (ns, revision) => server.broadcastEvent('settings.changed', { ns, revision }))
225
+ ctx.on('credentials/reference-updated', () => server.broadcastEvent('credentials.changed'))
226
+ ctx.on('credentials/record-updated', () => server.broadcastEvent('credentials.changed'))
227
+ ctx.on('llm/adapters-updated', () => server.broadcastEvent('models.changed'))
228
+ ctx.on('plugin-manager/changed', () => server.broadcastEvent('plugins.changed'))
229
+ ctx.on('agent-preset/selected', (sessionId) => server.broadcastEvent('presets.changed', { sessionId }))
230
+ ctx.inject(['messageFeedback'], (c) => {
231
+ c.effect(() => server.registerOps(feedbackOps(c.messageFeedback)))
232
+ })
233
+ ctx.inject(['sessionFeedback'], (c) => {
234
+ c.effect(() => server.registerOps({
235
+ 'sessionFeedback.record': async (a) => {
236
+ const result = await c.sessionFeedback.record(a)
237
+ if (!result.ok) throw Object.assign(new Error(result.error.code), { code: result.error.code, details: result.error })
238
+ return result.value
239
+ },
240
+ }))
241
+ })
242
+ ctx.inject(['fileReferences', 'sessionController'], (c) => {
243
+ c.effect(() => server.registerOps(fileReferenceOps(c.fileReferences, c.sessionController)))
244
+ })
245
+ ctx.inject(['sessionReferenceResolver', 'sessionController', 'sessionQuery'], (c) => {
246
+ c.effect(() => server.registerOps(sessionReferenceOps(c.sessionReferenceResolver, c.sessionController, c.sessionQuery)))
247
+ })
248
+ ctx.inject(['fileUploads', 'agents'], (c) => {
249
+ c.effect(() => server.registerOps(uploadOps(c.fileUploads, c.agents)))
250
+ })
251
+ ctx.inject(['userQuestions', 'sessionController'], (c) => {
252
+ c.on('user-questions/request', createUserQuestionHook(server, { userQuestions: c.userQuestions }), { prepend: true })
253
+ c.effect(() => server.registerOps(userQuestionOps(c.userQuestions, c.sessionController, {
254
+ settleOffer: (sessionId, callId, answer, deviceId) => server.settleUserQuestion(sessionId, callId, answer, deviceId),
255
+ })))
256
+ })
257
+ // Desktop settings surfaces (models providers / plugins / schedules).
258
+ // Each inject is optional; ops appear in hello only while the Host service is live.
259
+ ctx.inject(['llm', 'settingsController'], (c) => {
260
+ c.effect(() => {
261
+ const settings = c.get?.('settings') ?? null
262
+ const settingsController = c.settingsController
263
+ const credentials = c.get?.('credentials') ?? null
264
+ return server.registerOps(modelsSettingsOps({
265
+ llm: c.llm, settings, settingsController, credentials,
266
+ }))
267
+ })
268
+ })
269
+ ctx.inject(['pluginManager'], (c) => {
270
+ c.effect(() => server.registerOps(pluginsOps(c.pluginManager)))
271
+ })
272
+ ctx.inject(['pluginInventory'], (c) => {
273
+ c.effect(() => server.registerOps(pluginInventoryOps(c.pluginInventory)))
274
+ })
275
+ ctx.inject(['settingsController'], (c) => {
276
+ c.effect(() => server.registerOps(pluginSettingsOps(c.settingsController)))
277
+ })
278
+ ctx.inject(['schedule'], (c) => {
279
+ c.effect(() => server.registerOps(scheduleOps(c.schedule)))
280
+ })
281
+ ctx.inject(['credentials'], (c) => {
282
+ c.effect(() => server.registerOps(credentialsOps({ credentials: c.credentials })))
283
+ })
284
+ ctx.inject(['webServer'], (webCtx) => {
285
+ registerPairRoute(webCtx, manage)
286
+ registerCompletionRoutes(webCtx, server.completion)
287
+ })
288
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
289
+ webCtx.effect(() => server.installHostUi({
290
+ webServer: webCtx.webServer,
291
+ connection: webCtx.connection,
292
+ pluginUiPaths: config.pluginUiPaths,
293
+ }))
294
+ })
295
+
296
+ ctx.effect(() => {
297
+ const start = async () => {
298
+ log(`remote transport: ${remoteTransport} (bind ${bindHost}:${port})`)
299
+
300
+ // Legacy opt-in only — phone APK no longer ships iroh; keep server code dormant by default.
301
+ if (config.iroh === true) {
302
+ iroh = await startIrohSidecar({
303
+ server,
304
+ dataDir: join(dataDir, 'iroh'),
305
+ hostBinary: config.hostBinary,
306
+ log,
307
+ })
308
+ if (iroh) {
309
+ try {
310
+ await Promise.race([
311
+ iroh.endpoint,
312
+ new Promise((_, rej) => setTimeout(() => rej(new Error('iroh ready timeout')), 60_000)),
313
+ ])
314
+ log('legacy iroh sidecar ready (not used for phone pairing)')
315
+ } catch (e) {
316
+ console.error('[pocket] iroh sidecar did not become ready:', e.message ?? e)
317
+ await iroh.close()
318
+ iroh = null
319
+ }
320
+ }
321
+ }
322
+
323
+ const addr = await server.listen()
324
+ const selected = selectNetwork(getNetworks())
325
+ const listenAddress = server.address()
326
+ const listenPort = listenAddress && typeof listenAddress === 'object' ? listenAddress.port : port
327
+ log(`ws listening on ws://${addr} (pairing ${selected ? `${selected.type}: ${selected.host}:${listenPort}` : 'unavailable: no reachable network'})`)
328
+ log(`instance ${instanceId.slice(0, 8)}…`)
329
+ log(created ? `new owner token written to ${path}: ${token}` : `owner token: ${path}`)
330
+
331
+ try {
332
+ if (selected) {
333
+ mdns = startMdnsAdvertise({
334
+ host: selected.host,
335
+ port: listenPort,
336
+ instanceId,
337
+ log,
338
+ onError: (err) => console.error('[pocket] mdns:', err.message ?? err),
339
+ })
340
+ }
341
+ } catch (e) {
342
+ console.error('[pocket] mdns advertise failed:', e.message ?? e)
343
+ mdns = null
344
+ }
345
+
346
+ if (config.pair || devices.list().length === 0) {
347
+ try { manage.openPairing() } catch (e) { console.error('[pocket]', e.message ?? e) }
348
+ }
349
+ }
350
+ start().catch((error) => console.error('[pocket] start failed:', error))
351
+ return () => {
352
+ void mdns?.stop()
353
+ mdns = null
354
+ disposeTaskEvents()
355
+ taskFallback?.dispose()
356
+ void iroh?.close()
357
+ void server.close()
358
+ }
359
+ })
360
+ }