@twinsearth/w2m-dsh-plugin 2.0.4 → 2.0.6
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1795 -1779
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +215 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +118 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- package/src/util/tofuture-hmac.mjs +0 -30
package/bin/w2m-joint.mjs
CHANGED
|
@@ -17,9 +17,15 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Exit codes: 0 ok, 1 refusal (named code), 2 usage error.
|
|
19
19
|
*/
|
|
20
|
-
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
import { deviceFilePath } from '../src/agent/identity.mjs';
|
|
23
|
+
import { resolveStateDir } from '../src/util/cli.mjs';
|
|
21
24
|
|
|
22
25
|
import { buildControlEnvelopes, buildPlan } from '../src/plugin/joint-dev/dispatch.mjs';
|
|
26
|
+
import { abortMerge, advanceTarget, mergePatches } from '../src/plugin/joint-dev/merge-engine.mjs';
|
|
27
|
+
import { consumeMergeApproval, createMergeApproval, loadMergeApproval } from '../src/plugin/joint-dev/merge-approval.mjs';
|
|
28
|
+
import { resolve as resolvePath } from 'node:path';
|
|
23
29
|
import { negotiateFeatures, readRelayCapabilities } from '../src/plugin/relay-capabilities.mjs';
|
|
24
30
|
import { dispatchPlanToRelay } from '../src/plugin/joint-dev/relay-dispatch.mjs';
|
|
25
31
|
import { readOperatorToken } from '../src/plugin/operator-token.mjs';
|
|
@@ -53,8 +59,8 @@ const fail = (code, detail) => { print({ ok: false, code, detail: detail ?? null
|
|
|
53
59
|
|
|
54
60
|
const args = parseArgs(process.argv.slice(2));
|
|
55
61
|
const [command, action] = args._;
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
if (args['allow-unsigned-approval'] === true) fail('UNKNOWN_FLAG', '--allow-unsigned-approval was removed: joint approvals must be signed');
|
|
63
|
+
const stateDir = resolveStateDir(typeof args.state === 'string' ? args.state : (process.env.W2M_JOINT_STATE ?? null), 'joint-dev');
|
|
58
64
|
|
|
59
65
|
if (command === 'plan' && action === 'create') {
|
|
60
66
|
if (typeof args.steps !== 'string') fail('STEPS_REQUIRED', 'pass --steps <steps.json>');
|
|
@@ -130,7 +136,7 @@ if (command === 'status') {
|
|
|
130
136
|
const plan = loadPlan({ stateDir, planSha: args.plan });
|
|
131
137
|
if (plan.ok !== true) fail(plan.code ?? 'PLAN_NOT_FOUND');
|
|
132
138
|
const approval = loadApproval({ stateDir, planSha: args.plan });
|
|
133
|
-
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature:
|
|
139
|
+
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature: true });
|
|
134
140
|
print({
|
|
135
141
|
ok: true,
|
|
136
142
|
plan_sha256: plan.plan.plan_sha256,
|
|
@@ -147,7 +153,7 @@ if (command === 'dispatch') {
|
|
|
147
153
|
if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
|
|
148
154
|
const key = ensureOperatorKey();
|
|
149
155
|
const trusted = key.ok === true ? [key.key_id] : [];
|
|
150
|
-
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature:
|
|
156
|
+
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature: true, trustedOperatorKeyIds: trusted });
|
|
151
157
|
if (gate.ok !== true) fail(gate.code ?? 'APPROVAL_REQUIRED', 'dispatch refused before anything was sent');
|
|
152
158
|
const relayUrl = typeof args.relay === 'string' ? args.relay : null;
|
|
153
159
|
if (args['dry-run'] !== true) {
|
|
@@ -161,9 +167,23 @@ if (command === 'dispatch') {
|
|
|
161
167
|
}
|
|
162
168
|
if (token === null) fail('OPERATOR_TOKEN_MISSING', 'run w2m-operator set --file <token file>, or pass --token-file');
|
|
163
169
|
// decision 2: one approval authorises exactly one dispatch.
|
|
164
|
-
const consumed = consumeApproval({ stateDir, planSha: args.plan, approvalSha: gate.approval.
|
|
170
|
+
const consumed = consumeApproval({ stateDir, planSha: args.plan, approvalSha: gate.approval.approval_sha256 });
|
|
165
171
|
if (consumed.ok !== true) fail(consumed.code ?? 'APPROVAL_CONSUME_FAILED', consumed.detail ?? null);
|
|
166
|
-
|
|
172
|
+
// D2/B5: the gate is on by default in warn mode; enforce is explicit.
|
|
173
|
+
let deviceToken = '';
|
|
174
|
+
if (typeof args['device-token-file'] === 'string') {
|
|
175
|
+
deviceToken = readFileSync(args['device-token-file'], 'utf8').trim();
|
|
176
|
+
} else {
|
|
177
|
+
try {
|
|
178
|
+
const file = deviceFilePath({});
|
|
179
|
+
if (existsSync(file)) deviceToken = String(JSON.parse(readFileSync(file, 'utf8')).device_token ?? '');
|
|
180
|
+
} catch { deviceToken = ''; }
|
|
181
|
+
}
|
|
182
|
+
const expectedVersion = typeof args['expect-version'] === 'string' && args['expect-version'] !== ''
|
|
183
|
+
? args['expect-version']
|
|
184
|
+
: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
185
|
+
const versionGate = args['version-gate'] === 'off' || args['version-gate'] === 'enforce' ? args['version-gate'] : 'warn';
|
|
186
|
+
const sent = await dispatchPlanToRelay({ rabbitUrl: relayUrl, token, plan: gate.plan, approval: gate.approval, auditFile: stateDir + '/joint-dev/audit.jsonl', dispatchDir: stateDir + '/joint-dev/dispatches', deviceToken, expectedVersion, versionGate });
|
|
167
187
|
print({ ...sent, plan_sha256: gate.plan.plan_sha256, relay: relayUrl });
|
|
168
188
|
process.exit(sent.ok === true ? 0 : 1);
|
|
169
189
|
}
|
|
@@ -183,6 +203,7 @@ if (command === 'dispatch') {
|
|
|
183
203
|
relay: typeof args.relay === 'string' ? args.relay : null,
|
|
184
204
|
relay_capabilities: capabilities === null ? null : { relay_version: capabilities.relay_version, features: capabilities.features },
|
|
185
205
|
relay_negotiation: negotiation,
|
|
206
|
+
version_gate: args['version-gate'] === 'off' || args['version-gate'] === 'enforce' ? args['version-gate'] : 'warn',
|
|
186
207
|
plan_sha256: gate.plan.plan_sha256,
|
|
187
208
|
approval_sha256: gate.approval.approval_sha256,
|
|
188
209
|
envelopes: envelopes.length,
|
|
@@ -191,5 +212,46 @@ if (command === 'dispatch') {
|
|
|
191
212
|
process.exit(0);
|
|
192
213
|
}
|
|
193
214
|
|
|
215
|
+
if (command === 'merge') {
|
|
216
|
+
const repo = typeof args.repo === 'string' ? resolvePath(args.repo) : null;
|
|
217
|
+
const baseCommit = typeof args.base === 'string' ? args.base : null;
|
|
218
|
+
const targetRef = typeof args['target-ref'] === 'string' ? args['target-ref'] : null;
|
|
219
|
+
const expectedOld = typeof args['expected-old'] === 'string' ? args['expected-old'] : null;
|
|
220
|
+
if (repo === null || baseCommit === null || targetRef === null || expectedOld === null) {
|
|
221
|
+
fail('MERGE_ARGS_REQUIRED', 'merge needs --repo --base --target-ref --expected-old');
|
|
222
|
+
}
|
|
223
|
+
if (action === 'approve') {
|
|
224
|
+
if (typeof args.head !== 'string' || typeof args.approver !== 'string') fail('MERGE_APPROVER_REQUIRED', 'merge approve needs --head <sha> --approver <name> --authority <70..100>');
|
|
225
|
+
const authority = Number.parseInt(String(args.authority ?? ''), 10);
|
|
226
|
+
if (!Number.isInteger(authority)) fail('AUTHORITY_REQUIRED', 'pass --authority <70..100>');
|
|
227
|
+
const ttlMin = Number.parseInt(String(args['ttl-min'] ?? '10'), 10);
|
|
228
|
+
const created = createMergeApproval({ stateDir, planSha: typeof args.plan === 'string' ? args.plan : null, repo, baseCommit, targetRef, expectedOld, integrationHead: args.head, approver: args.approver, authority, ttlMs: (Number.isFinite(ttlMin) ? ttlMin : 10) * 60_000 });
|
|
229
|
+
if (created.ok !== true) fail(created.code ?? 'MERGE_APPROVAL_FAILED', created.detail ?? null);
|
|
230
|
+
print({ ok: true, approval_sha256: created.approval_sha256, approval: created.approval });
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
if (typeof args.patches !== 'string') fail('MERGE_PATCHES_REQUIRED', 'pass --patches <manifest.json>');
|
|
234
|
+
let patches = null;
|
|
235
|
+
try { patches = JSON.parse(readFileSync(args.patches, 'utf8').replace(/^\uFEFF/, '')); } catch (error) { fail('MERGE_PATCHES_UNREADABLE', error instanceof Error ? error.message : String(error)); }
|
|
236
|
+
if (!Array.isArray(patches) || patches.length === 0) fail('MERGE_PATCHES_INVALID', 'manifest must be a non-empty JSON array of {step_id, patch}');
|
|
237
|
+
const merged = mergePatches({ repoDir: repo, baseCommit, patches });
|
|
238
|
+
if (merged.ok !== true) fail(merged.code ?? 'MERGE_FAILED', merged.detail ?? null);
|
|
239
|
+
const dryRun = args.apply !== true;
|
|
240
|
+
print({ ok: true, dry_run: dryRun, integration_head: merged.integration_head, applied: merged.applied, target_ref: targetRef, expected_old: expectedOld });
|
|
241
|
+
if (dryRun) { abortMerge({ repoDir: repo, worktree: merged.worktree }); process.exit(0); }
|
|
242
|
+
if (typeof args.approval !== 'string') { abortMerge({ repoDir: repo, worktree: merged.worktree }); fail('MERGE_APPROVAL_REQUIRED', '--apply needs --approval <merge_approval_sha256>'); }
|
|
243
|
+
const gate = loadMergeApproval({ stateDir, approvalSha: args.approval, repo, baseCommit, targetRef, expectedOld, integrationHead: merged.integration_head, planSha: typeof args.plan === 'string' ? args.plan : null });
|
|
244
|
+
if (gate.ok !== true) { abortMerge({ repoDir: repo, worktree: merged.worktree }); fail(gate.code ?? 'MERGE_APPROVAL_INVALID', gate.detail ?? null); }
|
|
245
|
+
const consumed = consumeMergeApproval({ stateDir, approvalSha: args.approval });
|
|
246
|
+
if (consumed.ok !== true) { abortMerge({ repoDir: repo, worktree: merged.worktree }); fail(consumed.code ?? 'MERGE_APPROVAL_CONSUME_FAILED', consumed.detail ?? null); }
|
|
247
|
+
const stamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
|
|
248
|
+
const rollbackRef = 'refs/w2m/rollback/' + (typeof args.plan === 'string' ? args.plan.slice(0, 12) : 'plan') + '-' + stamp;
|
|
249
|
+
const advanced = advanceTarget({ repoDir: repo, targetRef, expectedOld, newCommit: merged.integration_head, rollbackRef });
|
|
250
|
+
abortMerge({ repoDir: repo, worktree: merged.worktree });
|
|
251
|
+
if (advanced.ok !== true) fail(advanced.code ?? 'MERGE_CAS_FAILED', advanced.detail ?? null);
|
|
252
|
+
print({ ok: true, dry_run: false, integration_head: merged.integration_head, target_ref: targetRef, rollback_ref: rollbackRef });
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
194
256
|
process.stderr.write('usage: node bin/w2m-joint.mjs <plan create|list|get|approve|status|dispatch> [options]\n');
|
|
195
257
|
process.exit(2);
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.5",
|
|
3
|
+
"case": "RELEASE-205",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T16:37:29.497Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-205-1793205449498",
|
|
11
|
+
"requirement": "v2.0.5:Relay 支持 AEAD v2 加密消息信封(enc.v=2 + aad{session_id,machine_id,message_id}),新增能力标记 chat-enc-v2,供主插件启用 encrypted mirror 发送;v1 兼容不变。",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"envelope": "v2 shape accepted and echoed verbatim (iv/tag + nested aad); v1 still accepted; unknown versions refused BAD_ENCRYPTION",
|
|
16
|
+
"aadBinding": "clear AAD must name the same session_id and machine_id as the message (CHAT_AAD_MISMATCH otherwise); missing aad CHAT_AAD_MISSING; bad message id CHAT_AAD_INVALID",
|
|
17
|
+
"feature": "healthz features includes chat-enc-v2; dispatcher can gate encrypted mirror on it",
|
|
18
|
+
"tests": "fleet-messages + relay-health-capabilities + relay.test 217 pass / 0 fail; targeted v2 suite 28 pass / 0 fail; eslint 0 errors"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.5",
|
|
3
|
+
"case": "RELEASE-2050",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T16:36:54.281Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2050-1793205414282",
|
|
11
|
+
"requirement": "v2.0.5:A7 AEAD v2 AAD(chat v2 + relay enc.v2 + capability chat-enc-v2);联合开发 P0/S1/S4/A1/A2/A3/A4/A5/A6/A8 全部切片在本工作树验证",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"preflight": "16 pass / 0 fail / 1 unverifiable (GREEN)",
|
|
16
|
+
"tests": "declared suite all pass (node --test via scripts.test)",
|
|
17
|
+
"lint": "0 errors",
|
|
18
|
+
"pack": "dist tarball byte-identical on a second pack; manifest equals tree",
|
|
19
|
+
"scope": "local code + tests only; no push, no tag, no npm publish, no relay deployment"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.0.6",
|
|
3
|
+
"case": "RELEASE-2060",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T17:02:50.164Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2060-1793206970165",
|
|
11
|
+
"requirement": "v2.0.6:CI 跨平台修复(realpath 归一) + 搜索规则(纯数字≤100拒绝/视口最多6条/滚动刷新) + 一键到顶等待加载",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"preflight": "16 pass / 0 fail / 1 unverifiable (GREEN)",
|
|
16
|
+
"tests": "declared suite all pass; client-bundle 27/27",
|
|
17
|
+
"lint": "0 errors",
|
|
18
|
+
"pack": "byte-identical; manifest equals tree",
|
|
19
|
+
"scope": "local code + tests only; release follows via tag v2.0.6"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ADR 0001 — 多机协作:本周期不引入 CRDT(D5)
|
|
2
|
+
|
|
3
|
+
- 状态:已接受(2026-10-28)
|
|
4
|
+
- 决策者:项目负责人(D5)
|
|
5
|
+
- 相关:D1=c、A6 项目房间、A7 聊天 AEAD、S4 数据面红线
|
|
6
|
+
|
|
7
|
+
## 背景
|
|
8
|
+
|
|
9
|
+
“多机跨机实时协作”的讨论中出现了共享编辑/多光标/实时文档(CRDT)诉求。当前系统的协作对象是:
|
|
10
|
+
|
|
11
|
+
- 追加式的编队聊天消息(`w2m_chat`);
|
|
12
|
+
- 追加式的项目房间事件(board/locks/reviews/notifications);
|
|
13
|
+
- 以 git 为权威的代码产物(patch/commit/merge)。
|
|
14
|
+
|
|
15
|
+
以上都是**追加式或 git 语义**,不是“多写者并发编辑同一份可变文档”。仓库同时保持 **零运行时依赖**、单文件手写 client bundle(无 bundler),以及“数据面不经 relay”的红线。
|
|
16
|
+
|
|
17
|
+
## 决策
|
|
18
|
+
|
|
19
|
+
**本周期(v2.x)不引入任何 CRDT 实现,不新增 CRDT 依赖,不写共享编辑代码。** 共享编辑若未来立项,必须先有新的 ADR 批准,并通过独立评审。
|
|
20
|
+
|
|
21
|
+
## 评估过的选项
|
|
22
|
+
|
|
23
|
+
| 选项 | 许可(本轮核实口径) | 体积/运行时 | 结论 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Yjs | MIT(npm 13.6.33) | 纯 JS,约 1MB+ 生态;需要 provider/transport | **未来首选**(若共享编辑立项,风险最低) |
|
|
26
|
+
| Automerge | MIT(npm latest 2.0.0-alpha.3) | Rust/WASM,数 MB,alpha 版本 | **备选**(需要 Git-like change DAG 证据时再评估) |
|
|
27
|
+
| json-joy | **AGPL-3.0-only**(多数包) | 2.2MB + 20+ 运行时依赖 | **明确排除**:与 MIT 公开插件分发不兼容,不依赖、不复制代码 |
|
|
28
|
+
|
|
29
|
+
## 不引入的理由
|
|
30
|
+
|
|
31
|
+
1. **问题形态不匹配**:当前协作是追加事件 + git 合并,CRDT 解决的是并发可变文档;引入它不会自动闭合 A3/A6 的任何缺口。
|
|
32
|
+
2. **零依赖是发布资产**:任何 CRDT 库都会进入持有 device token / operator key / fleet key 的进程信任域,扩大 CVE 面并破坏可复现的单文件打包。
|
|
33
|
+
3. **数据面红线**:共享编辑内容属于数据面,必须经用户 sink/git,不能经 relay;现有 relay 冻结,无法承载新的同步协议。
|
|
34
|
+
4. **权限与审计模型未定义**:多人同时编辑需要用户级身份、逐操作授权与审计,当前只有机器级身份。
|
|
35
|
+
5. **许可风险**:json-joy 为 AGPL,直接排除;Yjs/Automerge 需在立项时做人工法务复核。
|
|
36
|
+
|
|
37
|
+
## 触发条件(满足才可重新评估)
|
|
38
|
+
|
|
39
|
+
- 出现真实的“多人同时编辑同一份文档/文件”需求,而非代码补丁协作;
|
|
40
|
+
- 新的 ADR 获批,且明确数据面走 sink/git 或加密 P2P,不经 relay;
|
|
41
|
+
- 完成用户级身份、逐操作授权、审计与回滚设计;
|
|
42
|
+
- 选定单一实现(Yjs 优先),完成构建管线与许可复核,并接受依赖体积/供应链评审。
|
|
43
|
+
|
|
44
|
+
## 本周期替代方案
|
|
45
|
+
|
|
46
|
+
- 代码协作:git worktree + `git apply --3way` + CAS 更新目标分支(merge-engine,A4 CLI 已落地)。
|
|
47
|
+
- 产物同步:用户授权的 dir/git/ssh sink(A3)。
|
|
48
|
+
- 状态同步:项目房间事件 + 分页/截断信号(A6 截断已落地;持久化与客户端面板在后续切片)。
|
|
49
|
+
- 消息加密:聊天 AEAD v2 + AAD 会话/发送者绑定(A7)。
|
|
50
|
+
|
|
51
|
+
## 后果
|
|
52
|
+
|
|
53
|
+
- 不新增依赖,`package.json#dependencies` 保持为空;
|
|
54
|
+
- 不新增 `src/util/crdt.mjs` 之类的占位模块,避免制造“已有 CRDT”的读感;
|
|
55
|
+
- 未来若立项,按触发条件重新走 ADR,而不是在既有事件/合并路径上静默引入。
|
|
56
|
+
|
|
57
|
+
## 回滚/复审
|
|
58
|
+
|
|
59
|
+
纯文档决策,无运行时代码;复审时新增一份 ADR 取代本文件,并在 CHANGELOG 记录决策变化。
|
package/lib/client.js
CHANGED
|
@@ -889,6 +889,67 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
889
889
|
if (result.ok) setTokenState(result.body);
|
|
890
890
|
}, []);
|
|
891
891
|
React.useEffect(() => { loadToken(); }, [loadToken]);
|
|
892
|
+
const [sinkKind, setSinkKind] = React.useState('dir');
|
|
893
|
+
const [sinkRoot, setSinkRoot] = React.useState('');
|
|
894
|
+
const [sinkState, setSinkState] = React.useState(null);
|
|
895
|
+
const loadSink = React.useCallback(async () => {
|
|
896
|
+
const result = await call('/api/w2m/dataplane-sink', { method: 'GET' });
|
|
897
|
+
if (result.ok) setSinkState(result.body);
|
|
898
|
+
else if (result.routeMissing !== true) setError(result.error ?? 'sink status failed');
|
|
899
|
+
}, []);
|
|
900
|
+
React.useEffect(() => { loadSink(); }, [loadSink]);
|
|
901
|
+
const [projectRoomId, setProjectRoomId] = React.useState('');
|
|
902
|
+
const [projectEvents, setProjectEvents] = React.useState([]);
|
|
903
|
+
const [projectStreaming, setProjectStreaming] = React.useState(false);
|
|
904
|
+
const projectAbortRef = React.useRef(null);
|
|
905
|
+
const stopProjectStream = () => { projectAbortRef.current?.abort(); projectAbortRef.current = null; setProjectStreaming(false); };
|
|
906
|
+
React.useEffect(() => () => { projectAbortRef.current?.abort(); }, []);
|
|
907
|
+
const startProjectStream = async () => {
|
|
908
|
+
const projectId = projectRoomId.trim();
|
|
909
|
+
if (projectId === '') return;
|
|
910
|
+
stopProjectStream();
|
|
911
|
+
const controller = new AbortController();
|
|
912
|
+
projectAbortRef.current = controller;
|
|
913
|
+
setProjectEvents([]);
|
|
914
|
+
setProjectStreaming(true);
|
|
915
|
+
try {
|
|
916
|
+
const response = await fetch('/api/w2m/project-stream?project_id=' + encodeURIComponent(projectId) + '&since_seq=0', { credentials: 'same-origin', signal: controller.signal });
|
|
917
|
+
if (response.ok !== true || response.body === null) { setError('项目流不可用(HTTP ' + response.status + ')'); return; }
|
|
918
|
+
const reader = response.body.getReader();
|
|
919
|
+
const decoder = new TextDecoder();
|
|
920
|
+
let buffer = '';
|
|
921
|
+
for (;;) {
|
|
922
|
+
const { done, value } = await reader.read();
|
|
923
|
+
if (done) break;
|
|
924
|
+
buffer += decoder.decode(value, { stream: true });
|
|
925
|
+
let split = buffer.indexOf('\n\n');
|
|
926
|
+
while (split >= 0) {
|
|
927
|
+
const block = buffer.slice(0, split);
|
|
928
|
+
buffer = buffer.slice(split + 2);
|
|
929
|
+
const dataLine = block.split('\n').find((line) => line.startsWith('data: '));
|
|
930
|
+
if (dataLine !== undefined) {
|
|
931
|
+
try { setProjectEvents((prev) => [...prev.slice(-99), JSON.parse(dataLine.slice(6))]); } catch { /* ignore malformed frame */ }
|
|
932
|
+
}
|
|
933
|
+
split = buffer.indexOf('\n\n');
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (error?.name !== 'AbortError') setError('项目流中断');
|
|
938
|
+
} finally {
|
|
939
|
+
if (projectAbortRef.current === controller) { projectAbortRef.current = null; setProjectStreaming(false); }
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
const saveSink = async () => {
|
|
943
|
+
if (sinkRoot.trim() === '') return;
|
|
944
|
+
setBusy(true);
|
|
945
|
+
try {
|
|
946
|
+
const payload = sinkKind === 'ssh'
|
|
947
|
+
? { kind: 'ssh', host: sinkRoot.split(':')[0].trim(), user: (sinkRoot.split(':')[1] ?? '').trim(), dir: (sinkRoot.split(':')[2] ?? '').trim() }
|
|
948
|
+
: { kind: sinkKind, root: sinkRoot.trim() };
|
|
949
|
+
const result = await call('/api/w2m/dataplane-sink', { method: 'POST', body: JSON.stringify(payload) });
|
|
950
|
+
if (result.ok) { setSinkRoot(''); await loadSink(); } else setError(result.error ?? 'sink save failed');
|
|
951
|
+
} finally { setBusy(false); }
|
|
952
|
+
};
|
|
892
953
|
const saveToken = async () => {
|
|
893
954
|
if (tokenInput.trim() === '') return;
|
|
894
955
|
setBusy(true);
|
|
@@ -940,6 +1001,30 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
940
1001
|
]),
|
|
941
1002
|
h('div', { key: 'st', className: 'w2mGpw_adminSub' }, tokenState?.configured === true ? ('已配置 · ' + String(tokenState.source ?? '') + ' · ' + String(tokenState.fingerprint ?? '').slice(0, 12)) : '未配置 operator token(保存后写入 0600 文件,只显示指纹)'),
|
|
942
1003
|
]),
|
|
1004
|
+
h('div', { key: 'sink', className: 'w2mGpw_adminCard' }, [
|
|
1005
|
+
h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
|
|
1006
|
+
h('select', { key: 'k', className: 'w2mGpw_adminInput', value: sinkKind, onChange: (event) => setSinkKind(event.target.value) }, [
|
|
1007
|
+
h('option', { key: 'd', value: 'dir' }, '本地目录'),
|
|
1008
|
+
h('option', { key: 'g', value: 'lan-git' }, 'git 仓库'),
|
|
1009
|
+
h('option', { key: 's', value: 'ssh' }, 'ssh'),
|
|
1010
|
+
]),
|
|
1011
|
+
h('input', { key: 'p', className: 'w2mGpw_adminInput', value: sinkRoot, placeholder: sinkKind === 'ssh' ? 'host:user:绝对目录' : (sinkKind === 'lan-git' ? 'git 仓库绝对路径' : '产物目录绝对路径'), onChange: (event) => setSinkRoot(event.target.value) }),
|
|
1012
|
+
h('button', { key: 'a', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy, onClick: saveSink }, '授权并保存'),
|
|
1013
|
+
]),
|
|
1014
|
+
h('div', { key: 'st', className: 'w2mGpw_adminSub' }, sinkState?.configured === true
|
|
1015
|
+
? ('已授权 · ' + String(sinkState.kind ?? '') + ' · ' + String(sinkState.root ?? sinkState.host ?? '') + ' · 凭据不写入配置')
|
|
1016
|
+
: '未配置数据面 sink:产物只保留本地并标记 ARTIFACT_NOT_DELIVERED(工具参数不能完成授权)'),
|
|
1017
|
+
]),
|
|
1018
|
+
h('div', { key: 'projectroom', className: 'w2mGpw_adminCard' }, [
|
|
1019
|
+
h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
|
|
1020
|
+
h('input', { key: 'p', className: 'w2mGpw_adminInput', value: projectRoomId, placeholder: '项目 id(只读订阅项目事件)', onChange: (event) => setProjectRoomId(event.target.value) }),
|
|
1021
|
+
h('button', { key: 'start', type: 'button', className: 'w2mGpw_adminBtn', disabled: projectStreaming, onClick: startProjectStream }, '订阅'),
|
|
1022
|
+
h('button', { key: 'stop', type: 'button', className: 'w2mGpw_adminBtn', disabled: !projectStreaming, onClick: stopProjectStream }, '停止'),
|
|
1023
|
+
]),
|
|
1024
|
+
h('div', { key: 'ev', className: 'w2mGpw_adminSub' }, projectEvents.length === 0
|
|
1025
|
+
? (projectStreaming ? '已连接,等待事件…' : '未订阅(只读;截断时 relay 会先发 PROJECT_REPLAY_TRUNCATED notice)')
|
|
1026
|
+
: projectEvents.slice(-8).map((event) => String(event.seq ?? '?') + ' · ' + String(event.type ?? event.code ?? 'event')).join(' | ')),
|
|
1027
|
+
]),
|
|
943
1028
|
h('div', { key: 'toolbar', className: 'w2mGpw_adminCard' }, [
|
|
944
1029
|
h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
|
|
945
1030
|
h('input', { key: 'q', className: 'w2mGpw_adminInput', value: query, placeholder: '搜索路径', onChange: (event) => setQuery(event.target.value) }),
|
|
@@ -1015,12 +1100,30 @@ function clearSearchMarks() {
|
|
|
1015
1100
|
if (parent.normalize) parent.normalize();
|
|
1016
1101
|
}
|
|
1017
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Search rules (v2.0.5 UI): a pure number up to 100 is not a useful query in a conversation full of
|
|
1105
|
+
* ids/ports/timestamps, so it is refused outright (no marks, no scroll, no count). Only the viewport
|
|
1106
|
+
* is highlighted, at most six matches per screen; scrolling re-runs the pass for the new viewport.
|
|
1107
|
+
*/
|
|
1108
|
+
const SEARCH_MAX_MARKS = 6;
|
|
1109
|
+
function searchQueryAllowed(query) {
|
|
1110
|
+
const text = String(query ?? '').trim();
|
|
1111
|
+
if (text === '') return false;
|
|
1112
|
+
if (/^\d+$/.test(text)) return Number(text) > 100;
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
function viewportRect() {
|
|
1116
|
+
const width = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
|
1117
|
+
const height = typeof window !== 'undefined' ? window.innerHeight : 768;
|
|
1118
|
+
return { left: 0, top: 0, right: width, bottom: height };
|
|
1119
|
+
}
|
|
1018
1120
|
function paintSearchMarks(query) {
|
|
1019
1121
|
clearSearchMarks();
|
|
1020
|
-
if (typeof document === 'undefined' || query === '') return 0;
|
|
1122
|
+
if (typeof document === 'undefined' || query === '' || !searchQueryAllowed(query)) return 0;
|
|
1021
1123
|
const needle = query.toLowerCase();
|
|
1124
|
+
const view = viewportRect();
|
|
1022
1125
|
let total = 0;
|
|
1023
|
-
for (const root of searchRoots()) {
|
|
1126
|
+
outer: for (const root of searchRoots()) {
|
|
1024
1127
|
let walker;
|
|
1025
1128
|
try {
|
|
1026
1129
|
walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
@@ -1039,21 +1142,28 @@ function paintSearchMarks(query) {
|
|
|
1039
1142
|
let node = walker.nextNode();
|
|
1040
1143
|
while (node !== null) { nodes.push(node); node = walker.nextNode(); }
|
|
1041
1144
|
for (const textNode of nodes) {
|
|
1145
|
+
// Cheap pre-filter: a text node whose element is entirely outside the viewport cannot paint.
|
|
1146
|
+
const host = textNode.parentElement;
|
|
1147
|
+
const hostRect = host !== null && typeof host.getBoundingClientRect === 'function' ? host.getBoundingClientRect() : null;
|
|
1148
|
+
if (hostRect !== null && (hostRect.bottom < view.top || hostRect.top > view.bottom)) continue;
|
|
1042
1149
|
const text = textNode.nodeValue ?? '';
|
|
1043
1150
|
const lower = text.toLowerCase();
|
|
1044
1151
|
const hits = [];
|
|
1045
1152
|
let index = lower.indexOf(needle);
|
|
1046
1153
|
while (index >= 0) { hits.push(index); index = lower.indexOf(needle, index + query.length); }
|
|
1047
|
-
for (
|
|
1154
|
+
for (const hit of hits) {
|
|
1048
1155
|
try {
|
|
1049
1156
|
const range = document.createRange();
|
|
1050
|
-
range.setStart(textNode,
|
|
1051
|
-
range.setEnd(textNode,
|
|
1157
|
+
range.setStart(textNode, hit);
|
|
1158
|
+
range.setEnd(textNode, hit + query.length);
|
|
1159
|
+
const rect = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : null;
|
|
1160
|
+
if (rect !== null && (rect.bottom < view.top || rect.top > view.bottom || rect.right < view.left || rect.left > view.right)) continue;
|
|
1052
1161
|
const mark = document.createElement('mark');
|
|
1053
1162
|
mark.className = 'w2mSearchMark';
|
|
1054
1163
|
mark.setAttribute('style', 'background-color:rgba(255,235,59,.85);color:rgba(11,122,51,1)');
|
|
1055
1164
|
range.surroundContents(mark);
|
|
1056
1165
|
total += 1;
|
|
1166
|
+
if (total >= SEARCH_MAX_MARKS) break outer;
|
|
1057
1167
|
} catch { /* a range that crosses elements cannot be wrapped; skip it */ }
|
|
1058
1168
|
}
|
|
1059
1169
|
}
|
|
@@ -1142,23 +1252,68 @@ function clickLoadEarlier() {
|
|
|
1142
1252
|
try { el.click(); return true; } catch { return false; }
|
|
1143
1253
|
}
|
|
1144
1254
|
const waitFor = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
1255
|
+
/**
|
|
1256
|
+
* The history loader shows "加载中..." while a page is in flight. Clicking "加载更早" again in that
|
|
1257
|
+
* window is what made the top button look like it was being interrupted/blocked: every loop pressed
|
|
1258
|
+
* the row while the previous press was still loading, so nothing advanced.
|
|
1259
|
+
*/
|
|
1260
|
+
function isLoadingEarlier() {
|
|
1261
|
+
if (typeof document === 'undefined') return false;
|
|
1262
|
+
for (const root of searchRoots()) {
|
|
1263
|
+
let nodes = [];
|
|
1264
|
+
try { nodes = root.querySelectorAll ? [...root.querySelectorAll('button, [role="button"], a, span, div')] : []; } catch { continue; }
|
|
1265
|
+
for (const el of nodes) {
|
|
1266
|
+
const text = String(el.textContent ?? '').trim();
|
|
1267
|
+
if (text === '' || text.length > 24) continue;
|
|
1268
|
+
if (/^加载中|^loading|加载中\.\.\./i.test(text)) return true;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
/** Wait for an in-flight "load earlier" page to finish (or time out), then continue. */
|
|
1274
|
+
async function waitForLoadSettled(timeoutMs = 6000) {
|
|
1275
|
+
let waited = 0;
|
|
1276
|
+
while (waited < timeoutMs) {
|
|
1277
|
+
if (!isLoadingEarlier()) return true;
|
|
1278
|
+
await waitFor(100);
|
|
1279
|
+
waited += 100;
|
|
1280
|
+
}
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1145
1283
|
async function scrollChatTurns() {
|
|
1146
1284
|
const surface = findScrollSurface();
|
|
1147
1285
|
const viewport = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
1148
1286
|
const step = Math.round(Math.max(240, (surface !== null ? surface.clientHeight : viewport) * 1.8));
|
|
1149
|
-
if (surface !== null && surface.scrollTop <= 8
|
|
1287
|
+
if (surface !== null && surface.scrollTop <= 8) {
|
|
1288
|
+
if (isLoadingEarlier()) await waitForLoadSettled();
|
|
1289
|
+
else if (clickLoadEarlier()) await waitForLoadSettled();
|
|
1290
|
+
}
|
|
1150
1291
|
const after = surface ?? findScrollSurface();
|
|
1151
1292
|
if (after !== null) after.scrollTop = Math.max(0, after.scrollTop - step);
|
|
1152
1293
|
else if (typeof window !== 'undefined') window.scrollBy({ top: -step, behavior: 'smooth' });
|
|
1153
1294
|
}
|
|
1154
1295
|
async function scrollChatTop() {
|
|
1155
|
-
// Load history until the "load earlier" row is gone,
|
|
1156
|
-
for
|
|
1296
|
+
// Load history until the "load earlier" row is gone, but never press it while a page is loading:
|
|
1297
|
+
// each press waits for the loader to settle and for the surface to actually grow, and two pages
|
|
1298
|
+
// that add nothing end the loop instead of spinning. Only then is the surface pinned to the top.
|
|
1299
|
+
let stagnant = 0;
|
|
1300
|
+
let lastHeight = -1;
|
|
1301
|
+
for (let i = 0; i < 40; i += 1) {
|
|
1302
|
+
if (isLoadingEarlier()) {
|
|
1303
|
+
await waitForLoadSettled();
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1157
1306
|
const surface = findScrollSurface();
|
|
1158
1307
|
if (surface !== null) surface.scrollTop = 0;
|
|
1308
|
+
const before = surface !== null ? surface.scrollHeight : -1;
|
|
1159
1309
|
if (!clickLoadEarlier()) break;
|
|
1160
|
-
await
|
|
1310
|
+
await waitForLoadSettled();
|
|
1311
|
+
const after = findScrollSurface() !== null ? findScrollSurface().scrollHeight : -1;
|
|
1312
|
+
if (after <= before || after === lastHeight) stagnant += 1; else stagnant = 0;
|
|
1313
|
+
lastHeight = after;
|
|
1314
|
+
if (stagnant >= 2) break;
|
|
1161
1315
|
}
|
|
1316
|
+
await waitForLoadSettled();
|
|
1162
1317
|
for (let i = 0; i < 8; i += 1) {
|
|
1163
1318
|
const surface = findScrollSurface();
|
|
1164
1319
|
if (surface !== null) surface.scrollTop = 0;
|
|
@@ -1249,18 +1404,56 @@ function ConversationSearch() {
|
|
|
1249
1404
|
const [cursor, setCursor] = React.useState(-1);
|
|
1250
1405
|
const inputRef = React.useRef(null);
|
|
1251
1406
|
React.useEffect(() => { if (open && inputRef.current && inputRef.current.focus) inputRef.current.focus(); }, [open]);
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1407
|
+
// v2.0.5: wait until 3s pass without new input before walking the conversation, so typing does
|
|
1408
|
+
// not trigger a full-tree highlight pass on every keystroke (the reported input jank).
|
|
1409
|
+
// Rules: a pure number <= 100 is refused; only the viewport is highlighted (max 6) and scrolling
|
|
1410
|
+
// repaints the current viewport without yanking the scroll position.
|
|
1411
|
+
const appliedRef = React.useRef('');
|
|
1412
|
+
const paintRef = React.useRef(() => {});
|
|
1413
|
+
paintRef.current = (focus = false) => {
|
|
1414
|
+
const total = paintSearchMarks(appliedRef.current);
|
|
1255
1415
|
setCount(total);
|
|
1256
1416
|
setCursor(total > 0 ? 0 : -1);
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1417
|
+
if (focus === true) {
|
|
1418
|
+
const first = document.querySelector('mark.w2mSearchMark');
|
|
1419
|
+
if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
React.useEffect(() => {
|
|
1423
|
+
if (!open) { appliedRef.current = ''; clearSearchMarks(); setCount(0); setCursor(-1); }
|
|
1424
|
+
}, [open]);
|
|
1425
|
+
React.useEffect(() => {
|
|
1426
|
+
if (!open) return undefined;
|
|
1427
|
+
if (!searchQueryAllowed(query)) {
|
|
1428
|
+
// Refused query: no marks, no count, no scroll — the input itself stays usable.
|
|
1429
|
+
appliedRef.current = '';
|
|
1430
|
+
clearSearchMarks();
|
|
1431
|
+
setCount(0);
|
|
1432
|
+
setCursor(-1);
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
const timer = setTimeout(() => { appliedRef.current = query; paintRef.current(true); }, 3000);
|
|
1436
|
+
return () => clearTimeout(timer);
|
|
1260
1437
|
}, [open, query]);
|
|
1438
|
+
React.useEffect(() => {
|
|
1439
|
+
if (!open) return undefined;
|
|
1440
|
+
let timer = null;
|
|
1441
|
+
const onScroll = () => {
|
|
1442
|
+
if (appliedRef.current === '') return;
|
|
1443
|
+
if (timer !== null) clearTimeout(timer);
|
|
1444
|
+
timer = setTimeout(() => { paintRef.current(false); }, 150);
|
|
1445
|
+
};
|
|
1446
|
+
window.addEventListener('scroll', onScroll, true);
|
|
1447
|
+
return () => {
|
|
1448
|
+
if (timer !== null) clearTimeout(timer);
|
|
1449
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
1450
|
+
};
|
|
1451
|
+
}, [open]);
|
|
1261
1452
|
const step = (backwards) => {
|
|
1262
|
-
|
|
1263
|
-
|
|
1453
|
+
if (appliedRef.current === '') return;
|
|
1454
|
+
let marks = [...document.querySelectorAll('mark.w2mSearchMark')];
|
|
1455
|
+
if (marks.length === 0) { paintRef.current(false); marks = [...document.querySelectorAll('mark.w2mSearchMark')]; }
|
|
1456
|
+
if (marks.length === 0) return;
|
|
1264
1457
|
const next = (cursor + (backwards ? -1 : 1) + marks.length) % marks.length;
|
|
1265
1458
|
setCursor(next);
|
|
1266
1459
|
if (marks[next] && marks[next].scrollIntoView) marks[next].scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
@@ -1286,11 +1479,14 @@ function ConversationSearch() {
|
|
|
1286
1479
|
placeholder: '搜索本对话全部内容',
|
|
1287
1480
|
onChange: (event) => setQuery(event.target.value),
|
|
1288
1481
|
onKeyDown: (event) => {
|
|
1289
|
-
if (event.key === 'Enter') {
|
|
1482
|
+
if (event.key === 'Enter') {
|
|
1483
|
+
event.preventDefault();
|
|
1484
|
+
if (searchQueryAllowed(query)) { appliedRef.current = query; paintRef.current(true); step(event.shiftKey); }
|
|
1485
|
+
}
|
|
1290
1486
|
if (event.key === 'Escape') { setOpen(false); setQuery(''); }
|
|
1291
1487
|
},
|
|
1292
1488
|
}),
|
|
1293
|
-
h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (query
|
|
1489
|
+
h('span', { key: 'count', className: 'w2mSearchCount' }, count === 0 ? (searchQueryAllowed(query) ? '0' : '') : whenCount(count, cursor)),
|
|
1294
1490
|
h('button', { key: 'prev', type: 'button', className: 'w2mSearchBtn', title: '上一个', onClick: () => step(true) }, '↑'),
|
|
1295
1491
|
h('button', { key: 'next', type: 'button', className: 'w2mSearchBtn', title: '下一个', onClick: () => step(false) }, '↓'),
|
|
1296
1492
|
h('button', { key: 'close', type: 'button', className: 'w2mSearchBtn', title: '关闭搜索', onClick: () => { setOpen(false); setQuery(''); clearSearchMarks(); } }, '×'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|