@rezti/dsh-rez-wechat 0.1.7 → 0.1.11

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,19 +1,25 @@
1
1
  # @rezti/dsh-rez-wechat
2
2
 
3
- `@rezti/dsh-rez-suite` 一次装入。不要单独 `dsh plugin add`。
3
+ 企业微信走现成 `wecom-mcp`;个人微信走 QClaw 同款 `dsh-wechat-bridge`(腾讯 iLink)。都不自写长连接。
4
+
5
+ ```bash
6
+ dsh plugin --profile web add @rezti/dsh-rez-sso
7
+ dsh plugin --profile web add @rezti/dsh-rez-wechat
8
+ ```
4
9
 
5
10
  | 能力 | 做法 |
6
11
  |---|---|
7
- | 个人微信(QClaw / ClawBot) | 打开 dsh Web → Rez 面板 → **微信** → 扫码。之后直接在微信里说话。消息进左侧 **微信** 文件夹的同一会话,模型跟网页默认。 |
8
12
  | 企业微信发到群 | `wecom-mcp`(`mcp__wechat__*`)+ `REZ_WECHAT_WEBHOOK` |
13
+ | 个人微信(QClaw / ClawBot) | dsh Web 设置面板扫码,之后直接在微信里说话。消息进左侧 **微信** 文件夹的同一会话,模型跟网页默认。 |
9
14
 
10
- 个人微信复用现成 `dsh-wechat-bridge`(腾讯官方 iLink),不自写 WebSocket,也不用员工再跑 `npx weixin-mcp login`。桥接到正在跑的 `dsh web`(不是 `dsh --profile headless`),所以:
15
+ 个人微信桥接到正在跑的 `dsh web`(不是 `dsh --profile headless`):
11
16
 
12
17
  - 左侧工作区分组是 **微信**,不是 Ungrouped
13
- - 连续消息延续同一会话,不会一条一个窗口
14
- - 模型跟网页默认(在网页里用 Kimi Code,微信也会用)
15
- - 能用 web profile 里已经挂上的 Odoo 等 MCP
18
+ - 连续消息延续同一会话
19
+ - 模型跟网页默认(网页选了 Kimi,微信也走 Kimi)
20
+ - 网页归档后再从微信说话会自动回到侧栏(官方会藏空白会话,所以「重启对话」会先打一句招呼)
21
+ - 通道断了点「重新连接」,已登录不必再扫码
16
22
 
17
- 网页端必须开着。微信里 `/new <名字>` 会新开一条网页会话。
23
+ 扫码 UI 暂时还在旧单体 `dsh-rez-suite` 面板;通道本身在本包。不要对同一 profile 既 `plugin add suite` 又 `plugin add wechat`,会重复注册。
18
24
 
19
- 这是 ClawBot 客服会话,不是用个人微信号管好友/群。
25
+ 这是 ClawBot 客服会话,不是用个人微信号管好友/群,也不是客户层 WhatsApp/邮件通道。
@@ -0,0 +1,5 @@
1
+ # WeCom MCP is started by this plugin after rezSso resolves REZ_WECHAT_WEBHOOK.
2
+ # Personal WeChat (QClaw/ClawBot) is always owned here (dsh-wechat-bridge).
3
+ - insert:
4
+ - id: rez-wechat
5
+ name: '@rezti/dsh-rez-wechat'
package/lib/channel.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface WeixinStatus {
11
11
  qrDataUrl?: string;
12
12
  hint: string;
13
13
  }
14
+ export declare function weixinLoginKind(authExists: boolean): 'reconnect' | 'qr';
14
15
  export declare function weixinRunEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
15
16
  export declare class WeixinChannel {
16
17
  private loginChild;
@@ -20,6 +21,9 @@ export declare class WeixinChannel {
20
21
  private qrDataUrl;
21
22
  private hint;
22
23
  private logBuf;
24
+ private runStopped;
25
+ private runExits;
26
+ private runTimer;
23
27
  status(): WeixinStatus;
24
28
  startIfLoggedIn(): void;
25
29
  startLogin(): Promise<WeixinStatus>;
package/lib/channel.js CHANGED
@@ -11,6 +11,9 @@ import { weixinBridgeEnv, writeWeixinDshLauncher } from './web-shim.js';
11
11
  const DATA_DIR = join(homedir(), '.dsh', 'dsh-rez-weixin');
12
12
  const AUTH_FILE = join(DATA_DIR, 'weixin-auth.json');
13
13
  const SHIM_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'web-shim-cli.js');
14
+ export function weixinLoginKind(authExists) {
15
+ return authExists ? 'reconnect' : 'qr';
16
+ }
14
17
  export function weixinRunEnv(env = process.env) {
15
18
  const launcher = writeWeixinDshLauncher({
16
19
  dataDir: DATA_DIR,
@@ -49,6 +52,9 @@ export class WeixinChannel {
49
52
  qrDataUrl;
50
53
  hint = '打开 Rez 面板的「微信」页扫码,然后直接在微信里说话。';
51
54
  logBuf = '';
55
+ runStopped = true;
56
+ runExits = 0;
57
+ runTimer;
52
58
  status() {
53
59
  const loggedIn = existsSync(AUTH_FILE);
54
60
  const status = {
@@ -69,10 +75,16 @@ export class WeixinChannel {
69
75
  }
70
76
  async startLogin() {
71
77
  this.stopLogin();
72
- this.phase = 'login';
78
+ this.stopRun();
73
79
  this.qrContent = undefined;
74
80
  this.qrDataUrl = undefined;
75
81
  this.logBuf = '';
82
+ if (weixinLoginKind(existsSync(AUTH_FILE)) === 'reconnect') {
83
+ this.hint = '已重新拉起微信监听。若手机上仍发不出去,先点退出再扫码。';
84
+ this.startRun();
85
+ return this.status();
86
+ }
87
+ this.phase = 'login';
76
88
  this.hint = '正在向微信申请二维码…';
77
89
  const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'login', '--data-dir', DATA_DIR], {
78
90
  env: process.env,
@@ -147,6 +159,11 @@ export class WeixinChannel {
147
159
  this.stopRun();
148
160
  }
149
161
  startRun() {
162
+ this.runStopped = false;
163
+ if (this.runTimer !== undefined) {
164
+ clearTimeout(this.runTimer);
165
+ this.runTimer = undefined;
166
+ }
150
167
  if (this.runChild !== undefined && this.runChild.exitCode === null)
151
168
  return;
152
169
  this.phase = 'listening';
@@ -156,13 +173,32 @@ export class WeixinChannel {
156
173
  stdio: ['ignore', 'pipe', 'pipe'],
157
174
  });
158
175
  this.runChild = child;
176
+ const startedAt = Date.now();
159
177
  child.on('exit', code => {
160
178
  if (this.runChild === child)
161
179
  this.runChild = undefined;
162
- if (this.phase === 'listening') {
163
- this.phase = existsSync(AUTH_FILE) ? 'idle' : 'error';
164
- this.hint = code === 0 ? '微信监听已停。' : '微信监听退出,可再点扫码或等自动重连。';
180
+ if (this.runStopped)
181
+ return;
182
+ if (Date.now() - startedAt > 30_000)
183
+ this.runExits = 0;
184
+ this.runExits += 1;
185
+ if (!existsSync(AUTH_FILE)) {
186
+ this.phase = 'error';
187
+ this.hint = '微信登录已失效。请重新扫码。';
188
+ return;
189
+ }
190
+ if (this.runExits >= 5) {
191
+ this.phase = 'error';
192
+ this.hint = '微信监听反复断开。请点退出,再扫码登录。';
193
+ return;
165
194
  }
195
+ this.phase = 'idle';
196
+ this.hint = `微信监听断了(退出码 ${code ?? 'null'}),正在重连…`;
197
+ this.runTimer = setTimeout(() => {
198
+ this.runTimer = undefined;
199
+ if (!this.runStopped)
200
+ this.startRun();
201
+ }, 2000);
166
202
  });
167
203
  }
168
204
  stopLogin() {
@@ -172,6 +208,12 @@ export class WeixinChannel {
172
208
  this.loginChild = undefined;
173
209
  }
174
210
  stopRun() {
211
+ this.runStopped = true;
212
+ this.runExits = 0;
213
+ if (this.runTimer !== undefined) {
214
+ clearTimeout(this.runTimer);
215
+ this.runTimer = undefined;
216
+ }
175
217
  if (this.runChild === undefined)
176
218
  return;
177
219
  this.runChild.kill('SIGTERM');
package/lib/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { startMcpWhenSecret } from '@rezti/dsh-rez-sso';
2
2
  import { WeixinChannel } from './channel.js';
3
+ import { revealSessionInRegistry } from './unarchive.js';
3
4
  export const name = 'rez-wechat';
4
5
  export const inject = ['systemPrompt', 'webServer', 'rezSso'];
5
6
  const GUIDANCE = [
6
7
  '企业微信群发送走官方 dsh-mcp-client + wecom-mcp(mcp__wechat__*),密钥 REZ_WECHAT_WEBHOOK。未配置时不拉起 wecom-mcp。Webhook 在 设置 → 插件 → ReZ-TI 里填。',
7
- '个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。',
8
+ '个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。',
8
9
  '这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
9
10
  ].join('');
10
11
  function isLoopbackRequest(request) {
@@ -43,7 +44,7 @@ async function readJsonBody(req) {
43
44
  return undefined;
44
45
  }
45
46
  }
46
- function weixinRoutes(channel) {
47
+ function weixinRoutes(channel, ctx) {
47
48
  const path = '/api/dsh-rez-suite/weixin';
48
49
  const guard = (req, res, method) => {
49
50
  if (!isLoopbackRequest(req)) {
@@ -93,8 +94,38 @@ function weixinRoutes(channel) {
93
94
  writeJson(res, 200, channel.sendVerify(code));
94
95
  },
95
96
  },
97
+ {
98
+ kind: 'exact',
99
+ path: path + '/unarchive',
100
+ handler: async (req, res) => {
101
+ if (!guard(req, res, 'POST'))
102
+ return;
103
+ const body = await readJsonBody(req);
104
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
105
+ if (sessionId.length === 0) {
106
+ writeJson(res, 400, { error: 'missing sessionId' });
107
+ return;
108
+ }
109
+ const workspacePath = typeof body?.workspacePath === 'string' ? body.workspacePath.trim() : '';
110
+ try {
111
+ writeJson(res, 200, await revealSessionInRegistry(getWorkspaceRegistry(ctx), sessionId, workspacePath.length > 0 ? workspacePath : undefined));
112
+ }
113
+ catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ writeJson(res, 501, { error: message });
116
+ }
117
+ },
118
+ },
96
119
  ];
97
120
  }
121
+ function getWorkspaceRegistry(ctx) {
122
+ try {
123
+ return ctx.get('workspaceRegistry');
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
98
129
  export async function apply(ctx) {
99
130
  const channel = new WeixinChannel();
100
131
  channel.startIfLoggedIn();
@@ -103,7 +134,7 @@ export async function apply(ctx) {
103
134
  order: 162,
104
135
  text: GUIDANCE,
105
136
  }), 'dsh-rez-wechat: prompt');
106
- const routes = weixinRoutes(channel);
137
+ const routes = weixinRoutes(channel, ctx);
107
138
  ctx.effect(() => {
108
139
  const disposers = routes.map(route => ctx.webServer.register(route));
109
140
  return () => { for (const dispose of disposers)
@@ -0,0 +1,43 @@
1
+ /**
2
+ * dsh 0.1.0-rc.6 archives one-way over RPC. Stock Harness still exposes
3
+ * workspaceRegistry.requireState / setState (same primitives archiveSession
4
+ * uses). Weixin follow-ups call this so an archived web session reappears
5
+ * in the sidebar instead of staying hidden while WeChat keeps talking to it.
6
+ *
7
+ * Disk form is often `global.archivedSessionIds` in workspace.json; the live
8
+ * registry state may be that nested object or a flat `{ archivedSessionIds }`.
9
+ * After dropping the id, attachSession puts Ungrouped sessions into 微信 —
10
+ * session.create({ cwd }) never joins a workspace on its own after bootstrap.
11
+ */
12
+ export interface WorkspaceLike {
13
+ id?: string;
14
+ path?: string;
15
+ title?: string;
16
+ attachSession?: (sessionId: string) => Promise<void>;
17
+ }
18
+ export interface WorkspaceRegistryLike {
19
+ requireState?: () => Record<string, unknown>;
20
+ setState?: (state: Record<string, unknown>) => Promise<void>;
21
+ archiveSession?: (sessionId: string) => Promise<void>;
22
+ unarchiveSession?: (sessionId: string) => Promise<void>;
23
+ list?: () => WorkspaceLike[];
24
+ get?: (id: string) => WorkspaceLike | undefined;
25
+ resolveByPath?: (path: string) => Promise<WorkspaceLike | undefined>;
26
+ }
27
+ /** Read archive ids from either the live registry shape or the on-disk global bag. */
28
+ export declare function archivedSessionIds(state: unknown): string[];
29
+ export declare function unarchiveSessionInRegistry(registry: WorkspaceRegistryLike | undefined, sessionId: string): Promise<{
30
+ sessionId: string;
31
+ archived: boolean;
32
+ changed: boolean;
33
+ }>;
34
+ /**
35
+ * Drop the archive bit, then attach so the GUI's 微信 folder actually lists it.
36
+ * attachSession is host-only (no workspace.attachSession RPC on stock dsh).
37
+ */
38
+ export declare function revealSessionInRegistry(registry: WorkspaceRegistryLike | undefined, sessionId: string, workspacePath?: string): Promise<{
39
+ sessionId: string;
40
+ archived: boolean;
41
+ changed: boolean;
42
+ attached: boolean;
43
+ }>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * dsh 0.1.0-rc.6 archives one-way over RPC. Stock Harness still exposes
3
+ * workspaceRegistry.requireState / setState (same primitives archiveSession
4
+ * uses). Weixin follow-ups call this so an archived web session reappears
5
+ * in the sidebar instead of staying hidden while WeChat keeps talking to it.
6
+ *
7
+ * Disk form is often `global.archivedSessionIds` in workspace.json; the live
8
+ * registry state may be that nested object or a flat `{ archivedSessionIds }`.
9
+ * After dropping the id, attachSession puts Ungrouped sessions into 微信 —
10
+ * session.create({ cwd }) never joins a workspace on its own after bootstrap.
11
+ */
12
+ function asRecord(value) {
13
+ return typeof value === 'object' && value !== null ? value : undefined;
14
+ }
15
+ function stringIds(value) {
16
+ if (!Array.isArray(value))
17
+ return [];
18
+ return value.filter((id) => typeof id === 'string');
19
+ }
20
+ /** Read archive ids from either the live registry shape or the on-disk global bag. */
21
+ export function archivedSessionIds(state) {
22
+ const rec = asRecord(state);
23
+ if (rec === undefined)
24
+ return [];
25
+ const top = stringIds(rec.archivedSessionIds);
26
+ if (top.length > 0)
27
+ return top;
28
+ return stringIds(asRecord(rec.global)?.archivedSessionIds);
29
+ }
30
+ function dropArchivedId(state, sessionId) {
31
+ const next = { ...state };
32
+ if (Array.isArray(state.archivedSessionIds)) {
33
+ next.archivedSessionIds = stringIds(state.archivedSessionIds).filter(id => id !== sessionId);
34
+ }
35
+ const global = asRecord(state.global);
36
+ if (global !== undefined && Array.isArray(global.archivedSessionIds)) {
37
+ next.global = {
38
+ ...global,
39
+ archivedSessionIds: stringIds(global.archivedSessionIds).filter(id => id !== sessionId),
40
+ };
41
+ }
42
+ return next;
43
+ }
44
+ export async function unarchiveSessionInRegistry(registry, sessionId) {
45
+ if (registry !== undefined && typeof registry.unarchiveSession === 'function') {
46
+ await registry.unarchiveSession(sessionId);
47
+ return { sessionId, archived: false, changed: true };
48
+ }
49
+ if (registry === undefined || typeof registry.requireState !== 'function' || typeof registry.setState !== 'function') {
50
+ throw new Error('当前 Harness 不支持取消归档(缺少 workspaceRegistry 状态原语)');
51
+ }
52
+ const state = registry.requireState();
53
+ const ids = archivedSessionIds(state);
54
+ if (!ids.includes(sessionId))
55
+ return { sessionId, archived: false, changed: false };
56
+ await registry.setState(dropArchivedId(state, sessionId));
57
+ return { sessionId, archived: false, changed: true };
58
+ }
59
+ function pathLooksLike(candidate, wanted) {
60
+ if (candidate === undefined || candidate.length === 0)
61
+ return false;
62
+ const a = candidate.replace(/\\/g, '/').replace(/\/+$/, '');
63
+ const b = wanted.replace(/\\/g, '/').replace(/\/+$/, '');
64
+ return a === b || a.endsWith('/' + b.split('/').pop());
65
+ }
66
+ async function attachToWorkspace(workspace, sessionId) {
67
+ if (workspace === undefined || typeof workspace.attachSession !== 'function')
68
+ return false;
69
+ try {
70
+ await workspace.attachSession(sessionId);
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
78
+ * Drop the archive bit, then attach so the GUI's 微信 folder actually lists it.
79
+ * attachSession is host-only (no workspace.attachSession RPC on stock dsh).
80
+ */
81
+ export async function revealSessionInRegistry(registry, sessionId, workspacePath) {
82
+ let unarchived = { sessionId, archived: true, changed: false };
83
+ try {
84
+ unarchived = await unarchiveSessionInRegistry(registry, sessionId);
85
+ }
86
+ catch {
87
+ // Stock registry may lack requireState; attachSession still puts the row in 微信.
88
+ }
89
+ let attached = false;
90
+ if (registry !== undefined && workspacePath !== undefined && workspacePath.length > 0 && typeof registry.resolveByPath === 'function') {
91
+ try {
92
+ attached = await attachToWorkspace(await registry.resolveByPath(workspacePath), sessionId);
93
+ }
94
+ catch {
95
+ attached = false;
96
+ }
97
+ }
98
+ if (!attached && registry !== undefined && typeof registry.list === 'function') {
99
+ const workspaces = registry.list();
100
+ const preferred = workspacePath !== undefined
101
+ ? workspaces.find(ws => pathLooksLike(ws.path, workspacePath) || ws.title === '微信')
102
+ : undefined;
103
+ if (preferred !== undefined)
104
+ attached = await attachToWorkspace(preferred, sessionId);
105
+ if (!attached) {
106
+ for (const workspace of workspaces) {
107
+ if (await attachToWorkspace(workspace, sessionId)) {
108
+ attached = true;
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ }
114
+ return { ...unarchived, attached };
115
+ }
package/lib/web-shim.d.ts CHANGED
@@ -28,6 +28,15 @@ export declare function parseHeadlessArgv(argv: string[]): {
28
28
  error: string;
29
29
  };
30
30
  export declare function isFreshBridgeTurn(task: string): boolean;
31
+ /** WeChat /new is handled by dsh-wechat-bridge. Chinese "重启对话" is a normal message and must be detected here. */
32
+ export declare function isRestartCommand(text: string): boolean;
33
+ export declare const RESTART_ACK = "\u5DF2\u65B0\u5F00\u7F51\u9875\u4F1A\u8BDD\uFF08\u5DE6\u4FA7\u300C\u5FAE\u4FE1\u300D\u6587\u4EF6\u5939\uFF09\u3002\u4E4B\u540E\u76F4\u63A5\u8BF4\u8BDD\u5373\u53EF\u3002\u518D\u53D1\u300C\u91CD\u542F\u5BF9\u8BDD\u300D\u4F1A\u518D\u5F00\u4E00\u6761\uFF1B\u65E7\u4F1A\u8BDD\u4F1A\u5F52\u6863\uFF0C\u7F51\u9875\u4FA7\u680F\u66F4\u5E72\u51C0\u3002";
34
+ export declare const RESTART_SEED = "\u65B0\u4F1A\u8BDD\u5DF2\u5F00\u59CB\u3002\u8BF7\u7528\u4E00\u53E5\u4E2D\u6587\u6253\u62DB\u547C\uFF0C\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u6B65\u9AA4\u3002";
35
+ export declare const STUCK_ACK = "\u7F51\u9875\u7AEF\u8FD9\u6761\u4F1A\u8BDD\u5361\u4F4F\u4E86\uFF08\u5728\u7B49\u6279\u51C6\uFF0C\u6216\u6A21\u578B\u628A\u5185\u90E8\u63A8\u7406\u53D1\u51FA\u6765\u4E86\uFF09\u3002\u8BF7\u770B\u7F51\u9875\u5DE6\u4FA7\u300C\u5FAE\u4FE1\u300D\u6587\u4EF6\u5939\uFF0C\u6216\u53D1\u300C\u91CD\u542F\u5BF9\u8BDD\u300D\u3002";
36
+ export declare const RPC_TIMEOUT_MS = 15000;
37
+ export declare function looksLikeInternalMonologue(text: string): boolean;
38
+ export declare function usableAssistantText(text: string): string;
39
+ export declare function turnIsBlocked(events: unknown[]): boolean;
31
40
  export declare function extractLatestUserText(task: string): string;
32
41
  export declare function dshHomeDir(env?: NodeJS.ProcessEnv, home?: string): string;
33
42
  export declare function wechatWorkspaceDir(env?: NodeJS.ProcessEnv, home?: string): string;
@@ -35,9 +44,17 @@ export declare function sessionStorePath(env?: NodeJS.ProcessEnv, home?: string)
35
44
  export declare function webBaseUrl(env?: NodeJS.ProcessEnv): string;
36
45
  export declare function loadSessionStore(path: string): SessionStore;
37
46
  export declare function saveSessionStore(path: string, store: SessionStore): void;
47
+ export declare function encodeClientRequest(method: string, payload: Record<string, unknown>, rpcId?: string): {
48
+ type: 'client-request';
49
+ rpcId: string;
50
+ method: string;
51
+ payload: Record<string, unknown>;
52
+ };
38
53
  export declare function unwrapRpc(body: unknown): unknown;
39
54
  export declare function rpcErrorMessage(body: unknown): string | undefined;
40
55
  export declare function extractSessionId(body: unknown): string | undefined;
56
+ export declare function extractWorkspaceId(body: unknown): string | undefined;
57
+ export declare function findWorkspaceId(body: unknown, cwd?: string): string | undefined;
41
58
  export declare function historyEvents(body: unknown): unknown[];
42
59
  export declare function lastAssistantText(events: unknown[]): string;
43
60
  export declare function turnIsIdle(events: unknown[]): boolean;
@@ -55,7 +72,10 @@ export declare function weixinBridgeEnv(opts: {
55
72
  launcherPath: string;
56
73
  shimScript: string;
57
74
  }): NodeJS.ProcessEnv;
58
- export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>): Promise<unknown>;
75
+ export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
76
+ export declare function extractArchivedSessionIds(body: unknown): string[];
77
+ /** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
78
+ export declare function ensureSessionVisible(rpc: RpcFn, sessionId: string, env?: NodeJS.ProcessEnv, workspaceId?: string, loopback?: boolean): Promise<void>;
59
79
  export declare function runHeadlessViaWeb(opts: {
60
80
  task: string;
61
81
  cwd?: string;
package/lib/web-shim.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * folder is named 微信, turns continue, and the model follows the web default
8
8
  * (Kimi if that is what the user selected).
9
9
  */
10
+ import { randomUUID } from 'node:crypto';
10
11
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
12
  import { homedir } from 'node:os';
12
13
  import { basename, dirname, join } from 'node:path';
@@ -26,6 +27,54 @@ export function parseHeadlessArgv(argv) {
26
27
  export function isFreshBridgeTurn(task) {
27
28
  return !task.includes('【桥接上下文】');
28
29
  }
30
+ /** WeChat /new is handled by dsh-wechat-bridge. Chinese "重启对话" is a normal message and must be detected here. */
31
+ export function isRestartCommand(text) {
32
+ const t = text.trim();
33
+ if (t.length === 0)
34
+ return false;
35
+ if (/^\/(new|restart|reset|clear)(\s+\S+)?$/i.test(t))
36
+ return true;
37
+ return /^(重启对话|重新开始|新对话|开启新对话|重置对话)[。.!!]?$/.test(t);
38
+ }
39
+ export const RESTART_ACK = '已新开网页会话(左侧「微信」文件夹)。之后直接说话即可。再发「重启对话」会再开一条;旧会话会归档,网页侧栏更干净。';
40
+ export const RESTART_SEED = '新会话已开始。请用一句中文打招呼,不要解释内部步骤。';
41
+ export const STUCK_ACK = '网页端这条会话卡住了(在等批准,或模型把内部推理发出来了)。请看网页左侧「微信」文件夹,或发「重启对话」。';
42
+ export const RPC_TIMEOUT_MS = 15_000;
43
+ const MONOLOGUE_MARKERS = [
44
+ 'sandbox_permissions',
45
+ 'danger-full-access',
46
+ 'workspace-write',
47
+ 'Sandbox mode escalation',
48
+ 'escalate speculatively',
49
+ 'outside workspace',
50
+ 'I need danger-full-access',
51
+ ];
52
+ export function looksLikeInternalMonologue(text) {
53
+ if (text.trim().length === 0)
54
+ return false;
55
+ if (MONOLOGUE_MARKERS.some(marker => text.includes(marker)))
56
+ return true;
57
+ return text.length > 400 && /sandbox_permissions|danger-full-access|workspace-write|AbortSignal|justification/i.test(text);
58
+ }
59
+ export function usableAssistantText(text) {
60
+ const trimmed = text.trim();
61
+ if (trimmed.length === 0 || looksLikeInternalMonologue(trimmed))
62
+ return '';
63
+ return trimmed;
64
+ }
65
+ export function turnIsBlocked(events) {
66
+ let blocked = false;
67
+ for (const event of events) {
68
+ if (typeof event !== 'object' || event === null)
69
+ continue;
70
+ const type = event.type;
71
+ if (type === 'approval/asked' || type === 'approval/requested' || type === 'question/requested')
72
+ blocked = true;
73
+ if (type === 'approval/decided' || type === 'approval/resolved' || type === 'question/resolved' || type === 'turn/end')
74
+ blocked = false;
75
+ }
76
+ return blocked;
77
+ }
29
78
  export function extractLatestUserText(task) {
30
79
  if (!task.includes('【桥接上下文】'))
31
80
  return task.trim();
@@ -70,10 +119,19 @@ export function saveSessionStore(path, store) {
70
119
  mkdirSync(dirname(path), { recursive: true });
71
120
  writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
72
121
  }
122
+ export function encodeClientRequest(method, payload, rpcId = randomUUID()) {
123
+ return { type: 'client-request', rpcId, method, payload };
124
+ }
73
125
  export function unwrapRpc(body) {
74
126
  if (typeof body !== 'object' || body === null)
75
127
  return body;
76
128
  const rec = body;
129
+ if (rec.type === 'server-response' && typeof rec.result === 'object' && rec.result !== null) {
130
+ const result = rec.result;
131
+ if (result.ok === true)
132
+ return result.value;
133
+ return rec.result;
134
+ }
77
135
  if (rec.error !== undefined)
78
136
  return rec;
79
137
  if (typeof rec.result === 'object' && rec.result !== null)
@@ -116,6 +174,57 @@ export function extractSessionId(body) {
116
174
  }
117
175
  return undefined;
118
176
  }
177
+ export function extractWorkspaceId(body) {
178
+ if (typeof body !== 'object' || body === null)
179
+ return undefined;
180
+ const rec = body;
181
+ if (typeof rec.workspaceId === 'string')
182
+ return rec.workspaceId;
183
+ if (typeof rec.workspace === 'object' && rec.workspace !== null) {
184
+ const nested = rec.workspace;
185
+ if (typeof nested.workspaceId === 'string')
186
+ return nested.workspaceId;
187
+ if (typeof nested.id === 'string')
188
+ return nested.id;
189
+ }
190
+ // WorkspaceView-like row, or a live Workspace entity serialized with `id`.
191
+ if (typeof rec.id === 'string' && (typeof rec.path === 'string' || typeof rec.title === 'string' || Array.isArray(rec.sessionIds))) {
192
+ return rec.id;
193
+ }
194
+ return undefined;
195
+ }
196
+ function normalizePath(path) {
197
+ return path.replace(/\\/g, '/').replace(/\/+$/, '');
198
+ }
199
+ export function findWorkspaceId(body, cwd) {
200
+ const direct = extractWorkspaceId(body);
201
+ if (direct !== undefined)
202
+ return direct;
203
+ if (typeof body !== 'object' || body === null)
204
+ return undefined;
205
+ const rec = body;
206
+ const items = Array.isArray(rec.items) ? rec.items
207
+ : Array.isArray(body) ? body
208
+ : Array.isArray(rec.workspaces) ? rec.workspaces
209
+ : [];
210
+ const rows = items.filter((item) => typeof item === 'object' && item !== null);
211
+ if (rows.length === 0)
212
+ return undefined;
213
+ const wanted = cwd !== undefined ? normalizePath(cwd) : undefined;
214
+ const basename = wanted?.split('/').pop();
215
+ const match = rows.find(row => {
216
+ const path = typeof row.path === 'string' ? normalizePath(row.path) : undefined;
217
+ const title = typeof row.title === 'string' ? row.title : undefined;
218
+ if (wanted !== undefined && path === wanted)
219
+ return true;
220
+ if (basename !== undefined && title === basename)
221
+ return true;
222
+ if (basename !== undefined && path !== undefined && path.endsWith('/' + basename))
223
+ return true;
224
+ return false;
225
+ }) ?? rows.find(row => row.title === WECHAT_WORKSPACE_NAME);
226
+ return match !== undefined ? extractWorkspaceId(match) : undefined;
227
+ }
119
228
  function flattenText(content) {
120
229
  if (typeof content === 'string')
121
230
  return content;
@@ -134,22 +243,25 @@ function flattenText(content) {
134
243
  })
135
244
  .join('');
136
245
  }
246
+ function unwrapHistoryItem(item) {
247
+ if (typeof item !== 'object' || item === null)
248
+ return item;
249
+ const rec = item;
250
+ return rec.event ?? item;
251
+ }
137
252
  export function historyEvents(body) {
138
253
  const unwrapped = unwrapRpc(body);
139
254
  if (Array.isArray(unwrapped))
140
- return unwrapped;
255
+ return unwrapped.map(unwrapHistoryItem);
141
256
  if (typeof unwrapped !== 'object' || unwrapped === null)
142
257
  return [];
143
258
  const rec = unwrapped;
144
- if (Array.isArray(rec.events))
145
- return rec.events;
146
- if (Array.isArray(rec.messages))
147
- return rec.messages;
148
- if (Array.isArray(rec.items))
149
- return rec.items;
150
- if (Array.isArray(rec.log))
151
- return rec.log;
152
- return [];
259
+ const raw = Array.isArray(rec.events) ? rec.events
260
+ : Array.isArray(rec.messages) ? rec.messages
261
+ : Array.isArray(rec.items) ? rec.items
262
+ : Array.isArray(rec.log) ? rec.log
263
+ : [];
264
+ return raw.map(unwrapHistoryItem);
153
265
  }
154
266
  export function lastAssistantText(events) {
155
267
  let text = '';
@@ -197,12 +309,14 @@ function collectModelRows(input, out, depth = 0, inheritedProvider) {
197
309
  if (typeof input !== 'object')
198
310
  return;
199
311
  const rec = input;
312
+ const isGroup = Array.isArray(rec.models);
313
+ const groupProvider = isGroup && typeof rec.id === 'string' ? rec.id : inheritedProvider;
200
314
  const provider = typeof rec.provider === 'string' ? rec.provider
201
315
  : typeof rec.providerID === 'string' ? rec.providerID
202
316
  : typeof rec.adapter === 'string' ? rec.adapter
203
- : inheritedProvider;
317
+ : groupProvider;
204
318
  const model = typeof rec.model === 'string' ? rec.model
205
- : typeof rec.id === 'string' && !rec.id.startsWith('session') ? rec.id
319
+ : !isGroup && typeof rec.id === 'string' && !rec.id.startsWith('session') ? rec.id
206
320
  : undefined;
207
321
  if (model !== undefined)
208
322
  out.push(provider !== undefined ? { provider, model } : { model });
@@ -250,12 +364,9 @@ export function pickWechatModel(models, env = process.env) {
250
364
  return coding ?? kimi[0];
251
365
  }
252
366
  export function selectModelPayloads(sessionId, ref) {
253
- const payloads = [
254
- { sessionId, model: ref.model, ...(ref.provider !== undefined ? { provider: ref.provider } : {}) },
255
- { sessionId, selection: ref },
256
- { sessionId, model: ref },
257
- ];
258
- return payloads;
367
+ if (ref.provider === undefined)
368
+ return [];
369
+ return [{ sessionId, provider: ref.provider, model: ref.model }];
259
370
  }
260
371
  export function writeWeixinDshLauncher(opts) {
261
372
  mkdirSync(opts.dataDir, { recursive: true });
@@ -281,14 +392,16 @@ export function weixinBridgeEnv(opts) {
281
392
  async function sleep(ms) {
282
393
  await new Promise(resolve => setTimeout(resolve, ms));
283
394
  }
284
- export async function postRpc(baseUrl, method, params) {
395
+ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_MS) {
285
396
  const url = `${baseUrl.replace(/\/$/, '')}/api/${method}`;
397
+ const envelope = encodeClientRequest(method, params);
286
398
  let response;
287
399
  try {
288
400
  response = await fetch(url, {
289
401
  method: 'POST',
290
402
  headers: { 'content-type': 'application/json' },
291
- body: JSON.stringify(params),
403
+ body: JSON.stringify(envelope),
404
+ signal: AbortSignal.timeout(timeoutMs),
292
405
  });
293
406
  }
294
407
  catch (error) {
@@ -312,10 +425,27 @@ export async function postRpc(baseUrl, method, params) {
312
425
  }
313
426
  async function ensureWorkspace(rpc, cwd) {
314
427
  mkdirSync(cwd, { recursive: true });
315
- await rpc('workspace.create', { path: cwd, title: WECHAT_WORKSPACE_NAME });
428
+ try {
429
+ const created = await rpc('workspace.create', { path: cwd });
430
+ const id = extractWorkspaceId(created) ?? findWorkspaceId(created, cwd);
431
+ if (id !== undefined)
432
+ return id;
433
+ }
434
+ catch {
435
+ // create may 409 / invalid-path; list is the reconnect authority
436
+ }
437
+ try {
438
+ return findWorkspaceId(await rpc('workspace.list', {}), cwd);
439
+ }
440
+ catch {
441
+ return undefined;
442
+ }
316
443
  }
317
- async function createSession(rpc, cwd) {
318
- const created = await rpc('session.create', { cwd });
444
+ async function createSession(rpc, cwd, workspaceId) {
445
+ // Official session.create accepts at most one of workspaceId / cwd.
446
+ // workspaceId is the one that attachSession-s into the 微信 sidebar.
447
+ const payload = workspaceId !== undefined ? { workspaceId } : { cwd };
448
+ const created = await rpc('session.create', payload);
319
449
  const id = extractSessionId(created);
320
450
  if (id === undefined)
321
451
  throw new Error('session.create 没有返回 sessionId');
@@ -348,14 +478,67 @@ async function sessionAlive(rpc, sessionId) {
348
478
  return true;
349
479
  }
350
480
  catch {
481
+ return false;
482
+ }
483
+ }
484
+ export function extractArchivedSessionIds(body) {
485
+ const unwrapped = unwrapRpc(body);
486
+ if (typeof unwrapped !== 'object' || unwrapped === null)
487
+ return [];
488
+ const rec = unwrapped;
489
+ if (Array.isArray(rec.archivedSessionIds)) {
490
+ return rec.archivedSessionIds.filter((id) => typeof id === 'string');
491
+ }
492
+ if (typeof rec.global === 'object' && rec.global !== null) {
493
+ const nested = rec.global;
494
+ if (Array.isArray(nested.archivedSessionIds)) {
495
+ return nested.archivedSessionIds.filter((id) => typeof id === 'string');
496
+ }
497
+ }
498
+ return [];
499
+ }
500
+ async function tryRpc(rpc, method, params) {
501
+ try {
502
+ await rpc(method, params);
503
+ return true;
504
+ }
505
+ catch {
506
+ return false;
507
+ }
508
+ }
509
+ /** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
510
+ export async function ensureSessionVisible(rpc, sessionId, env = process.env, workspaceId, loopback = true) {
511
+ await tryRpc(rpc, 'workspace.unarchiveSession', { sessionId });
512
+ await tryRpc(rpc, 'session.unarchive', { sessionId });
513
+ if (loopback) {
514
+ const workspacePath = wechatWorkspaceDir(env);
515
+ const url = `${webBaseUrl(env)}/api/dsh-rez-suite/weixin/unarchive`;
351
516
  try {
352
- await rpc('session.get', { sessionId });
353
- return true;
517
+ await fetch(url, {
518
+ method: 'POST',
519
+ headers: { 'content-type': 'application/json' },
520
+ body: JSON.stringify({ sessionId, workspacePath }),
521
+ signal: AbortSignal.timeout(3000),
522
+ });
354
523
  }
355
524
  catch {
356
- return false;
525
+ // web may be down or an older plugin; prompting still works, sidebar may stay hidden
357
526
  }
358
527
  }
528
+ if (workspaceId !== undefined) {
529
+ await tryRpc(rpc, 'workspace.attachSession', { workspaceId, sessionId });
530
+ await tryRpc(rpc, 'workspace.insertSessionBefore', { workspaceId, sessionId });
531
+ }
532
+ await tryRpc(rpc, 'session.rename', { sessionId, title: WECHAT_WORKSPACE_NAME });
533
+ }
534
+ async function archiveSession(rpc, sessionId) {
535
+ await tryRpc(rpc, 'workspace.archiveSession', { sessionId });
536
+ }
537
+ async function abortSessionTurn(rpc, sessionId) {
538
+ for (const method of ['session.abort', 'session.cancel', 'session.stop', 'session.interrupt']) {
539
+ if (await tryRpc(rpc, method, { sessionId }))
540
+ return;
541
+ }
359
542
  }
360
543
  async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
361
544
  const deadline = Date.now() + timeoutMs;
@@ -367,14 +550,11 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
367
550
  events = historyEvents(await rpc('session.history', { sessionId, maxMessages: 40 }));
368
551
  }
369
552
  catch {
370
- try {
371
- events = historyEvents(await rpc('session.get', { sessionId }));
372
- }
373
- catch {
374
- events = [];
375
- }
553
+ events = [];
376
554
  }
377
- const text = lastAssistantText(events);
555
+ if (turnIsBlocked(events))
556
+ return STUCK_ACK;
557
+ const text = usableAssistantText(lastAssistantText(events));
378
558
  if (text !== '')
379
559
  last = text;
380
560
  const idle = turnIsIdle(events);
@@ -388,7 +568,7 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
388
568
  }
389
569
  if (last !== '' && last !== beforeText)
390
570
  return last;
391
- throw new Error('等待 dsh web 回复超时。请看网页左侧「微信」文件夹里的会话。');
571
+ return STUCK_ACK;
392
572
  }
393
573
  export async function runHeadlessViaWeb(opts) {
394
574
  const env = opts.env ?? process.env;
@@ -398,12 +578,15 @@ export async function runHeadlessViaWeb(opts) {
398
578
  const workspace = wechatWorkspaceDir(env);
399
579
  const storeFile = sessionStorePath(env);
400
580
  const key = basename(opts.cwd ?? process.cwd()) || 'default';
581
+ const customRpc = opts.rpc !== undefined;
401
582
  const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
402
583
  const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
403
- await ensureWorkspace(rpc, workspace);
584
+ const workspaceId = await ensureWorkspace(rpc, workspace);
585
+ const restart = isRestartCommand(text);
586
+ const fresh = isFreshBridgeTurn(opts.task) || restart;
404
587
  const store = loadSessionStore(storeFile);
405
588
  let sessionId = store.sessions[key]?.sessionId;
406
- const fresh = isFreshBridgeTurn(opts.task);
589
+ const previousId = sessionId;
407
590
  if (sessionId !== undefined && !fresh) {
408
591
  const alive = await sessionAlive(rpc, sessionId);
409
592
  if (!alive)
@@ -413,21 +596,39 @@ export async function runHeadlessViaWeb(opts) {
413
596
  sessionId = undefined;
414
597
  }
415
598
  if (sessionId === undefined) {
416
- sessionId = await createSession(rpc, workspace);
599
+ sessionId = await createSession(rpc, workspace, workspaceId);
417
600
  await maybeSelectModel(rpc, sessionId, env);
601
+ await ensureSessionVisible(rpc, sessionId, env, workspaceId, !customRpc);
602
+ if (previousId !== undefined && previousId !== sessionId) {
603
+ await abortSessionTurn(rpc, previousId);
604
+ await archiveSession(rpc, previousId);
605
+ }
606
+ }
607
+ else {
608
+ await ensureSessionVisible(rpc, sessionId, env, workspaceId, !customRpc);
418
609
  }
419
610
  store.sessions[key] = { sessionId, updatedAt: Date.now() };
420
611
  saveSessionStore(storeFile, store);
612
+ const promptText = restart ? RESTART_SEED : text;
421
613
  let before = '';
614
+ let events = [];
422
615
  try {
423
- before = lastAssistantText(historyEvents(await rpc('session.history', { sessionId, maxMessages: 40 })));
616
+ events = historyEvents(await rpc('session.history', { sessionId, maxMessages: 40 }));
617
+ before = usableAssistantText(lastAssistantText(events));
424
618
  }
425
619
  catch {
426
620
  before = '';
427
621
  }
622
+ if (!turnIsIdle(events) || turnIsBlocked(events)) {
623
+ await abortSessionTurn(rpc, sessionId);
624
+ }
428
625
  await rpc('session.prompt', {
429
626
  sessionId,
430
- content: [{ type: 'text', text }],
627
+ mode: 'queue',
628
+ content: [{ type: 'text', text: promptText }],
431
629
  });
432
- return await waitForAssistant(rpc, sessionId, before, timeoutMs);
630
+ const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs);
631
+ if (restart && (reply === STUCK_ACK || reply === ''))
632
+ return RESTART_ACK;
633
+ return reply;
433
634
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.7",
3
+ "version": "0.1.11",
4
4
  "description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom group send uses wecom-mcp.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -14,15 +14,21 @@
14
14
  "default": "./lib/index.js"
15
15
  }
16
16
  },
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ }
21
+ },
17
22
  "files": [
18
23
  "lib/**/*.js",
19
24
  "lib/**/*.d.ts",
25
+ "cordis.patch.yml",
20
26
  "README.md"
21
27
  ],
22
28
  "license": "Apache-2.0",
23
29
  "dependencies": {
24
30
  "qrcode": "^1.5.4",
25
- "@rezti/dsh-rez-sso": "0.1.6"
31
+ "@rezti/dsh-rez-sso": "0.1.7"
26
32
  },
27
33
  "devDependencies": {
28
34
  "@deepseek-ai/cordis": "^4.0.1",
@@ -37,7 +43,7 @@
37
43
  },
38
44
  "repository": {
39
45
  "type": "git",
40
- "url": "git+https://github.com/kelvinchen1119/deepseek-harness.git",
46
+ "url": "git+https://github.com/ReZ-TI/deepseek-harness.git",
41
47
  "directory": "packages/dsh-rez-wechat"
42
48
  },
43
49
  "scripts": {