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 CHANGED
@@ -1,11 +1,20 @@
1
1
  # dsh-remote-wecom
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/dsh-remote-wecom)](https://www.npmjs.com/package/dsh-remote-wecom)
4
+ [![license](https://img.shields.io/npm/l/dsh-remote-wecom)](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
+ ![手机企业微信里切换会话、审批、插话、收结果和文件、切换模型](docs/images/chat.png)
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.0" },
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
- ## 离线看门狗(可选,macOS)
90
+ ## 命令行
71
91
 
72
- 插件随 Desktop 启停,Desktop 退出后就没人接企微了。看门狗是一个独立的 launchd 进程:发现插件不在线时暂存消息、在后台拉起 Desktop,插件上线后自动处理。
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
 
@@ -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 {fileURLToPath} from 'node:url';
11
- import {createWatchdog, heartbeatAge, heartbeatFresh, spoolCount, watchdogState} from '../lib/core/watchdog.mjs';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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('pgrep', ['-x', 'DeepSeek Harness'], {stdio: 'ignore'}); return true; } catch { return false; }
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 args = [process.execPath, fileURLToPath(import.meta.url), 'watchdog', 'run', '--dir', dir];
141
- const env = {PATH: '/usr/bin:/bin:/usr/sbin:/sbin', ...(process.env.DSH_HOME ? {DSH_HOME: process.env.DSH_HOME} : {})};
142
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
143
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
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
- try { execFileSync('launchctl', ['bootout', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); } catch {}
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
- let loaded = false;
179
- try { execFileSync('launchctl', ['print', `${domain()}/${WATCHDOG_LABEL}`], {stdio: 'ignore'}); loaded = true; } catch {}
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 (process.platform !== 'darwin' && sub !== 'run') throw new Error('看门狗仅支持 macOS(launchd)');
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 Desktop",
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
- ))), /* @__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));
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` 投影还是空的)。`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'`(首条消息截断),模型起的标题随后再来一条。
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 后企微无人接。`dsh-remote-wecom watchdog install` 装一个 launchd LaunchAgent(`KeepAlive`)跑 `watchdog run`,每 2 秒 tick 一次(`lib/core/watchdog.mjs`)。
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }
@@ -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();
@@ -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
 
@@ -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}} deps
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.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": {