dsh-remote-wecom 0.1.1 → 0.1.3
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.
- package/README.md +25 -12
- package/bin/dsh-remote.mjs +14 -44
- package/dist/client.js +12 -2
- package/docs/DESIGN.md +5 -2
- package/lib/core/launchd.mjs +98 -0
- package/lib/core/remote.mjs +9 -0
- package/lib/core/watchdog.mjs +7 -1
- package/lib/core/wecom.mjs +8 -3
- package/lib/host/settings-rpc.mjs +12 -4
- package/lib/index.mjs +8 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# dsh-remote-wecom
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/dsh-remote-wecom)
|
|
4
|
+
[](LICENSE)
|
|
4
5
|
|
|
5
|
-
> Control DeepSeek Harness Desktop sessions from WeCom (企业微信) on your phone. Docs and commands are in Chinese.
|
|
6
|
+
> Control DeepSeek Harness Desktop sessions from WeCom (企业微信) on your phone: send prompts, get results and files back, approve tool calls, switch sessions and models. Docs and commands are in Chinese.
|
|
6
7
|
|
|
7
8
|
在手机企业微信里遥控 DeepSeek Harness Desktop:发消息、收结果、切会话和模型、审批与回答提问、取回产物。插件跑在 Desktop 里,自己持有企微智能机器人的长连接。
|
|
8
9
|
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
- 任务跑完自动推结果,生成的文件一并发到手机
|
|
13
|
+
- 审批和提问同时发到手机和 Desktop,哪边先答用哪边
|
|
14
|
+
- 运行中可以插话、停止,也能设长期目标让它自己续跑
|
|
15
|
+
- 引用某条推送回复,消息就发回那个会话,多个任务并行不串
|
|
16
|
+
- Desktop 没开也能发:消息先暂存,后台拉起 Desktop 后自动处理(macOS)
|
|
17
|
+
|
|
9
18
|
## 要求
|
|
10
19
|
|
|
11
20
|
- DeepSeek Harness Desktop(DSH `>=0.2.0-rc.2`)
|
|
@@ -21,7 +30,7 @@ Desktop 运行时会锁住 `desktop` profile,`dsh plugin add` 用不了,需
|
|
|
21
30
|
|
|
22
31
|
```json
|
|
23
32
|
{
|
|
24
|
-
"dependencies": { "dsh-remote-wecom": "^0.1.
|
|
33
|
+
"dependencies": { "dsh-remote-wecom": "^0.1.2" },
|
|
25
34
|
"dsh": { "profile": { "bundles": ["…", "dsh-remote-wecom"] } }
|
|
26
35
|
}
|
|
27
36
|
```
|
|
@@ -36,15 +45,26 @@ Desktop 运行时会锁住 `desktop` profile,`dsh plugin add` 用不了,需
|
|
|
36
45
|
|
|
37
46
|
升级时改版本号,再重复第 3 步。
|
|
38
47
|
|
|
48
|
+
也可以把下面这段发给 Agent(Claude Code、Cursor、pi 等)代装:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
按 https://github.com/lukeinx/dsh-remote-wecom 的 README 给我的 DeepSeek Harness Desktop 安装 dsh-remote-wecom,
|
|
52
|
+
遵守「让 Agent 代装」一节的规则;装好后提醒我到「设置 → 企微远程」扫码绑定。
|
|
53
|
+
```
|
|
54
|
+
|
|
39
55
|
## 绑定
|
|
40
56
|
|
|
41
57
|
打开 Desktop「设置 → 企微远程」:
|
|
42
58
|
|
|
59
|
+
<img src="docs/images/settings.png" alt="Desktop 设置页「企微远程」" width="560">
|
|
60
|
+
|
|
43
61
|
1. 用企业微信扫码,自动创建机器人并连接。
|
|
44
62
|
2. 10 分钟内给机器人发一条单聊消息,发送人即成为主人;过了窗口就发页面上的 6 位绑定码。之后只接主人的单聊。
|
|
45
63
|
|
|
46
64
|
同一页可以换机器人、解绑、选新会话位置、设置手机审批超时。扫不了码时可以手动填 BotID / Secret。
|
|
47
65
|
|
|
66
|
+
打开「桌面端退出后仍可拉起」(macOS):Desktop 没开时,手机发来的消息先暂存,并在后台启动 Desktop,启动后自动处理。移除插件前先关掉它。
|
|
67
|
+
|
|
48
68
|
## 手机上用
|
|
49
69
|
|
|
50
70
|
选中会话后直接发消息;引用某条推送回复,消息会发给该推送所属的会话。图片和文件可以先发,5 分钟内再补说明。任务完成后自动推送结果;审批和提问同时出现在手机和 Desktop,谁先答用谁的。
|
|
@@ -67,28 +87,21 @@ Desktop 运行时会锁住 `desktop` profile,`dsh plugin add` 用不了,需
|
|
|
67
87
|
|
|
68
88
|
中文命令和参数之间要有空格:`模型 high` 是命令,`模型high` 会当普通消息发出。
|
|
69
89
|
|
|
70
|
-
##
|
|
90
|
+
## 命令行
|
|
71
91
|
|
|
72
|
-
|
|
92
|
+
设置页之外的备用入口:
|
|
73
93
|
|
|
74
94
|
```sh
|
|
75
95
|
alias dsh-remote-wecom='node ~/.dsh/profiles/desktop/node_modules/dsh-remote-wecom/bin/dsh-remote.mjs'
|
|
76
|
-
dsh-remote-wecom watchdog install # 升级插件或换 Node 后要重新执行
|
|
77
|
-
dsh-remote-wecom watchdog status
|
|
78
|
-
dsh-remote-wecom watchdog uninstall
|
|
79
96
|
```
|
|
80
97
|
|
|
81
|
-
## 命令行
|
|
82
|
-
|
|
83
|
-
设置页之外的备用入口(`dsh-remote-wecom` 即上面的 alias):
|
|
84
|
-
|
|
85
98
|
| 命令 | 作用 |
|
|
86
99
|
|---|---|
|
|
87
100
|
| `setup [--bot-id ID --secret S]` | 写入机器人凭证并生成绑定码 |
|
|
88
101
|
| `bind` | 解除当前主人,生成新绑定码 |
|
|
89
102
|
| `status` | 查看配置、绑定状态和最近日志 |
|
|
90
103
|
| `alias [名称 目录 \| rm 名称]` | 管理目录别名,供「创建会话」「工作区」使用 |
|
|
91
|
-
| `watchdog install\|uninstall\|status` |
|
|
104
|
+
| `watchdog install\|uninstall\|status` | 「桌面端退出后仍可拉起」的开关与状态 |
|
|
92
105
|
|
|
93
106
|
数据在 `~/.dsh/integrations/dsh-remote-wecom/`,`config.json` 权限为 600。可选配置和实现细节见 [docs/DESIGN.md](docs/DESIGN.md)。
|
|
94
107
|
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import {parseArgs} from 'node:util';
|
|
|
7
7
|
import {BIND_FILE, BIND_TTL_MS, CONFIG_FILE, VERSION, applyCredentials, defaultDataDir, hasCredentials, loadConfig, readUserConfig, resetOwner} from '../lib/core/config.mjs';
|
|
8
8
|
import {checkName, expandDir, loadAliases, missingDirs, saveAliases, tildeDir} from '../lib/core/aliases.mjs';
|
|
9
9
|
import {execFileSync} from 'node:child_process';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import {DESKTOP_BUNDLE_ID, createWatchdog, heartbeatAge, heartbeatFresh, spoolCount, watchdogState} from '../lib/core/watchdog.mjs';
|
|
11
|
+
import {createLaunchd, launchdSupported} from '../lib/core/launchd.mjs';
|
|
12
12
|
|
|
13
13
|
const USAGE = `dsh-remote-wecom ${VERSION}
|
|
14
14
|
|
|
@@ -103,13 +103,9 @@ function alias(dir, args) {
|
|
|
103
103
|
console.log(rows.length ? rows.map(([n, d]) => `${n} → ${d}${missing.has(n) ? '(目录不存在)' : ''}`).join('\n') : '还没有目录别名。');
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
const WATCHDOG_LABEL = 'com.dsh-remote-wecom.watchdog';
|
|
107
106
|
const WATCHDOG_TICK_MS = 2000;
|
|
108
107
|
const WATCHDOG_LOG = 'watchdog.log';
|
|
109
108
|
const WATCHDOG_LOG_MAX = 1024 * 1024;
|
|
110
|
-
const plistFile = () => path.join(process.env.HOME, 'Library', 'LaunchAgents', `${WATCHDOG_LABEL}.plist`);
|
|
111
|
-
const domain = () => `gui/${process.getuid()}`;
|
|
112
|
-
const xml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
113
109
|
|
|
114
110
|
function watchdogRun(dir) {
|
|
115
111
|
const file = path.join(dir, WATCHDOG_LOG);
|
|
@@ -129,55 +125,29 @@ function watchdogRun(dir) {
|
|
|
129
125
|
process.on('SIGINT', quit);
|
|
130
126
|
}
|
|
131
127
|
|
|
128
|
+
// 看门狗进程用的也是 Desktop 的可执行文件,按进程名判断会误报;只认登记到 LaunchServices 的 App。
|
|
132
129
|
function desktopRunning() {
|
|
133
|
-
try { execFileSync('
|
|
130
|
+
try { return execFileSync('lsappinfo', ['find', `bundleid=${DESKTOP_BUNDLE_ID}`], {encoding: 'utf8'}).trim() !== ''; } catch { return false; }
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
function watchdogInstall(dir, force) {
|
|
137
134
|
// 旧版插件不写心跳:此时装上看门狗会把正在运行的插件踢下线。
|
|
138
135
|
if (!force && desktopRunning() && !heartbeatFresh(dir))
|
|
139
136
|
throw new Error('DSH Desktop 正在运行,但插件没有心跳(可能是旧版本插件或插件未启用)。请先重启 Desktop 加载新版插件再安装;确认无误可加 --force。');
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
<plist version="1.0">
|
|
145
|
-
<dict>
|
|
146
|
-
<key>Label</key><string>${WATCHDOG_LABEL}</string>
|
|
147
|
-
<key>ProgramArguments</key>
|
|
148
|
-
<array>
|
|
149
|
-
${args.map(a => ` <string>${xml(a)}</string>`).join('\n')}
|
|
150
|
-
</array>
|
|
151
|
-
<key>EnvironmentVariables</key>
|
|
152
|
-
<dict>
|
|
153
|
-
${Object.entries(env).map(([k, v]) => ` <key>${k}</key><string>${xml(v)}</string>`).join('\n')}
|
|
154
|
-
</dict>
|
|
155
|
-
<key>RunAtLoad</key><true/>
|
|
156
|
-
<key>KeepAlive</key><true/>
|
|
157
|
-
<key>ProcessType</key><string>Background</string>
|
|
158
|
-
<key>StandardErrorPath</key><string>${xml(path.join(dir, 'watchdog.err'))}</string>
|
|
159
|
-
</dict>
|
|
160
|
-
</plist>
|
|
161
|
-
`;
|
|
162
|
-
fs.mkdirSync(path.dirname(plistFile()), {recursive: true});
|
|
163
|
-
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
164
|
-
try { execFileSync('launchctl', ['bootout', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); } catch {}
|
|
165
|
-
fs.writeFileSync(plistFile(), plist);
|
|
166
|
-
execFileSync('launchctl', ['bootstrap', domain(), plistFile()]);
|
|
167
|
-
console.log(`看门狗已安装并启动:${plistFile()}`);
|
|
168
|
-
console.log(`Node:${process.execPath}(升级或切换 Node 后请重新执行 watchdog install)`);
|
|
137
|
+
const launchd = createLaunchd({dir});
|
|
138
|
+
launchd.install();
|
|
139
|
+
console.log(`看门狗已安装并启动:${launchd.file}`);
|
|
140
|
+
console.log('也可以在 Desktop「设置 → 企微远程」里开关。');
|
|
169
141
|
}
|
|
170
142
|
|
|
171
|
-
function watchdogUninstall() {
|
|
172
|
-
|
|
173
|
-
fs.rmSync(plistFile(), {force: true});
|
|
143
|
+
function watchdogUninstall(dir) {
|
|
144
|
+
createLaunchd({dir}).uninstall();
|
|
174
145
|
console.log('看门狗已卸载。');
|
|
175
146
|
}
|
|
176
147
|
|
|
177
148
|
function watchdogStatus(dir) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
console.log(`看门狗:${loaded ? '已安装并运行' : fs.existsSync(plistFile()) ? '已安装但未加载' : '未安装'}`);
|
|
149
|
+
const launchd = createLaunchd({dir});
|
|
150
|
+
console.log(`看门狗:${launchd.loaded() ? '已安装并运行' : fs.existsSync(launchd.file) ? '已安装但未加载' : '未安装'}`);
|
|
181
151
|
const age = heartbeatAge(dir);
|
|
182
152
|
console.log(`插件心跳:${Number.isFinite(age) ? `${Math.round(age / 1000)} 秒前` : '无'}`);
|
|
183
153
|
const wd = watchdogState(dir);
|
|
@@ -190,10 +160,10 @@ function watchdogStatus(dir) {
|
|
|
190
160
|
}
|
|
191
161
|
|
|
192
162
|
function watchdog(dir, [sub = 'status'], force) {
|
|
193
|
-
if (
|
|
163
|
+
if (!launchdSupported() && sub !== 'run') throw new Error('看门狗仅支持 macOS(launchd)');
|
|
194
164
|
if (sub === 'run') watchdogRun(dir);
|
|
195
165
|
else if (sub === 'install') watchdogInstall(dir, force);
|
|
196
|
-
else if (sub === 'uninstall') watchdogUninstall();
|
|
166
|
+
else if (sub === 'uninstall') watchdogUninstall(dir);
|
|
197
167
|
else if (sub === 'status') watchdogStatus(dir);
|
|
198
168
|
else throw new Error('用法:dsh-remote-wecom watchdog install|uninstall|status');
|
|
199
169
|
}
|
package/dist/client.js
CHANGED
|
@@ -174,7 +174,8 @@ var zh = {
|
|
|
174
174
|
"row.owner": "\u7ED1\u5B9A\u8D26\u53F7",
|
|
175
175
|
"row.place": "\u65B0\u4F1A\u8BDD\u4F4D\u7F6E",
|
|
176
176
|
"row.confirm": "\u624B\u673A\u5BA1\u6279",
|
|
177
|
-
"row.openDesktop": "\u65B0\u5EFA\u4F1A\u8BDD\u65F6\u6253\u5F00
|
|
177
|
+
"row.openDesktop": "\u65B0\u5EFA\u4F1A\u8BDD\u65F6\u6253\u5F00\u684C\u9762\u7AEF",
|
|
178
|
+
"row.watchdog": "\u684C\u9762\u7AEF\u9000\u51FA\u540E\u4ECD\u53EF\u62C9\u8D77",
|
|
178
179
|
"bot.rescan": "\u66F4\u6362",
|
|
179
180
|
"bot.rescanConfirm": "\u5C06\u66FF\u6362\u5F53\u524D\u673A\u5668\u4EBA",
|
|
180
181
|
"owner.unbind": "\u89E3\u7ED1",
|
|
@@ -220,6 +221,7 @@ var en = {
|
|
|
220
221
|
"row.place": "New sessions in",
|
|
221
222
|
"row.confirm": "Phone approvals",
|
|
222
223
|
"row.openDesktop": "Open Desktop for new sessions",
|
|
224
|
+
"row.watchdog": "Stay reachable after Desktop quits",
|
|
223
225
|
"bot.rescan": "Change",
|
|
224
226
|
"bot.rescanConfirm": "Replaces the current bot",
|
|
225
227
|
"owner.unbind": "Unlink",
|
|
@@ -432,7 +434,15 @@ function Configured({ s, now, act, busy, errors, setError, t }) {
|
|
|
432
434
|
disabled: busy.open,
|
|
433
435
|
onChange: (v) => act("open", "setPrefs", { openDesktop: v })
|
|
434
436
|
}
|
|
435
|
-
))
|
|
437
|
+
)), s.watchdog ? /* @__PURE__ */ h(Row, { label: t("row.watchdog"), error: errors.watchdog }, /* @__PURE__ */ h(
|
|
438
|
+
Switch,
|
|
439
|
+
{
|
|
440
|
+
checked: s.watchdog.enabled,
|
|
441
|
+
label: t("row.watchdog"),
|
|
442
|
+
disabled: busy.watchdog,
|
|
443
|
+
onChange: (v) => act("watchdog", "setWatchdog", { enabled: v })
|
|
444
|
+
}
|
|
445
|
+
)) : null), /* @__PURE__ */ h("div", { className: "drw-foot" }, /* @__PURE__ */ h(Button, { variant: "outline", disabled: busy.restart, onClick: () => act("restart", "restart") }, busy.restart ? t("restarting") : t("restart")), errors.restart ? /* @__PURE__ */ h("span", { className: "drw-error", role: "alert" }, errors.restart) : null));
|
|
436
446
|
}
|
|
437
447
|
function Settings({ call, t }) {
|
|
438
448
|
const [s, setS] = useState(null);
|
package/docs/DESIGN.md
CHANGED
|
@@ -55,7 +55,7 @@ DeepSeek Harness.app ── host 进程
|
|
|
55
55
|
|
|
56
56
|
事件(`ctx.on(name, fn, { global: true })`):
|
|
57
57
|
|
|
58
|
-
- `session/event (session, event)`:`turn/start{turn}`、`user/message{source.kind, source.rpcId}`(`source.kind` 除 `user` 外还有注入类 `runtime-context` / `skill-catalog` / `time-context`,以及宿主唤起的 `schedule` / `goal`)、`assistant/message{message.content[]}`、`tool/call{name, arguments}`、`tool/result{message.isError, meta.path}`、`turn/end{reason.kind: completed|aborted|error|max-tokens, reason.error.message}`、`session/title{title, source}`、`model/selection{provider, model, reasoningEffort}`(只在切模型时发)、`request/header{header.config{provider, model, reasoningEffort}}`(每次 LLM 请求,新会话首轮靠它拿模型,此时 `modelSelection`
|
|
58
|
+
- `session/event (session, event)`:`turn/start{turn}`、`user/message{source.kind, source.rpcId}`(`source.kind` 除 `user` 外还有注入类 `runtime-context` / `skill-catalog` / `time-context`,以及宿主唤起的 `schedule` / `goal`)、`assistant/message{message.content[]}`、`tool/call{name, arguments}`、`tool/result{message.isError, meta.path}`、`turn/end{reason.kind: completed|aborted|error|max-tokens, reason.error.message}`、`session/title{title, source}`、`model/selection{provider, model, reasoningEffort}`(只在切模型时发)、`request/header{header.config{provider, model, reasoningEffort}}`(每次 LLM 请求,新会话首轮靠它拿模型,此时 `modelSelection` 投影还是空的;还没发首条时按 `modelCatalog.default` 显示)。`user/message` 的 data 就是消息本身(`data.source`、`data.content`),混有 runtime-context 等系统注入消息,只认 `source.kind === 'user'`。一轮的顺序:`agent/inbox/spliced` → `api-session/status [sid, true]` → `turn/start` → `user/message` → … → `turn/end`。标题先来一条 `source.kind:'fallback'`(首条消息截断),模型起的标题随后再来一条。
|
|
59
59
|
- `api-session/added (summary)`、`api-session/removed (sessionId)`、`api-session/status (sessionId, running)`。
|
|
60
60
|
- `approval/request (req, next)`,注册时加 `prepend: true`:`req.{toolName, callId, reason, displayReason.zh, agent, signal}`,返回 `'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'`。会话 id 取 `req.agent.session.id ?? req.agent.id`。
|
|
61
61
|
- `user-questions/request (req, next)`:`req.questions[{id, question, header, options[{label, description}], multiSelect}]`,返回 `{answers:[{id, selected:[label], custom?}]}`。
|
|
@@ -144,7 +144,10 @@ DeepSeek Harness.app ── host 进程
|
|
|
144
144
|
|
|
145
145
|
## Desktop 离线看门狗
|
|
146
146
|
|
|
147
|
-
插件随 Desktop 启停,macOS 上 Cmd+Q
|
|
147
|
+
插件随 Desktop 启停,macOS 上 Cmd+Q 后企微无人接。设置页「桌面端退出后仍可拉起」(`setWatchdog`)或 `dsh-remote-wecom watchdog install` 装一个 launchd LaunchAgent(`KeepAlive`)跑 `watchdog run`,每 2 秒 tick 一次(`lib/core/watchdog.mjs`)。
|
|
148
|
+
|
|
149
|
+
- 登记在 `lib/core/launchd.mjs`。可执行文件优先用 Desktop 自带的 Electron(`ELECTRON_RUN_AS_NODE=1`,与 dsh 命令行相同),不依赖用户的 Node。这样的进程不向 LaunchServices 登记,`open -b` 照常拉起 Desktop;但进程名同为 `DeepSeek Harness`,判断 Desktop 是否在运行要用 `lsappinfo find bundleid=...`,不能用 `pgrep`。
|
|
150
|
+
- 插件启动时 `sync()`:登记指向本数据目录且内容与当前插件路径不一致(pnpm 升级后路径会变)或未加载时重装;指向其他数据目录的登记(如测试)不动。插件配置 `watchdog: false` 隐藏开关,`watchdogDryRun: true`(预览、e2e)把 plist 写进数据目录且不调 launchctl。
|
|
148
151
|
|
|
149
152
|
- 交接靠心跳:插件 `apply` 时先写 `alive.json`(先于连企微),此后每 10 秒刷新,插件停止且遥控实例收尾后删除(只删自己 pid 写的)。心跳 30 秒内新鲜时看门狗不连、已连则断开;未配置或未绑定主人也不连。配置重载只重建遥控实例,心跳不断。
|
|
150
153
|
- 企微服务端在新连接建立时向旧连接发 `disconnected_event` 并断开,SDK 不重连被踢的一方。插件上线时挤掉看门狗是预期路径;看门狗被其他连接踢掉时退避 5 分钟,避免与重复部署互踢。反向竞争(看门狗刚好在插件连上后才连,把插件踢掉):传输层记 `kickedAt`,看门狗下一个 tick 看到心跳会断开并把 `watchdog.json` 写成 `connected:false`,插件心跳 tick 发现「被踢之后看门狗已让出」就重建遥控实例。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 看门狗的 launchd 登记:设置页开关与命令行共用。
|
|
2
|
+
// 优先用 Desktop 自带的 Electron 以 Node 模式运行(dsh 命令行也是这样跑的),不依赖用户自己装的 Node。
|
|
3
|
+
// 这样的进程不向 LaunchServices 登记,`open -b` 仍会正常拉起 Desktop。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {execFileSync} from 'node:child_process';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const WATCHDOG_LABEL = 'com.dsh-remote-wecom.watchdog';
|
|
11
|
+
export const DESKTOP_EXECUTABLE = '/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness';
|
|
12
|
+
const BIN = fileURLToPath(new URL('../../bin/dsh-remote.mjs', import.meta.url));
|
|
13
|
+
const BOOTSTRAP_TRIES = 5;
|
|
14
|
+
|
|
15
|
+
export const launchdSupported = (platform = process.platform) => platform === 'darwin';
|
|
16
|
+
|
|
17
|
+
const xml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
18
|
+
const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
19
|
+
|
|
20
|
+
function runtime() {
|
|
21
|
+
if (process.versions.electron) return {execPath: process.execPath, electron: true};
|
|
22
|
+
if (fs.existsSync(DESKTOP_EXECUTABLE)) return {execPath: DESKTOP_EXECUTABLE, electron: true};
|
|
23
|
+
return {execPath: process.execPath, electron: false};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderPlist({dir, execPath, electron, bin = BIN, dshHome = process.env.DSH_HOME}) {
|
|
27
|
+
const args = [execPath, bin, 'watchdog', 'run', '--dir', dir];
|
|
28
|
+
const env = {
|
|
29
|
+
PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
30
|
+
...(electron ? {ELECTRON_RUN_AS_NODE: '1'} : {}),
|
|
31
|
+
...(dshHome ? {DSH_HOME: dshHome} : {}),
|
|
32
|
+
};
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key><string>${WATCHDOG_LABEL}</string>
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>
|
|
40
|
+
${args.map(a => ` <string>${xml(a)}</string>`).join('\n')}
|
|
41
|
+
</array>
|
|
42
|
+
<key>EnvironmentVariables</key>
|
|
43
|
+
<dict>
|
|
44
|
+
${Object.entries(env).map(([k, v]) => ` <key>${k}</key><string>${xml(v)}</string>`).join('\n')}
|
|
45
|
+
</dict>
|
|
46
|
+
<key>RunAtLoad</key><true/>
|
|
47
|
+
<key>KeepAlive</key><true/>
|
|
48
|
+
<key>ProcessType</key><string>Background</string>
|
|
49
|
+
<key>StandardErrorPath</key><string>${xml(path.join(dir, 'watchdog.err'))}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 一台机器只登记一个看门狗:只认指向 dir 的那份,别的数据目录(如测试)不会改写它。
|
|
57
|
+
* @param {{dir: string, home?: string, uid?: number, run?: (cmd: string, args: string[]) => void, execPath?: string, electron?: boolean, bin?: string}} opts
|
|
58
|
+
*/
|
|
59
|
+
export function createLaunchd({dir, home = os.homedir(), uid = process.getuid?.(), run = null, ...spec}) {
|
|
60
|
+
const exec = run || ((cmd, args) => execFileSync(cmd, args, {stdio: 'ignore'}));
|
|
61
|
+
const file = path.join(home, 'Library', 'LaunchAgents', `${WATCHDOG_LABEL}.plist`);
|
|
62
|
+
const target = `gui/${uid}/${WATCHDOG_LABEL}`;
|
|
63
|
+
const render = () => renderPlist({dir, ...runtime(), ...spec});
|
|
64
|
+
const read = () => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
|
|
65
|
+
const bootout = () => { try { exec('launchctl', ['bootout', target]); } catch {} };
|
|
66
|
+
const loaded = () => { try { exec('launchctl', ['print', target]); return true; } catch { return false; } };
|
|
67
|
+
|
|
68
|
+
function install() {
|
|
69
|
+
fs.mkdirSync(path.dirname(file), {recursive: true});
|
|
70
|
+
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
71
|
+
bootout();
|
|
72
|
+
fs.writeFileSync(file, render());
|
|
73
|
+
// bootout 是异步卸载,紧接着 bootstrap 可能还在卸载中。
|
|
74
|
+
for (let i = 1; ; i++) {
|
|
75
|
+
try { exec('launchctl', ['bootstrap', `gui/${uid}`, file]); return; }
|
|
76
|
+
catch (e) { if (i >= BOOTSTRAP_TRIES) throw e; sleep(200 * i); }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
file,
|
|
81
|
+
loaded,
|
|
82
|
+
install,
|
|
83
|
+
/** 当前登记的是不是这个数据目录的看门狗。 */
|
|
84
|
+
enabled: () => read().includes(`<string>${xml(dir)}</string>`),
|
|
85
|
+
uninstall() {
|
|
86
|
+
bootout();
|
|
87
|
+
fs.rmSync(file, {force: true});
|
|
88
|
+
},
|
|
89
|
+
/** 已开启时把登记对齐到当前插件(升级后安装路径会变);返回是否改写。 */
|
|
90
|
+
sync() {
|
|
91
|
+
const current = read();
|
|
92
|
+
if (!current.includes(`<string>${xml(dir)}</string>`)) return false;
|
|
93
|
+
if (current === render() && loaded()) return false;
|
|
94
|
+
install();
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/lib/core/remote.mjs
CHANGED
|
@@ -665,6 +665,15 @@ export function createRemote({ host, dir, config, log: echo = () => { }, createT
|
|
|
665
665
|
catch (e) {
|
|
666
666
|
log(`dsh[${this.key}] projections 失败(沿用上次信息):${e?.message || e}`);
|
|
667
667
|
}
|
|
668
|
+
// 新会话跑完首轮前 modelSelection 投影为空,按部署默认模型显示。
|
|
669
|
+
if (!this.model) {
|
|
670
|
+
const d = await host.modelCatalog().then((c) => c?.default, () => null);
|
|
671
|
+
if (d?.model) {
|
|
672
|
+
this.model = String(d.model);
|
|
673
|
+
this.modelProvider = String(d.provider || "");
|
|
674
|
+
this.thinkingLevel = d.reasoningEffort ? String(d.reasoningEffort) : "";
|
|
675
|
+
}
|
|
676
|
+
}
|
|
668
677
|
}
|
|
669
678
|
async stats() {
|
|
670
679
|
await this.refreshMeta();
|
package/lib/core/watchdog.mjs
CHANGED
|
@@ -76,8 +76,14 @@ export function takeLaunched(dir, now = Date.now()) {
|
|
|
76
76
|
return Number.isFinite(at) && now - at <= SPOOL_TTL_MS;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** 看门狗跑在 ELECTRON_RUN_AS_NODE 下;`open` 会把环境传给 Desktop,不去掉它 Desktop 会以 Node 模式启动后立即退出。 */
|
|
80
|
+
export function desktopEnv(env = process.env) {
|
|
81
|
+
const {ELECTRON_RUN_AS_NODE, ...rest} = env;
|
|
82
|
+
return rest;
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
export function launchDesktop() {
|
|
80
|
-
return new Promise((resolve, reject) => execFile('open', ['-g', '-j', '-b', DESKTOP_BUNDLE_ID], {timeout: 15_000},
|
|
86
|
+
return new Promise((resolve, reject) => execFile('open', ['-g', '-j', '-b', DESKTOP_BUNDLE_ID], {timeout: 15_000, env: desktopEnv()},
|
|
81
87
|
e => e ? reject(e) : resolve()));
|
|
82
88
|
}
|
|
83
89
|
|
package/lib/core/wecom.mjs
CHANGED
|
@@ -353,6 +353,11 @@ export class WeComTransport {
|
|
|
353
353
|
async flush() {
|
|
354
354
|
if (!this.connected || this.closed || this.working) return;
|
|
355
355
|
this.working = true;
|
|
356
|
+
// 回执超时时服务端多半已送达(常见于电脑睡眠),重发只会重复,按已送达处理;明确报错才重试。
|
|
357
|
+
const deliver = async request => {
|
|
358
|
+
try { await request(); }
|
|
359
|
+
catch (e) { if (!/ack timeout/i.test(String(e?.message || ''))) throw e; this.log('企微回执超时,按已送达处理'); }
|
|
360
|
+
};
|
|
356
361
|
try {
|
|
357
362
|
for (const name of this.queueFiles()) {
|
|
358
363
|
if (!this.connected || this.closed) break;
|
|
@@ -363,10 +368,10 @@ export class WeComTransport {
|
|
|
363
368
|
try {
|
|
364
369
|
for (; packet.cursor < packet.steps.length; packet.cursor++) {
|
|
365
370
|
const step = packet.steps[packet.cursor];
|
|
366
|
-
if (step.kind === 'text') await this.client.sendMessage(packet.recipient, {msgtype: 'markdown', markdown: {content: step.text}});
|
|
371
|
+
if (step.kind === 'text') await deliver(() => this.client.sendMessage(packet.recipient, {msgtype: 'markdown', markdown: {content: step.text}}));
|
|
367
372
|
if (step.kind === 'card') {
|
|
368
373
|
try {
|
|
369
|
-
await this.client.sendMessage(packet.recipient, {msgtype: 'template_card', template_card: step.card});
|
|
374
|
+
await deliver(() => this.client.sendMessage(packet.recipient, {msgtype: 'template_card', template_card: step.card}));
|
|
370
375
|
this.rememberCard(step.card);
|
|
371
376
|
}
|
|
372
377
|
catch (e) {
|
|
@@ -380,7 +385,7 @@ export class WeComTransport {
|
|
|
380
385
|
}
|
|
381
386
|
if (step.kind === 'file') {
|
|
382
387
|
const media = await this.client.uploadMedia(fs.readFileSync(step.file), {type: 'file', filename: path.basename(step.file)});
|
|
383
|
-
await this.client.sendMediaMessage(packet.recipient, 'file', media.media_id);
|
|
388
|
+
await deliver(() => this.client.sendMediaMessage(packet.recipient, 'file', media.media_id));
|
|
384
389
|
}
|
|
385
390
|
writeJson(file, {...packet, cursor: packet.cursor + 1});
|
|
386
391
|
}
|
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import {tildeDir} from '../core/aliases.mjs';
|
|
10
10
|
|
|
11
11
|
export const SETTINGS_ENDPOINT = 'dsh-remote-wecom/settings';
|
|
12
|
-
export const SETTINGS_METHODS = ['state', 'qrStart', 'qrCancel', 'saveCredentials', 'newBindCode', 'unbind', 'setPrefs', 'setPlace', 'restart'];
|
|
12
|
+
export const SETTINGS_METHODS = ['state', 'qrStart', 'qrCancel', 'saveCredentials', 'newBindCode', 'unbind', 'setPrefs', 'setPlace', 'setWatchdog', 'restart'];
|
|
13
13
|
// 与遥控实例共用:手机「工作区」命令和设置页写同一个文件,内容 {workspaceId} 或 {cwd},没有文件 = 未分组。
|
|
14
14
|
export const DEFAULT_PLACE_FILE = '.default-place';
|
|
15
15
|
const UNGROUPED = 'none';
|
|
@@ -89,11 +89,12 @@ const RELOAD_WAIT_MS = 2500;
|
|
|
89
89
|
const QR_READY_WAIT_MS = 3000;
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* getWorkspaces
|
|
92
|
+
* getWorkspaces 缺省时(宿主不支持工作区)不显示默认位置;watchdog 缺省时(非 macOS)不显示看门狗开关。
|
|
93
93
|
* @param {{dir: string, getRemote: () => any, getBootError: () => string, reload: (why: string, opts?: {notify?: boolean}) => Promise<void>, qr: any,
|
|
94
|
-
* getWorkspaces?: (() => Promise<{items: {id: string, path: string, title: string}[]}>) | null
|
|
94
|
+
* getWorkspaces?: (() => Promise<{items: {id: string, path: string, title: string}[]}>) | null,
|
|
95
|
+
* watchdog?: {enabled: () => boolean, install: () => void, uninstall: () => void} | null}} deps
|
|
95
96
|
*/
|
|
96
|
-
export function createSettingsHandler({dir, getRemote, getBootError, reload, qr, getWorkspaces = null}) {
|
|
97
|
+
export function createSettingsHandler({dir, getRemote, getBootError, reload, qr, getWorkspaces = null, watchdog = null}) {
|
|
97
98
|
const settle = p => Promise.race([Promise.resolve(p).catch(() => {}), delay(RELOAD_WAIT_MS)]);
|
|
98
99
|
const placeFile = path.join(dir, DEFAULT_PLACE_FILE);
|
|
99
100
|
|
|
@@ -156,6 +157,7 @@ export function createSettingsHandler({dir, getRemote, getBootError, reload, qr,
|
|
|
156
157
|
? {remoteConfirm: cfg.remoteConfirm, remoteConfirmTimeoutMs: cfg.remoteConfirmTimeoutMs, openDesktop: cfg.openDesktop}
|
|
157
158
|
: {remoteConfirm: 'ask-then-allow', remoteConfirmTimeoutMs: 600000, openDesktop: false},
|
|
158
159
|
place: placeState(items),
|
|
160
|
+
watchdog: watchdog ? {enabled: watchdog.enabled()} : null,
|
|
159
161
|
version: VERSION,
|
|
160
162
|
};
|
|
161
163
|
}
|
|
@@ -212,6 +214,12 @@ export function createSettingsHandler({dir, getRemote, getBootError, reload, qr,
|
|
|
212
214
|
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
213
215
|
fs.writeFileSync(placeFile, JSON.stringify({workspaceId: id}) + '\n', {mode: 0o600});
|
|
214
216
|
},
|
|
217
|
+
async setWatchdog(payload) {
|
|
218
|
+
if (!watchdog) throw new Error('当前系统不支持');
|
|
219
|
+
if (typeof payload?.enabled !== 'boolean') throw new Error('参数无效');
|
|
220
|
+
try { payload.enabled ? watchdog.install() : watchdog.uninstall(); }
|
|
221
|
+
catch { throw new Error(payload.enabled ? '开启失败,请稍后重试' : '关闭失败,请稍后重试'); }
|
|
222
|
+
},
|
|
215
223
|
async restart() {
|
|
216
224
|
requireConfigured();
|
|
217
225
|
await settle(reload('设置页重启服务'));
|
package/lib/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {createQrAuth, createQrFlow} from './core/qr-auth.mjs';
|
|
|
8
8
|
import {createDshHost} from './host/dsh-host.mjs';
|
|
9
9
|
import {HEARTBEAT_MS, clearHeartbeat, watchdogState, writeHeartbeat} from './core/watchdog.mjs';
|
|
10
10
|
import {createSettingsHandler, qrSuccessWriter, registerSettingsRoute} from './host/settings-rpc.mjs';
|
|
11
|
+
import {createLaunchd, launchdSupported} from './core/launchd.mjs';
|
|
11
12
|
|
|
12
13
|
export const name = 'dsh-remote-wecom';
|
|
13
14
|
export const inject = ['typertGateway', 'connection'];
|
|
@@ -95,9 +96,16 @@ export function apply(ctx, options = {}) {
|
|
|
95
96
|
intervalMs: Number(options?.qrPollMs) > 0 ? Number(options.qrPollMs) : undefined,
|
|
96
97
|
log: debug,
|
|
97
98
|
});
|
|
99
|
+
// watchdogDryRun:预览和测试用,plist 写进数据目录、不调 launchctl。
|
|
100
|
+
const dryRun = options?.watchdogDryRun === true;
|
|
101
|
+
const launchd = options?.watchdog === false || !(dryRun || launchdSupported()) ? null
|
|
102
|
+
: createLaunchd({dir, ...(dryRun ? {home: dir, run: () => {}} : {})});
|
|
103
|
+
try { if (launchd?.sync()) debug('看门狗已对齐到当前插件'); }
|
|
104
|
+
catch (e) { warn(`看门狗更新失败:${e?.message || e}`); }
|
|
98
105
|
const handler = createSettingsHandler({
|
|
99
106
|
dir, getRemote: () => remote, getBootError: () => bootError, reload, qr,
|
|
100
107
|
getWorkspaces: typeof host.workspaces === 'function' ? () => host.workspaces() : null,
|
|
108
|
+
watchdog: launchd,
|
|
101
109
|
});
|
|
102
110
|
ctx.effect(() => registerSettingsRoute(ctx, handler), 'dsh-remote-wecom: settings route');
|
|
103
111
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-wecom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: control Desktop sessions from WeCom, with session cards, attachments, approvals and task results.",
|
|
5
5
|
"author": "lukeinx",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
"deepseek-desktop",
|
|
20
20
|
"wecom",
|
|
21
21
|
"wechat-work",
|
|
22
|
-
"remote-control"
|
|
22
|
+
"remote-control",
|
|
23
|
+
"deepseek",
|
|
24
|
+
"dsh",
|
|
25
|
+
"ai-agent",
|
|
26
|
+
"chatops",
|
|
27
|
+
"mobile",
|
|
28
|
+
"企业微信"
|
|
23
29
|
],
|
|
24
30
|
"main": "lib/index.mjs",
|
|
25
31
|
"exports": {
|
|
@@ -38,7 +44,7 @@
|
|
|
38
44
|
"README.md",
|
|
39
45
|
"LICENSE",
|
|
40
46
|
"NOTICE.md",
|
|
41
|
-
"docs"
|
|
47
|
+
"docs/DESIGN.md"
|
|
42
48
|
],
|
|
43
49
|
"dsh": {
|
|
44
50
|
"bundle": {
|