commit b2fb257aaab18598f0dd0a3e26cb0b8e5de3e109 Author: jiangchen <1703935164@qq.com> Date: Tue Sep 1 14:25:35 2026 +0800 Initial DSHWatch dsh-watch scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1a6840 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 truestudio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..59819ba --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# dsh-watch + +DeepSeek Harness 插件:把当前 Harness 变成一个可被 DSHWatch 手机/手表远程控制的端点。 + +## 安装 + +像安装其他 DSH 插件一样,对 Harness 说"安装这个插件": + + dsh plugin --profile desktop add dsh-watch + +或本地开发安装: + + dsh plugin --profile desktop add link:D:/project/DeepSeek_Harness_Watch/dsh-watch + +然后在 profile 的 cordis.patch.yml 配置: + + - id: dsh-watch + config: + mode: relay # relay | direct + serverUrl: "ws://你的服务器:8787" + userId: "dalong" + token: "与服务器/客户端一致的 token" + reconnectMs: 5000 + timeoutMs: 30000 + +## 两种模式 + +### 1. 直连模式(direct) + +插件在本机起一个 WebSocket 服务,手机端直接填主机/端口/token 连进来(可配合内网穿透/NAT 转发)。 + + - id: dsh-watch + config: + mode: direct + host: "0.0.0.0" + port: 18321 + token: "你自己定的 token" + +手机端填:ws://主机IP:18321 和同一个 token。 + +### 2. 服务器中转模式(relay) + +插件主动外连 DSHWatch_Server(预设或手动填 serverUrl),手机端连同一个服务器,用同样的 userId/token。适合公网、不需要给电脑开端口。 + + - id: dsh-watch + config: + mode: relay + serverUrl: "wss://your-server:8787" + userId: "someone" + token: "shared-secret" + +## 暴露方法 + +透传 DSH apiProxy: + +- harness.status / harness.prompt +- session.list create history prompt cancel rename fork models selectModel attachment +- goal.* subagent.* workspace.* host.* +- skill.list agentPreset.* llm.* settings.* credentials.* + +事件推送:session/event、session/projection、status。 + +## 依赖 + +- node >= 18 +- ws(装在 DSH 宿主里即可) + +## 仓库 + +- 服务端:DSHWatch_Server +- 手表端:DSHWatch +- 手机端:DSHWatchPhone diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..f510788 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,6 @@ +# dsh-watch bundle patch +# Insert the plugin row into the DSH profile tree. +# Configuration is set through the profile patch / plugin settings UI. +- insert: + - id: dsh-watch + name: dsh-watch diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..1455779 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,287 @@ +import { createServer } from 'node:http'; +import { WebSocket, WebSocketServer } from 'ws'; +import z from '@deepseek-ai/schemastery'; + +const name = 'dsh-watch'; + +const Config = z.object({ + mode: z.union([z.const('direct'), z.const('relay')]).default('relay'), + // Relay mode + serverUrl: z.string().default(''), + userId: z.string().default(''), + token: z.string().default(''), + reconnectMs: z.number().min(500).default(5000), + timeoutMs: z.number().min(1000).default(30000), + // Direct mode + host: z.union([z.const('0.0.0.0'), z.const('127.0.0.1')]).default('0.0.0.0'), + port: z.natural().max(65535).default(18321) +}); + +function apply(ctx, config) { + let api; + ctx.inject(['apiProxy'], (c) => { api = c.apiProxy; }); + + const directClients = new Set(); + let relayWs = null; + let httpServer = null; + let wss = null; + let closed = false; + let reconnectTimer = null; + let statusTimer = null; + + function log(msg) { ctx.logger.info('[dsh-watch] ' + msg); } + function warn(msg) { ctx.logger.warn('[dsh-watch] ' + msg); } + + function safeSend(ws, obj) { + if (ws && ws.readyState === WebSocket.OPEN) { + try { ws.send(JSON.stringify(obj)); } catch { /* ignore */ } + } + } + + function pushEvent(event) { + const frame = { type: 'event', event }; + if (config.mode === 'direct') { + for (const client of directClients) safeSend(client, frame); + } else { + safeSend(relayWs, frame); + } + } + + function pushStatus(status) { + const frame = { type: 'status', status }; + if (config.mode === 'direct') { + for (const client of directClients) safeSend(client, frame); + } else { + safeSend(relayWs, frame); + } + } + + function sendStatus() { + computeStatus().then((status) => { + pushStatus(status); + }).catch((err) => warn('status error ' + String(err && err.message || err))); + } + + async function computeStatus() { + let host = {}; + let items = []; + try { + if (api && api.host) host = await api.host.describe({}); + } catch (err) { warn('host.describe ' + String(err && err.message || err)); } + try { + if (api && api.sessions) { + const list = await api.sessions.list({}); + items = list && list.items ? list.items : []; + } + } catch (err) { warn('session.list ' + String(err && err.message || err)); } + let active = null; + if (items.length) { + active = items.find((s) => s.running) || items.find((s) => !s.blank) || items.slice().sort((a, b) => b.updatedAt - a.updatedAt)[0] || null; + } + return { + online: true, + ts: Date.now(), + version: host.version, + cwd: host.cwd, + provider: host.provider, + model: host.model, + attachedSessions: host.attachedSessions, + activeSessionId: active ? active.sessionId : null, + running: active ? Boolean(active.running) : false, + sessions: items.slice(0, 50) + }; + } + + async function invoke(method, params) { + params = params || {}; + if (method === 'harness.status') return computeStatus(); + if (method === 'harness.prompt') return harnessPrompt(params); + const dot = method.indexOf('.'); + if (dot <= 0) throw new Error('unknown method ' + method); + const domain = method.slice(0, dot); + const fnName = method.slice(dot + 1); + const domainObj = api && api[domain]; + if (!domainObj || typeof domainObj[fnName] !== 'function') { + throw new Error('no handler for ' + method); + } + return domainObj[fnName](params); + } + + async function harnessPrompt(params) { + let sessionId = params.sessionId; + if (!sessionId) { + const status = await computeStatus(); + sessionId = status.activeSessionId; + } + if (!sessionId) { + const created = await api.sessions.create({}); + sessionId = created.sessionId; + } + let content = params.content; + if (typeof content === 'string') content = [{ type: 'text', text: content }]; + if (!Array.isArray(content)) throw new Error('content must be a string or an array of content blocks'); + const mode = params.mode === 'queue' ? 'queue' : 'steer'; + return api.sessions.prompt({ + sessionId, + mode, + content, + clientTimeZone: params.clientTimeZone + }); + } + + function handleRequest(msg, respond) { + invoke(msg.method || '', msg.params || {}).then((result) => { + respond({ type: 'response', requestId: msg.requestId, ok: true, result }); + }).catch((err) => { + respond({ + type: 'response', + requestId: msg.requestId, + ok: false, + error: { code: 'invoke-error', message: String(err && err.message || err) } + }); + }); + } + + function isAuthed(msg) { + return config.token && msg.token === config.token; + } + + // ---------- Direct mode ---------- + function handleDirectConnection(ws) { + ws.isAlive = true; + ws.on('pong', () => { ws.isAlive = true; }); + ws.on('message', (raw) => { + let msg; + try { msg = JSON.parse(String(raw)); } catch { return; } + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'hello') { + if (msg.role !== 'client' || !isAuthed(msg)) { + safeSend(ws, { type: 'auth-error', message: 'invalid token or role' }); + ws.close(); + return; + } + directClients.add(ws); + computeStatus().then((status) => { + safeSend(ws, { type: 'hello', role: 'client', online: true, harness: true, status }); + }).catch(() => { + safeSend(ws, { type: 'hello', role: 'client', online: true, harness: true, status: null }); + }); + return; + } + if (!directClients.has(ws)) { ws.close(); return; } + if (msg.type === 'request') { + handleRequest(msg, (resp) => safeSend(ws, resp)); + } else if (msg.type === 'ping') { + safeSend(ws, { type: 'pong' }); + } + }); + ws.on('close', () => directClients.delete(ws)); + ws.on('error', () => {}); + } + + function startDirect() { + httpServer = createServer((req, res) => { + const url = new URL(req.url || '/', 'http://localhost'); + if (url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mode: 'direct', clients: directClients.size, port: config.port })); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('dsh-watch direct mode. Connect clients to /ws\n'); + }); + wss = new WebSocketServer({ server: httpServer }); + wss.on('connection', handleDirectConnection); + httpServer.listen(config.port, config.host, () => { + log('direct mode listening on ws://' + config.host + ':' + config.port + '/ws'); + sendStatus(); + }); + } + + // ---------- Relay mode ---------- + function connectRelay() { + if (closed || (relayWs && relayWs.readyState === WebSocket.OPEN)) return; + const w = new WebSocket(config.serverUrl); + relayWs = w; + w.on('open', () => { + safeSend(w, { type: 'hello', role: 'dsh', userId: config.userId, token: config.token }); + sendStatus(); + log('relay connected to ' + config.serverUrl + ' as ' + config.userId); + }); + w.on('message', (raw) => { + let msg; + try { msg = JSON.parse(String(raw)); } catch { return; } + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'request') { + handleRequest(msg, (resp) => safeSend(w, resp)); + } else if (msg.type === 'ping') { + safeSend(w, { type: 'pong' }); + } else if (msg.type === 'hello') { + log('registered'); + } + }); + w.on('close', () => { + if (relayWs === w) relayWs = null; + scheduleReconnect(); + }); + w.on('error', (err) => warn('relay socket error ' + String(err && err.message || err))); + } + + function scheduleReconnect() { + if (closed || reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectRelay(); + }, config.reconnectMs); + } + + // ---------- Shared event hooks ---------- + ctx.on('session/event', (session, event) => { + pushEvent({ type: 'session/event', sessionId: session.id, event }); + scheduleStatus(); + }); + ctx.inject(['sessionProjections'], (c) => { + c.sessionProjections.onChanged((session, key, value, seq) => { + pushEvent({ type: 'session/projection', sessionId: session.id, key, value, seq }); + }); + }); + + let statusDebounce = null; + function scheduleStatus() { + if (statusDebounce) return; + statusDebounce = setTimeout(() => { + statusDebounce = null; + sendStatus(); + }, 500); + } + + statusTimer = setInterval(() => sendStatus(), 15000); + + ctx.effect(() => { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (statusTimer) clearInterval(statusTimer); + if (statusDebounce) clearTimeout(statusDebounce); + for (const ws of directClients) { try { ws.close(); } catch {} } + directClients.clear(); + if (relayWs) { try { relayWs.close(); } catch {} relayWs = null; } + if (wss) { try { wss.close(); } catch {} wss = null; } + if (httpServer) { try { httpServer.close(); } catch {} httpServer = null; } + }); + + if (config.mode === 'direct') { + if (!config.port || !config.token) { + warn('direct mode requires port and token; bridge stays idle.'); + return; + } + startDirect(); + } else { + if (!config.serverUrl || !config.userId || !config.token) { + warn('relay mode requires serverUrl/userId/token; bridge stays idle.'); + return; + } + connectRelay(); + } +} + +export { Config, apply, name }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8b3d4a --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "dsh-watch", + "version": "0.1.0", + "description": "DSHWatch Harness plugin: remote-control a DeepSeek Harness instance from the DSHWatch phone/watch clients. Supports direct (LAN/NAT) and relay-server modes.", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "cordis.patch.yml", + "README.md", + "LICENSE" + ], + "keywords": [ + "dsh", + "dsh-plugin", + "deepseek-harness", + "watch", + "smartband", + "vela" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "ws": "^8.18.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/schemastery": "^3.18.1" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +}