@twinsearth/w2m-dsh-plugin 0.47.0 → 0.49.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,123 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.49.0] — 2026-10-15
8
+
9
+ **Every message now gets an answer. Five inputs were being silently dropped, and two of the three input
10
+ types the request asked me to advertise are not supported.**
11
+
12
+ ### What was silent, measured before it was changed
13
+
14
+ ```
15
+ 文字 /run node --version → dispatch
16
+ 文字 /run --ask 你好 → reply
17
+ 文字 你好 → ignore why=not a /run line
18
+ 图片 (type 2) → ignore why=not a text message
19
+ 语音 (type 3) → ignore why=not a text message
20
+ 空 item_list → ignore why=not a text message
21
+ ```
22
+
23
+ **Five inputs, no answer.** A bot that reads messages and replies to none of them is
24
+ **indistinguishable from a bot that is broken**, and the operator's only move was to send the command again.
25
+
26
+ ### The one silence that survived, and why it had to
27
+
28
+ `no sender` and `no context_token` still return `ignore`. **With no sender or no context token there is
29
+ nowhere to send an answer**, so returning `reply` would be a plan that lies about what the caller can do.
30
+ **That is the reason the vocabulary still contains `ignore` at all** — collapsing it into `reply` would have
31
+ made every plan look successful while nothing was sent.
32
+
33
+ ### The request asked the help to advertise "text, voice and images". I refused two of the three.
34
+
35
+ `textOf` reads only `item type: 1` — **measured, not inferred: an image and a voice note both answer
36
+ `not a text message`.** So the help says:
37
+
38
+ > 支持:文字。中文可以。
39
+ > 不支持:语音、图片、文件、表情——它们只会得到这条帮助。
40
+
41
+ **A help text that promised a capability the bridge does not have would be read as a bug the first time
42
+ somebody sent a photo**, and the bug report would be about the bridge rather than about the promise.
43
+
44
+ ### The three sections are asserted by heading
45
+
46
+ `【1】开关指令` · `【2】指令规则与格式` · `【3】参考与边界` — asserted by name rather than by "the text mentions
47
+ commands", because **a help text that loses a section still reads fine.**
48
+
49
+ Two of the seven new assertions are worth naming:
50
+
51
+ * **`--ask` is in the help.** It was reachable only by reading the source, which is the entire
52
+ discoverability defect this section exists to close. If it ever leaves the help, the defect is back.
53
+ * **every `/run` example in the help is put through the real parser.** The help cannot drift into
54
+ advertising syntax that is refused.
55
+
56
+ ### The activation instruction, answered honestly
57
+
58
+ *"If refused, provide the activation instruction"* — and the honest answer is that **a chat window cannot
59
+ activate itself**. The help says the capabilities are granted by whoever starts the bridge, through
60
+ `W2M_CAPABILITIES`, and that a refusal names the missing capability. **Implying otherwise would have sent
61
+ the operator looking for a command that does not exist.**
62
+
63
+ **Three existing tests demanded `ignore` and now demand `reply`.** They were right about the rule — a typo
64
+ must never become a fleet execution — and wrong about how the rule is expressed. They were updated rather
65
+ than deleted, because **the old expectation is the record of what changed.**
66
+
67
+ **7 new tests, 0 failures.**
68
+
69
+ ## [0.48.0] — 2026-10-15
70
+
71
+ **`--dry-run` dispatched. Two dry runs sent a command to five machines, and one made a real task.**
72
+
73
+ ### The defect, measured before it was described
74
+
75
+ The bridge guarded exactly one thing under `--dry-run`: the **reply**. The dispatch ran unconditionally.
76
+
77
+ ```
78
+ [15:13:57] dry run : true
79
+ [15:15:00] dispatched task 01M50231AYJYDD41WSPMS9PRM3
80
+ [15:15:50] verdict for …: partial
81
+ ```
82
+
83
+ **That is a real task record with five real leases, produced by a run whose flag promised nothing would
84
+ happen.** A rehearsal executed commands on production machines — and it was found by accident, while draining
85
+ a message queue, not by a test.
86
+
87
+ ### Why the fix is "skip the dispatch" and not "rename the flag"
88
+
89
+ **This repository already fixes the meaning of the word.** `update-install`'s `dryRun` is asserted to **call no
90
+ package manager and write nothing**, and `auto-update`'s is asserted to **write nothing to the profile**. So a
91
+ second `dry-run` meaning "do the dangerous part anyway" is not a naming preference — **it is two flags with one
92
+ name and opposite meanings, and the bridge held the unsafe one.**
93
+
94
+ **Renaming would have left the flag. Aligning keeps the word and removes the hazard**, and a reader who knows
95
+ `dryRun` from anywhere else in this project is now already right about this one.
96
+
97
+ ### Why the guard is a function in `lib`, not an `if` in the script
98
+
99
+ `scripts/` is **not in `package.json#files`**, so nothing there is covered by a test that ships. A safety
100
+ property expressed as one line inside an untested script is a property nobody re-checks. `dryRunPolicy` is
101
+ exported and asserted beside the wire shapes — **a script is not where a guard should live if the guard is the
102
+ point.**
103
+
104
+ The guard sits at the **top of the only function that reaches `fleet.run`**, not at its call site, because a
105
+ guard at the call site is one edit away from being bypassed by a second caller.
106
+
107
+ ### A name collision that made my own test wrong
108
+
109
+ I asserted that `dryRun: 'true'` must not dispatch. **It does dispatch — the code was right and the test was
110
+ wrong**, and the cause is two different things sharing one word:
111
+
112
+ | | meaning |
113
+ |---|---|
114
+ | the CLI **flag** `--dry-run` | **present-means-true** (`argv.includes('--dry-run')`) |
115
+ | the **parameter** `dryRun` | **defaults to false**, which is what `update-install` means by it |
116
+
117
+ `dryRunPolicy` takes the parameter, and the script always hands it a boolean. The assertion is now the strict
118
+ one — **only `true` turns the dispatch off, so a future caller cannot open the path with a truthy value.**
119
+
120
+ **5 new tests, 0 failures.** One of them reads the script and asserts the guard appears **before** the only
121
+ call that can reach the fleet — because a test of the pure function alone would pass while the script still
122
+ dispatched, **which is precisely the shape of the original defect.**
123
+
7
124
  ## [0.47.0] — 2026-10-15
8
125
 
9
126
  **Correcting my own counts: 0.46.0 said 54/32/22, the table says 61/53/8.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -25,6 +25,47 @@
25
25
 
26
26
  import { parseInvocation, renderVerdict, renderDispatchFailure } from './run-command.mjs';
27
27
 
28
+ /**
29
+ * What a run with `--dry-run` set is allowed to do.
30
+ *
31
+ * ## The defect this exists to make unreachable
32
+ *
33
+ * `scripts/ilink-fleet-bridge.mjs` guarded exactly one thing under `--dry-run`: the **reply**. The
34
+ * dispatch itself ran unconditionally, so **two dry runs sent `node--version` to all five machines**
35
+ * — and one of them produced a real task record with five real leases. The flag's name promises that
36
+ * nothing happens, and the promise was false in the direction that matters: **a rehearsal executed
37
+ * commands on production machines.**
38
+ *
39
+ * ## Why "skip the dispatch" rather than "rename the flag"
40
+ *
41
+ * This repository already fixes the meaning of the word. `update-install`'s `dryRun` is asserted to
42
+ * **write nothing and call no package manager**, and `auto-update`'s is asserted to **write nothing to
43
+ * the profile**. So a second `dry-run` that means "do the dangerous part anyway" is not a naming
44
+ * preference — it is two flags with one name and opposite meanings, and the bridge held the unsafe one.
45
+ *
46
+ * **Renaming would have left the flag. Aligning keeps the word and removes the hazard**, and it means
47
+ * a reader who knows `dryRun` from anywhere else in this project is already right about this one.
48
+ *
49
+ * ## Why this is a function rather than an `if` in the script
50
+ *
51
+ * `scripts/` is not in `package.json#files`, so nothing there is covered by a test that ships. A safety
52
+ * property expressed as one line inside an untested script is a property nobody re-checks. As an
53
+ * exported function with a name, it is asserted in `chatops-ilink-fleet-bridge.test.mjs` beside the
54
+ * wire shapes — **a script is not where a guard should live if the guard is the point.**
55
+ *
56
+ * @param {{ dryRun?: boolean }} [options]
57
+ * @returns {{ dispatch: boolean, reply: boolean, reason: string|null }}
58
+ */
59
+ export function dryRunPolicy(options = {}) {
60
+ if (options.dryRun !== true) return { dispatch: true, reply: true, reason: null };
61
+ return {
62
+ dispatch: false,
63
+ reply: false,
64
+ // Carried so the caller logs *why* nothing happened rather than printing a bare "[dry-run]".
65
+ reason: 'dry run: the fleet was not dispatched to, and no reply was sent',
66
+ };
67
+ }
68
+
28
69
  /** The header every authenticated call needs; undocumented and mandatory. */
29
70
  export const AUTH_TYPE = 'ilink_bot_token';
30
71
  export const APP_ID = 'bot';
@@ -99,6 +140,64 @@ export function sendSucceeded(response) {
99
140
  return response.ret === 0 || response.errcode === 0;
100
141
  }
101
142
 
143
+ /**
144
+ * What to say when a message is not a command.
145
+ *
146
+ * ## The requirement this satisfies, and the part of it that had to be refused
147
+ *
148
+ * *"Every sentence from WeChat must get a reply; a mistake must get help."* Before this, **five measured
149
+ * inputs were silently dropped**: a plain sentence, an image, a voice note, an empty `item_list`, and an
150
+ * empty string. The operator sees a bot that reads messages and answers nothing, which is
151
+ * indistinguishable from a bot that is broken.
152
+ *
153
+ * The request also asked the help to advertise *"text, voice and images"*. **Two of those three are not
154
+ * supported**, and `textOf` reading only `type: 1` is the proof: an image and a voice note both answer
155
+ * `not a text message`. So the boundary section says what is true — **a help text that promises a
156
+ * capability the bridge does not have would be read as a bug the first time somebody sent a photo.**
157
+ *
158
+ * ## Why the boundary is a section rather than a footnote
159
+ *
160
+ * A user cannot discover a limitation they are never told about. **"Voice is not supported" arriving in
161
+ * the same breath as the command syntax is the difference between a limitation and a mystery.**
162
+ *
163
+ * @param {{reason?: string|null}} [context]
164
+ * @returns {string}
165
+ */
166
+ export function helpText(context = {}) {
167
+ const why = typeof context.reason === 'string' && context.reason !== '' ? context.reason : null;
168
+ const lines = [];
169
+ if (why !== null) lines.push(`没有执行这条消息:${why}`, '');
170
+
171
+ lines.push(
172
+ '【1】开关指令',
173
+ ' /run <命令> 把一个命令派发到编队(默认所有在线机器)',
174
+ ' /run --ask <一句话> 问一句话,不执行任何东西',
175
+ ' /run --on <机器> <命令> 只派发到指定机器(逗号分隔)',
176
+ ' /run --timeout <毫秒> <命令> 覆盖等待上限',
177
+ ' /run --mode <模式> <命令> replicate | split | broadcast | pipeline',
178
+ '',
179
+ ' —— 关于权限:这个窗口当前的能力是 operate、fleet.dispatch。',
180
+ ' 若被拒绝,回复里会写明缺哪一个能力;能力由启动桥接的人通过',
181
+ ' W2M_CAPABILITIES 授予,聊天窗口本身不能自行激活。',
182
+ '',
183
+ '【2】指令规则与格式',
184
+ ' · 只能以 /run 开头,其余文字一律当作普通消息并回这条帮助。',
185
+ ' · /run 之后是 argv(逐个词),不是 shell 字符串:',
186
+ ' 管道 | 、重定向 > 、&& 都不会生效,会被拒绝。',
187
+ ' · 每个词里的空格要用引号包起来。',
188
+ ' · 举例: /run node --version',
189
+ ' /run git rev-parse --short HEAD',
190
+ ' /run --ask 现在编队里有几台机器在线?',
191
+ '',
192
+ '【3】参考与边界',
193
+ ' · 支持:文字。中文可以。',
194
+ ' · 不支持:语音、图片、文件、表情——它们只会得到这条帮助。',
195
+ ' · 命令是否被允许由目标机器自己的允许清单决定;',
196
+ ' 被拒绝时回复会带上原因码(如 COMMAND_NOT_ALLOWED)。',
197
+ );
198
+ return lines.join('\n');
199
+ }
200
+
102
201
  /**
103
202
  * Decide what to do with one inbound message.
104
203
  *
@@ -117,14 +216,34 @@ export function planMessage({ message, authorize }) {
117
216
  const contextToken = message?.context_token;
118
217
  const text = textOf(message);
119
218
 
219
+ /**
220
+ * The only two real silences, and both are structural rather than a decision.
221
+ *
222
+ * With no `from_user_id` or no `context_token` there is nowhere to send an answer to — so a `reply`
223
+ * here would be a promise the caller cannot keep. **These stay `ignore`, and they are the reason the
224
+ * vocabulary still has one**: collapsing them into `reply` would make the plan lie about what the
225
+ * script can do. Everything below this line gets an answer, because it can.
226
+ */
120
227
  if (typeof from !== 'string' || from === '') return { act: 'ignore', why: 'no sender' };
121
228
  if (typeof contextToken !== 'string' || contextToken === '') return { act: 'ignore', why: 'no context_token' };
122
- if (text === null) return { act: 'ignore', why: 'not a text message' };
229
+
230
+ // A non-text message is answered rather than dropped: "every sentence must get a reply" includes the
231
+ // sentences the bridge cannot read.
232
+ if (text === null) {
233
+ return { act: 'reply', to: from, contextToken, text: helpText({ reason: '这条消息不是文字(语音/图片/文件暂不支持)' }) };
234
+ }
123
235
 
124
236
  const trimmed = text.trim();
125
- // Only the exact `/run` prefix is an action. Everything else is conversation, which is what keeps
126
- // a typo from becoming a fleet execution.
127
- if (!/^\/run(\s|$)/i.test(trimmed)) return { act: 'ignore', why: 'not a /run line' };
237
+ // Only the exact `/run` prefix is an action, and that rule is unchanged: a typo must never become a
238
+ // fleet execution. What changed is that it now gets an answer instead of silence.
239
+ if (!/^\/run(\s|$)/i.test(trimmed)) {
240
+ return {
241
+ act: 'reply',
242
+ to: from,
243
+ contextToken,
244
+ text: helpText({ reason: trimmed === '' ? '空消息' : '这条消息没有以 /run 开头' }),
245
+ };
246
+ }
128
247
 
129
248
  const body = trimmed.replace(/^\/run\s*/i, '');
130
249
  const invocation = parseInvocation(body);
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.47.0';
211
+ const PLUGIN_VERSION = '0.49.0';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;