Initial DSHWatch dsh-watch scaffold

This commit is contained in:
2026-09-01 14:25:35 +08:00
commit b2fb257aaa
6 changed files with 427 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+21
View File
@@ -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.
+72
View File
@@ -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
+6
View File
@@ -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
+287
View File
@@ -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 };
+38
View File
@@ -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"
}