Initial DSHWatch dsh-watch scaffold
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 };
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user