@twinsearth/w2m-dsh-plugin 2.1.11 → 2.2.1
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 +18 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.2.0/ACCOUNT-SWITCH-POLICY-2200.json +21 -0
- package/docs/acceptance/2.2.1/RELEASE-2210.json +31 -0
- package/lib/client.js +442 -6
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/plugin/fleet-binding.mjs +22 -0
- package/src/plugin/fleet-config-route.mjs +130 -0
- package/src/plugin/fleet-config.mjs +183 -0
- package/src/plugin/fleet-home.mjs +263 -0
- package/src/plugin/tools.mjs +263 -26
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "2.1
|
|
8
|
+
"version": "2.2.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "2.1
|
|
15
|
+
"version": "2.2.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.1
|
|
29
|
+
"version": "2.2.1"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "2.1
|
|
4
|
+
"version": "2.2.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.2.1] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
- Fleet identity is now per-account **home**-first. Each machine records the fleet key it actually joined under `fleet-home.json` beside `device.json`; switching back to an account returns to that account’s previous fleet instead of deriving a new one. This is the “whoever leaves returns by themselves” rule: a first appearance of account B may join B’s account fleet, but an existing home for A is never rewritten by B, and no other machine is moved.
|
|
13
|
+
- `fleetKeyPolicy` is now `home` by default. `account` keeps the v2.2.0 always-follow-the-account behaviour as an opt-in; `config` remains the explicit operator pin. An invalid policy is a startup error naming the setting, not a silent default.
|
|
14
|
+
- A corrupt or unreadable `fleet-home.json` is reported and blocks `home` policy instead of silently creating a new fleet; `account` policy may continue without the history. The file is written atomically with mode 0600 (ACL protection on Windows).
|
|
15
|
+
- Upgrading a paired 2.2.0 machine with no home to 2.2.1 keeps it in its current fleet instead of deriving the account key; an actual account switch is still allowed to make its first join, and that first join is remembered.
|
|
16
|
+
- leet-home.json` now also records the last account code, so a restart can tell `same account, no home yet` (stay put) from a real account switch (first join allowed).
|
|
17
|
+
- Composer target control: selecting a machine now writes a visible `@machine` token into the draft after the host accepts the selection. Switching targets replaces the previous token, and selecting All machines removes it; names containing whitespace use `@"machine name"`. A refused host write reopens the menu with the host’s reason; an editor rejection is shown instead of leaving a stale token.
|
|
18
|
+
|
|
19
|
+
- Settings page: new `编队绑定` section for machines that must return to an old fleet. It previews the `fleet_id` derived from a pasted key, requires explicit confirmation, then writes an independent `fleet-config.json` (0600) with a timestamped backup and rollback; `cordis.patch.yml` is never modified. The file takes precedence over the profile's `fleetKey`/`fleetKeyPolicy` at startup, and the raw key is never returned by the route.
|
|
20
|
+
- Tests: fleet-config storage/backup/corruption and the preview/save route, including an explicit assertion that the profile patch is untouched.
|
|
21
|
+
## [2.2.0] - 2026-11-01
|
|
22
|
+
|
|
23
|
+
- Fleet binding policy: `fleetKeyPolicy: account` is the new default. A configured `fleetKey` is a fallback while the signed-in account is authoritative, so switching DeepSeek accounts moves the machine without editing every profile. Set `fleetKeyPolicy: config` to keep the old explicit-pin behaviour.
|
|
24
|
+
- `w2m_status` now reads `device.json` through the `DSH_HOME/xclient` fallback when `stateDir` is unset; it no longer reports `paired: false` / `FLEET_UNPAIRED` on a working machine. The reported fleet is compared against the key the stack actually resolved.
|
|
25
|
+
- Includes the v2.1.10 `spawnSync.status` capture fix and the v2.1.11 profile-refresh fix for a switched account.
|
|
26
|
+
- Breaking: an explicit `fleetKey` no longer pins the fleet by default; add `fleetKeyPolicy: config` to opt out.
|
|
27
|
+
|
|
10
28
|
## [2.1.11] - 2026-11-01
|
|
11
29
|
|
|
12
30
|
- Account switch: refresh the account profile when `getDeviceIdentity()` has no userId. After a DeepSeek account switch the host clears its cached profile, so the identity read returns no userId until someone calls `getProfile()`; the plugin then derived no new fleet key and kept the previous account's token. It now fetches the profile once, derives the new key, detects the fleet mismatch and re-pairs. This is the remaining half of the v2.1.10 account-switch fix.
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ w2m-mcp --help
|
|
|
92
92
|
# 复制进你自己的 codex/mcp-servers.toml
|
|
93
93
|
[mcp_servers.w2m]
|
|
94
94
|
command = "npx"
|
|
95
|
-
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1
|
|
95
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.1", "w2m-mcp"]
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.0",
|
|
3
|
+
"case": "ACCOUNT-SWITCH-POLICY-2200",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"1bef9935-8c13-4dd9-9043-4e025320723f"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T20:06:14.655Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "account-switch-policy-2200-1793477174656",
|
|
11
|
+
"requirement": "A DeepSeek account switch must move a machine without per-machine fleetKey edits, while w2m_status must use the same device identity fallback as the other tools",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"policy": "fleetKeyPolicy defaults account; configured key is fallback; config opts out",
|
|
16
|
+
"status": "w2m_status reads DSH_HOME/xclient device.json when stateDir is unset",
|
|
17
|
+
"select": "selectFleetKey account/config/no-account tests pass",
|
|
18
|
+
"account": "profile refresh and fleet mismatch re-pair from 2.1.11"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.1",
|
|
3
|
+
"case": "RELEASE-2210",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-11-01T03:05:23.452Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2210-1793511923452",
|
|
11
|
+
"requirement": "v2.2.1: fleet membership returns to the per-account home after an account switch, a first appearance of a new account may join its fleet without moving other machines, and the settings page can pin an old fleet key without editing cordis.patch.yml",
|
|
12
|
+
"commands": [
|
|
13
|
+
["node", "--test", "--test-force-exit", "test/fleet-home.test.mjs", "test/fleet-binding.test.mjs"],
|
|
14
|
+
["node", "--test", "--test-force-exit", "test/fleet-config.test.mjs", "test/fleet-config-route.test.mjs"],
|
|
15
|
+
["node", "--test", "--test-force-exit", "test/tools.test.mjs"],
|
|
16
|
+
["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
|
|
17
|
+
["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"]
|
|
18
|
+
],
|
|
19
|
+
"result": {
|
|
20
|
+
"outcome": "ok",
|
|
21
|
+
"assertions": {
|
|
22
|
+
"fleet_home": "test/fleet-home.test.mjs 6/6: A first join, B first join, B->A returns A's home, account policy does not overwrite home, config pin wins, corrupt home blocks a new fleet, upgrade with an existing token stays put",
|
|
23
|
+
"fleet_config": "test/fleet-config.test.mjs 4/4 and test/fleet-config-route.test.mjs 3/3: preview does not write, mismatch is shown before saving, save writes fleet-config.json with a backup, a wrong expected fleet_id is refused, and cordis.patch.yml is untouched",
|
|
24
|
+
"tools": "test/tools.test.mjs 141/141, including w2m_status reading the settings-page pin without printing the key",
|
|
25
|
+
"bundle": "test/client-bundle.test.mjs 34/34; the 编队绑定 settings section is registered and target @mention tests remain green",
|
|
26
|
+
"loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.1 tarball in the real DSH Loader",
|
|
27
|
+
"lint": "eslint 0 errors; layer-check clean",
|
|
28
|
+
"scope": "local code, tests and packed artifact only; the release tag is pushed separately; no machine profile was edited by the tests"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -103,6 +103,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
103
103
|
costAll: '本次会话的派发会送到所有在线机器',
|
|
104
104
|
costOne: '本次会话的派发会送到 {name}',
|
|
105
105
|
unconfirmed: '(未确认在线)',
|
|
106
|
+
mentionFailed: '目标已切换,但输入框未能同步 @ 标记;请稍后重新选择该机器',
|
|
106
107
|
targetLabel: '目标机器:{name}',
|
|
107
108
|
unreachable: '无法连接插件',
|
|
108
109
|
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
@@ -176,6 +177,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
176
177
|
costAll: 'Work from this session goes to every online machine',
|
|
177
178
|
costOne: 'Work from this session goes to {name}',
|
|
178
179
|
unconfirmed: ' (not confirmed online)',
|
|
180
|
+
mentionFailed: 'the target changed, but the composer could not add the @ handle; pick the machine again in a moment',
|
|
179
181
|
targetLabel: 'target machine: {name}',
|
|
180
182
|
unreachable: 'the plugin could not be reached',
|
|
181
183
|
routeMissing: 'this host does not serve the W2M target route',
|
|
@@ -1748,7 +1750,336 @@ function UpdateSettingsSection() {
|
|
|
1748
1750
|
]);
|
|
1749
1751
|
}
|
|
1750
1752
|
|
|
1751
|
-
|
|
1753
|
+
/**
|
|
1754
|
+
* The `@machine` token the target control writes into the composer.
|
|
1755
|
+
*
|
|
1756
|
+
* This is a *visible handle*, not the dispatch decision: the host-side selection store remains
|
|
1757
|
+
* the source of truth, and `w2m_run` reads it from the host process. The token exists so the
|
|
1758
|
+
* operator can see which machine the next turn is aimed at after the menu closes.
|
|
1759
|
+
*
|
|
1760
|
+
* A name that is not a single safe word is quoted, because DSH's own `@` trigger runs to the
|
|
1761
|
+
* next whitespace. A name containing a quote cannot be represented safely and yields null:
|
|
1762
|
+
* the selection still works, it simply gets no token.
|
|
1763
|
+
*
|
|
1764
|
+
* @param {string|null|undefined} name The machine's display name.
|
|
1765
|
+
* @returns {string|null} `@name`, `@"name with spaces"`, or null.
|
|
1766
|
+
*/
|
|
1767
|
+
function targetTokenForName(name) {
|
|
1768
|
+
if (typeof name !== 'string') return null;
|
|
1769
|
+
const trimmed = name.trim();
|
|
1770
|
+
if (trimmed === '' || /[\r\n"]/.test(trimmed)) return null;
|
|
1771
|
+
return /^[A-Za-z0-9_-]+$/.test(trimmed) ? `@${trimmed}` : `@"${trimmed}"`;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/** Occurrence offsets are clipboard offsets; sort them so both maps walk one order. */
|
|
1775
|
+
function sortedOccurrences(occurrences) {
|
|
1776
|
+
return (Array.isArray(occurrences) ? occurrences : [])
|
|
1777
|
+
.filter((entry) => entry !== null && typeof entry === 'object'
|
|
1778
|
+
&& Number.isInteger(entry.offset) && Number.isInteger(entry.length) && entry.length > 0)
|
|
1779
|
+
.slice()
|
|
1780
|
+
.sort((left, right) => left.offset - right.offset);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Clipboard offset → editor (detect) offset.
|
|
1785
|
+
*
|
|
1786
|
+
* A reference chip is one U+FFFC in the editor but its whole clipboard text in the draft, so
|
|
1787
|
+
* the two coordinate spaces diverge after every chip. Positions inside a chip are clamped to
|
|
1788
|
+
* its editor position; a token boundary never lands there.
|
|
1789
|
+
*/
|
|
1790
|
+
function clipboardToDetect(offset, occurrences) {
|
|
1791
|
+
let shift = 0;
|
|
1792
|
+
for (const occurrence of sortedOccurrences(occurrences)) {
|
|
1793
|
+
if (offset <= occurrence.offset) break;
|
|
1794
|
+
if (offset < occurrence.offset + occurrence.length) return occurrence.offset - shift + 1;
|
|
1795
|
+
shift += occurrence.length - 1;
|
|
1796
|
+
}
|
|
1797
|
+
return offset - shift;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/** Editor (detect) offset → clipboard offset; the inverse of {@link clipboardToDetect}. */
|
|
1801
|
+
function detectToClipboard(offset, occurrences) {
|
|
1802
|
+
let shift = 0;
|
|
1803
|
+
for (const occurrence of sortedOccurrences(occurrences)) {
|
|
1804
|
+
const start = occurrence.offset - shift;
|
|
1805
|
+
if (offset <= start) break;
|
|
1806
|
+
if (offset < start + 1) return occurrence.offset;
|
|
1807
|
+
shift += occurrence.length - 1;
|
|
1808
|
+
}
|
|
1809
|
+
return offset + shift;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Find a token in the draft, requiring a word boundary on both sides. */
|
|
1813
|
+
function targetTokenSpan(draft, token) {
|
|
1814
|
+
if (typeof draft !== 'string' || typeof token !== 'string' || token === '') return null;
|
|
1815
|
+
let from = 0;
|
|
1816
|
+
while (from <= draft.length) {
|
|
1817
|
+
const start = draft.indexOf(token, from);
|
|
1818
|
+
if (start < 0) return null;
|
|
1819
|
+
const before = start === 0 ? '' : draft.charAt(start - 1);
|
|
1820
|
+
const after = draft.charAt(start + token.length);
|
|
1821
|
+
const beforeOk = start === 0 || /\s/.test(before);
|
|
1822
|
+
const afterOk = after === '' || /\s/.test(after) || /[.,;:!?,。;:!?、]/.test(after);
|
|
1823
|
+
if (beforeOk && afterOk) return { start, end: start + token.length };
|
|
1824
|
+
from = start + 1;
|
|
1825
|
+
}
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* The editor span that removes one token without leaving a double word gap.
|
|
1831
|
+
*
|
|
1832
|
+
* Letting a token go and leaving its separating space turns "@alpha run" into " run", which is
|
|
1833
|
+
* visibly not the draft the operator typed. Only one ASCII space is eaten, and a newline is
|
|
1834
|
+
* never eaten: the plugin owns the token, not the paragraph around it.
|
|
1835
|
+
*/
|
|
1836
|
+
function targetTokenRemoval(draft, span, occurrences) {
|
|
1837
|
+
let clipboardEnd = span.end;
|
|
1838
|
+
if (draft.charAt(clipboardEnd) === ' ') clipboardEnd += 1;
|
|
1839
|
+
return {
|
|
1840
|
+
start: clipboardToDetect(span.start, occurrences),
|
|
1841
|
+
end: clipboardToDetect(clipboardEnd, occurrences),
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Plan the one editor edit that keeps the visible token in step with a target change.
|
|
1847
|
+
*
|
|
1848
|
+
* Rules (all deliberately conservative):
|
|
1849
|
+
* · switching A → B replaces the old `@A` instead of leaving two targets in the draft;
|
|
1850
|
+
* · the new token is only inserted when the host accepted the selection;
|
|
1851
|
+
* · clearing the selection removes only the token this plugin inserted;
|
|
1852
|
+
* · an existing `@B` is never duplicated;
|
|
1853
|
+
* · a name that cannot be represented as a token is left alone rather than guessed.
|
|
1854
|
+
*
|
|
1855
|
+
* @returns {{kind: 'none'|'present'|'remove'|'replace'|'insert', text?: string,
|
|
1856
|
+
* start?: number, end?: number, reason?: string}}
|
|
1857
|
+
*/
|
|
1858
|
+
function planTargetMention(input = {}) {
|
|
1859
|
+
const draft = typeof input.draft === 'string' ? input.draft : '';
|
|
1860
|
+
const occurrences = sortedOccurrences(input.occurrences);
|
|
1861
|
+
const caret = input.caret !== null && typeof input.caret === 'object' ? input.caret : {};
|
|
1862
|
+
const nextToken = targetTokenForName(input.nextName);
|
|
1863
|
+
const previousToken = targetTokenForName(input.previousName);
|
|
1864
|
+
const previousSpan = previousToken === null ? null : targetTokenSpan(draft, previousToken);
|
|
1865
|
+
|
|
1866
|
+
if (nextToken === null) {
|
|
1867
|
+
if (previousSpan === null) return { kind: previousToken === null ? 'none' : 'present', reason: 'no-token' };
|
|
1868
|
+
return {
|
|
1869
|
+
kind: 'remove',
|
|
1870
|
+
...targetTokenRemoval(draft, previousSpan, occurrences),
|
|
1871
|
+
draftRev: Number.isInteger(caret.draftRev) ? caret.draftRev : 0,
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const nextSpan = targetTokenSpan(draft, nextToken);
|
|
1876
|
+
if (nextSpan !== null) {
|
|
1877
|
+
if (previousSpan !== null && previousToken !== nextToken) {
|
|
1878
|
+
return {
|
|
1879
|
+
kind: 'remove',
|
|
1880
|
+
...targetTokenRemoval(draft, previousSpan, occurrences),
|
|
1881
|
+
draftRev: Number.isInteger(caret.draftRev) ? caret.draftRev : 0,
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
return { kind: 'present' };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (previousSpan !== null) {
|
|
1888
|
+
return {
|
|
1889
|
+
kind: 'replace',
|
|
1890
|
+
text: nextToken,
|
|
1891
|
+
start: clipboardToDetect(previousSpan.start, occurrences),
|
|
1892
|
+
end: clipboardToDetect(previousSpan.end, occurrences),
|
|
1893
|
+
draftRev: Number.isInteger(caret.draftRev) ? caret.draftRev : 0,
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const start = Number.isInteger(caret.start) ? caret.start : 0;
|
|
1898
|
+
const end = Number.isInteger(caret.end) ? caret.end : start;
|
|
1899
|
+
// Inserting at a non-collapsed selection must not consume what the operator selected:
|
|
1900
|
+
// the selection is where their caret is, not text this control owns. Anchor at its end.
|
|
1901
|
+
const at = end;
|
|
1902
|
+
const clipboardAt = detectToClipboard(at, occurrences);
|
|
1903
|
+
const lead = clipboardAt > 0 && !/\s/.test(draft.charAt(clipboardAt - 1)) ? ' ' : '';
|
|
1904
|
+
const tail = /\s/.test(draft.charAt(clipboardAt)) ? '' : ' ';
|
|
1905
|
+
return {
|
|
1906
|
+
kind: 'insert',
|
|
1907
|
+
text: lead + nextToken + tail,
|
|
1908
|
+
start: at,
|
|
1909
|
+
end: at,
|
|
1910
|
+
draftRev: Number.isInteger(caret.draftRev) ? caret.draftRev : 0,
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/** Put the caret back in the composer after the menu closes; best effort, never fatal. */
|
|
1915
|
+
function focusComposer() {
|
|
1916
|
+
if (typeof document === 'undefined') return;
|
|
1917
|
+
try {
|
|
1918
|
+
const node = document.querySelector('[data-composer-input]');
|
|
1919
|
+
if (node !== null && typeof node.focus === 'function') node.focus({ preventScroll: true });
|
|
1920
|
+
} catch { /* a detached or re-rendered editor is not an error */ }
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** A composition without the input seat keeps the control working, minus the token edit. */
|
|
1924
|
+
function useNoComposerInput() {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** Stable selector: the whole input snapshot, never a fresh derived object. */
|
|
1929
|
+
function selectComposerInput(snapshot) {
|
|
1930
|
+
return snapshot;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* v2.2.1: the settings-page fleet pin.
|
|
1935
|
+
*
|
|
1936
|
+
* The operator pastes an old fleet key, sees the fleet id it derives, compares that with the
|
|
1937
|
+
* expected id, and only then saves. The host route writes an independent `fleet-config.json`
|
|
1938
|
+
* with a backup; this component never edits `cordis.patch.yml` and never displays the key back.
|
|
1939
|
+
*/
|
|
1940
|
+
function FleetConfigSection() {
|
|
1941
|
+
const [state, setState] = React.useState(null);
|
|
1942
|
+
const [keyInput, setKeyInput] = React.useState('');
|
|
1943
|
+
const [expectedId, setExpectedId] = React.useState('');
|
|
1944
|
+
const [preview, setPreview] = React.useState(null);
|
|
1945
|
+
const [confirmed, setConfirmed] = React.useState(false);
|
|
1946
|
+
const [error, setError] = React.useState(null);
|
|
1947
|
+
const [busy, setBusy] = React.useState(false);
|
|
1948
|
+
const [saved, setSaved] = React.useState(null);
|
|
1949
|
+
|
|
1950
|
+
const call = async (init) => {
|
|
1951
|
+
try {
|
|
1952
|
+
const response = await fetch('/api/w2m/fleet-config', {
|
|
1953
|
+
credentials: 'same-origin',
|
|
1954
|
+
headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
1955
|
+
...init,
|
|
1956
|
+
});
|
|
1957
|
+
let body = null;
|
|
1958
|
+
try { body = await response.json(); } catch { body = null; }
|
|
1959
|
+
if (response.status === 404) return { ok: false, routeMissing: true, error: 'this host does not serve the fleet-config route' };
|
|
1960
|
+
if (response.ok !== true || body?.ok === false) {
|
|
1961
|
+
return { ok: false, error: body?.error ?? body?.code ?? `HTTP ${response.status}`, body };
|
|
1962
|
+
}
|
|
1963
|
+
return { ok: true, body };
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
const load = React.useCallback(async () => {
|
|
1970
|
+
const result = await call({ method: 'GET' });
|
|
1971
|
+
if (result.ok) setState(result.body);
|
|
1972
|
+
else setError(result.routeMissing ? '当前 DSH 没有编队绑定路由(插件版本过旧或未挂载)' : (result.error ?? '读取失败'));
|
|
1973
|
+
}, []);
|
|
1974
|
+
React.useEffect(() => { load(); }, [load]);
|
|
1975
|
+
|
|
1976
|
+
const previewKey = async () => {
|
|
1977
|
+
const key = keyInput.trim();
|
|
1978
|
+
if (key === '') return;
|
|
1979
|
+
setBusy(true);
|
|
1980
|
+
setError(null);
|
|
1981
|
+
setSaved(null);
|
|
1982
|
+
setConfirmed(false);
|
|
1983
|
+
try {
|
|
1984
|
+
const result = await call({ method: 'POST', body: JSON.stringify({ action: 'preview', fleet_key: key, expected_fleet_id: expectedId.trim() }) });
|
|
1985
|
+
if (!result.ok) { setPreview(null); setError(result.error ?? '无法计算 fleet_id'); return; }
|
|
1986
|
+
setPreview(result.body);
|
|
1987
|
+
} finally { setBusy(false); }
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
const save = async () => {
|
|
1991
|
+
const key = keyInput.trim();
|
|
1992
|
+
if (key === '' || preview === null || confirmed !== true) return;
|
|
1993
|
+
setBusy(true);
|
|
1994
|
+
setError(null);
|
|
1995
|
+
try {
|
|
1996
|
+
const result = await call({
|
|
1997
|
+
method: 'POST',
|
|
1998
|
+
body: JSON.stringify({ action: 'save', fleet_key: key, expected_fleet_id: preview.fleet_id }),
|
|
1999
|
+
});
|
|
2000
|
+
if (!result.ok) { setError(result.error ?? '保存失败'); return; }
|
|
2001
|
+
setSaved(result.body);
|
|
2002
|
+
setKeyInput('');
|
|
2003
|
+
setPreview(null);
|
|
2004
|
+
setConfirmed(false);
|
|
2005
|
+
await load();
|
|
2006
|
+
} finally { setBusy(false); }
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
const expectedMismatch = preview !== null && expectedId.trim() !== ''
|
|
2010
|
+
&& String(preview.fleet_id).toLowerCase() !== expectedId.trim().toLowerCase();
|
|
2011
|
+
const mono = { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', wordBreak: 'break-all' };
|
|
2012
|
+
return h('div', { className: 'w2mGpw_admin' }, [
|
|
2013
|
+
h('div', { key: 'head', className: 'w2mGpw_adminHead' }, [
|
|
2014
|
+
h('h1', { key: 'h', className: 'w2mGpw_adminTitle' }, '编队绑定'),
|
|
2015
|
+
h('div', { key: 'sub', className: 'w2mGpw_adminSub' },
|
|
2016
|
+
'把本机固定到指定编队。保存到独立的 fleet-config.json(0600),不改 cordis.patch.yml;保存后重启 DSH 才生效。'),
|
|
2017
|
+
]),
|
|
2018
|
+
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_adminNote' }, error),
|
|
2019
|
+
h('div', { key: 'current', className: 'w2mGpw_adminCard' }, [
|
|
2020
|
+
h('div', { key: 's', className: 'w2mGpw_adminSub' }, state === null
|
|
2021
|
+
? '读取中…'
|
|
2022
|
+
: state.configured === true
|
|
2023
|
+
? `已配置 · policy ${String(state.policy ?? '?')} · 指纹 ${String(state.fingerprint ?? '').slice(0, 12)}`
|
|
2024
|
+
: '未配置:当前按 fleetKeyPolicy 的默认策略绑定'),
|
|
2025
|
+
state?.fleet_id ? h('div', { key: 'id', style: mono }, 'fleet_id: ' + state.fleet_id) : null,
|
|
2026
|
+
state?.path ? h('div', { key: 'p', className: 'w2mGpw_adminSub' }, '文件:' + state.path) : null,
|
|
2027
|
+
]),
|
|
2028
|
+
h('div', { key: 'form', className: 'w2mGpw_adminCard' }, [
|
|
2029
|
+
h('div', { key: 'row', className: 'w2mGpw_adminToolbar' }, [
|
|
2030
|
+
h('input', {
|
|
2031
|
+
key: 'k',
|
|
2032
|
+
className: 'w2mGpw_adminInput',
|
|
2033
|
+
type: 'password',
|
|
2034
|
+
value: keyInput,
|
|
2035
|
+
placeholder: '旧 fleet key(保存后只显示指纹,不回显)',
|
|
2036
|
+
onChange: (event) => { setKeyInput(event.target.value); setPreview(null); setConfirmed(false); },
|
|
2037
|
+
}),
|
|
2038
|
+
h('button', { key: 'c', type: 'button', className: 'w2mGpw_adminBtn', disabled: busy || keyInput.trim() === '', onClick: previewKey }, '计算 fleet_id'),
|
|
2039
|
+
]),
|
|
2040
|
+
h('div', { key: 'row2', className: 'w2mGpw_adminToolbar', style: { marginTop: '8px' } }, [
|
|
2041
|
+
h('input', {
|
|
2042
|
+
key: 'e',
|
|
2043
|
+
className: 'w2mGpw_adminInput',
|
|
2044
|
+
value: expectedId,
|
|
2045
|
+
placeholder: '期望的 fleet_id(可选,例如 004945ed…)',
|
|
2046
|
+
onChange: (event) => { setExpectedId(event.target.value); setConfirmed(false); },
|
|
2047
|
+
}),
|
|
2048
|
+
]),
|
|
2049
|
+
preview === null ? null : h('div', { key: 'preview', style: { marginTop: '12px' } }, [
|
|
2050
|
+
h('div', { key: 'l', className: 'w2mGpw_adminSub' }, '这个 key 算出的 fleet_id:'),
|
|
2051
|
+
h('div', { key: 'id', style: { ...mono, margin: '4px 0 8px' } }, String(preview.fleet_id)),
|
|
2052
|
+
expectedMismatch
|
|
2053
|
+
? h('div', { key: 'bad', className: 'w2mGpw_adminNote' }, '与期望值不一致,不能保存。请核对 key 或期望值。')
|
|
2054
|
+
: h('label', { key: 'ok', className: 'w2mGpw_adminSub', style: { display: 'flex', gap: '8px', alignItems: 'center' } }, [
|
|
2055
|
+
h('input', {
|
|
2056
|
+
key: 'c',
|
|
2057
|
+
type: 'checkbox',
|
|
2058
|
+
checked: confirmed,
|
|
2059
|
+
disabled: expectedMismatch,
|
|
2060
|
+
onChange: (event) => setConfirmed(event.target.checked),
|
|
2061
|
+
}),
|
|
2062
|
+
h('span', { key: 't' }, '我确认这是目标编队,保存并固定'),
|
|
2063
|
+
]),
|
|
2064
|
+
h('button', {
|
|
2065
|
+
key: 'save',
|
|
2066
|
+
type: 'button',
|
|
2067
|
+
className: 'w2mGpw_adminBtn',
|
|
2068
|
+
style: { marginTop: '8px' },
|
|
2069
|
+
disabled: busy || confirmed !== true || expectedMismatch,
|
|
2070
|
+
onClick: save,
|
|
2071
|
+
}, '保存并固定到该编队'),
|
|
2072
|
+
]),
|
|
2073
|
+
saved === null ? null : h('div', { key: 'saved', className: 'w2mGpw_adminNote' }, [
|
|
2074
|
+
h('div', { key: 'a' }, '已保存。重启 DSH 后生效;本页不会自动重启。'),
|
|
2075
|
+
h('div', { key: 'b', style: mono }, 'fleet_id: ' + String(saved.fleet_id ?? '')),
|
|
2076
|
+
saved.backup ? h('div', { key: 'c' }, '备份:' + saved.backup) : null,
|
|
2077
|
+
]),
|
|
2078
|
+
]),
|
|
2079
|
+
]);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function TargetSelect({ sessionId, locked: _locked, t, inputActions, useInput }) {
|
|
1752
2083
|
const [open, setOpen] = React.useState(false);
|
|
1753
2084
|
// v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
|
|
1754
2085
|
// the menu shows a summary and this toggle reveals the folded ones.
|
|
@@ -1756,11 +2087,29 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1756
2087
|
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
1757
2088
|
const [state, setState] = React.useState(null);
|
|
1758
2089
|
const [error, setError] = React.useState(null);
|
|
2090
|
+
// A write/edit refusal is not a fleet-read failure: the menu is closed when it happens, so
|
|
2091
|
+
// keeping it beside `error` (which a successful read clears) is what makes it survive the
|
|
2092
|
+
// reopen that shows it to the operator.
|
|
2093
|
+
const [notice, setNotice] = React.useState(null);
|
|
1759
2094
|
const [busy, setBusy] = React.useState(false);
|
|
1760
2095
|
const anchorRef = React.useRef(null);
|
|
1761
2096
|
const menuRef = React.useRef(null);
|
|
1762
2097
|
const [placement, setPlacement] = React.useState(null);
|
|
1763
2098
|
|
|
2099
|
+
// v2.3.0: the target control also writes a visible `@machine` token. `inputActions` and
|
|
2100
|
+
// `useInput` are standard session props (dsh-client-ui-conversation provides them to every
|
|
2101
|
+
// session slot); a host that does not carry them still gets the selector, just no token.
|
|
2102
|
+
const useComposerInput = typeof useInput === 'function' ? useInput : useNoComposerInput;
|
|
2103
|
+
const input = useComposerInput(selectComposerInput);
|
|
2104
|
+
const inputRef = React.useRef(input);
|
|
2105
|
+
inputRef.current = input;
|
|
2106
|
+
// Session switches are per mount in the shell, but a write can still land after the prop
|
|
2107
|
+
// changed under a reused component. The ref reads the current value at resolve time; the
|
|
2108
|
+
// sequence makes the last click win when two writes are in flight.
|
|
2109
|
+
const sessionRef = React.useRef(sessionId);
|
|
2110
|
+
sessionRef.current = sessionId;
|
|
2111
|
+
const writeSeq = React.useRef(0);
|
|
2112
|
+
|
|
1764
2113
|
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
1765
2114
|
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
1766
2115
|
React.useEffect(() => {
|
|
@@ -1832,7 +2181,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1832
2181
|
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
1833
2182
|
// v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
|
|
1834
2183
|
const relayFold = state?.relays ?? null;
|
|
1835
|
-
const problem = error ?? fleetProblem(state, t);
|
|
2184
|
+
const problem = notice ?? error ?? fleetProblem(state, t);
|
|
1836
2185
|
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
1837
2186
|
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
1838
2187
|
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
@@ -1846,21 +2195,92 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1846
2195
|
? t('costAll')
|
|
1847
2196
|
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
1848
2197
|
|
|
2198
|
+
/**
|
|
2199
|
+
* Apply the one editor edit that mirrors a target change.
|
|
2200
|
+
*
|
|
2201
|
+
* Ordering matters: the host write happens first, so a token is never left claiming a target
|
|
2202
|
+
* the host refused. The edit is best effort — a composer that is submitting, or a host
|
|
2203
|
+
* without `inputActions`, keeps the selection and simply loses the visible handle.
|
|
2204
|
+
*/
|
|
2205
|
+
const applyTargetMention = (nextName, previousName) => {
|
|
2206
|
+
if (inputActions === null || typeof inputActions !== 'object'
|
|
2207
|
+
|| typeof inputActions.insertText !== 'function'
|
|
2208
|
+
|| typeof inputActions.captureInsertion !== 'function') {
|
|
2209
|
+
focusComposer();
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const capture = inputActions.captureInsertion();
|
|
2213
|
+
if (capture === null || typeof capture !== 'object') {
|
|
2214
|
+
focusComposer();
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const current = inputRef.current ?? {};
|
|
2218
|
+
// `captureInsertion()` is the editor's current revision; `inputRef.current` is the last
|
|
2219
|
+
// published snapshot. If they disagree, the draft these offsets were computed from is not
|
|
2220
|
+
// the draft the editor holds, and `draftRev` would let a stale offset through.
|
|
2221
|
+
if (Number.isInteger(current.draftRev) && current.draftRev !== capture.draftRev) {
|
|
2222
|
+
focusComposer();
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
const plan = planTargetMention({
|
|
2226
|
+
draft: typeof current.draft === 'string' ? current.draft : '',
|
|
2227
|
+
occurrences: current.occurrences,
|
|
2228
|
+
caret: capture,
|
|
2229
|
+
nextName,
|
|
2230
|
+
previousName,
|
|
2231
|
+
});
|
|
2232
|
+
if (plan.kind === 'insert' || plan.kind === 'replace' || plan.kind === 'remove') {
|
|
2233
|
+
const text = plan.kind === 'remove' ? '' : plan.text;
|
|
2234
|
+
const applied = inputActions.insertText(text, {
|
|
2235
|
+
start: plan.start,
|
|
2236
|
+
end: plan.end,
|
|
2237
|
+
draftRev: capture.draftRev,
|
|
2238
|
+
});
|
|
2239
|
+
if (applied !== true) {
|
|
2240
|
+
setNotice(t('mentionFailed'));
|
|
2241
|
+
setOpen(true);
|
|
2242
|
+
} else {
|
|
2243
|
+
setNotice(null);
|
|
2244
|
+
}
|
|
2245
|
+
} else {
|
|
2246
|
+
setNotice(null);
|
|
2247
|
+
}
|
|
2248
|
+
focusComposer();
|
|
2249
|
+
};
|
|
2250
|
+
|
|
1849
2251
|
const choose = (machineId, machineName) => {
|
|
1850
2252
|
setOpen(false);
|
|
1851
|
-
|
|
2253
|
+
const previousName = selectedMachine?.name ?? state?.selected?.name ?? null;
|
|
2254
|
+
if (machineId === chosen) {
|
|
2255
|
+
// Clicking the current target restores a token the operator deleted, but never turns
|
|
2256
|
+
// the click into a destructive "clear" gesture: clearing is the All-machines row.
|
|
2257
|
+
setNotice(null);
|
|
2258
|
+
applyTargetMention(machineId === null ? null : previousName, null);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const seq = (writeSeq.current += 1);
|
|
2262
|
+
const at = sessionId;
|
|
1852
2263
|
setBusy(true);
|
|
1853
2264
|
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
2265
|
+
if (seq !== writeSeq.current) return; // a later click owns the answer
|
|
2266
|
+
if (at !== sessionRef.current) return; // the session changed while the write was in flight
|
|
1854
2267
|
setBusy(false);
|
|
1855
2268
|
if (result.ok) {
|
|
1856
2269
|
setState(result.state);
|
|
1857
2270
|
setError(null);
|
|
2271
|
+
setNotice(null);
|
|
2272
|
+
if (machineId === null) {
|
|
2273
|
+
applyTargetMention(null, previousName);
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
applyTargetMention(result.state?.selected?.name ?? machineName ?? null, previousName);
|
|
1858
2277
|
return;
|
|
1859
2278
|
}
|
|
1860
2279
|
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
1861
|
-
// fleet could not be read)
|
|
1862
|
-
|
|
1863
|
-
|
|
2280
|
+
// fleet could not be read). Reopen the menu so `notice` is actually in front of them.
|
|
2281
|
+
setNotice(result.error);
|
|
2282
|
+
setOpen(true);
|
|
2283
|
+
setReloadSeq((reload) => reload + 1);
|
|
1864
2284
|
});
|
|
1865
2285
|
};
|
|
1866
2286
|
|
|
@@ -2848,6 +3268,20 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
2848
3268
|
} catch (error) {
|
|
2849
3269
|
console.error('@twinsearth/w2m-dsh-plugin: the GPW settings tab was not mounted', error);
|
|
2850
3270
|
}
|
|
3271
|
+
// v2.2.1: the supported alternative to editing cordis.patch.yml. The section is its own
|
|
3272
|
+
// settings card so an operator can find "where do I put the old fleet key?" without
|
|
3273
|
+
// touching a profile file.
|
|
3274
|
+
try {
|
|
3275
|
+
slots.inject('settings.section', () => slots.register({
|
|
3276
|
+
name: 'settings.section',
|
|
3277
|
+
id: 'w2m-fleet',
|
|
3278
|
+
order: 11,
|
|
3279
|
+
label: () => '编队绑定',
|
|
3280
|
+
inject: () => ({ t }),
|
|
3281
|
+
}, FleetConfigSection));
|
|
3282
|
+
} catch (error) {
|
|
3283
|
+
console.error('@twinsearth/w2m-dsh-plugin: the fleet-binding settings section was not mounted', error);
|
|
3284
|
+
}
|
|
2851
3285
|
// v1.9.4: the update controls belong on the plugin's own page (same page as Uninstall),
|
|
2852
3286
|
// not only in the global settings list.
|
|
2853
3287
|
try {
|
|
@@ -3008,6 +3442,8 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
3008
3442
|
exports.apply = apply;
|
|
3009
3443
|
exports.inject = inject;
|
|
3010
3444
|
exports.TargetSelect = TargetSelect;
|
|
3445
|
+
exports.targetTokenForName = targetTokenForName;
|
|
3446
|
+
exports.planTargetMention = planTargetMention;
|
|
3011
3447
|
exports.GpwLauncher = GpwLauncher;
|
|
3012
3448
|
exports.GpwPrompt = GpwPrompt;
|
|
3013
3449
|
exports.FleetChat = FleetChat;
|