@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.
@@ -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.11"
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.11",
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.11"
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.11",
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.11", "w2m-mcp"]
95
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.1", "w2m-mcp"]
96
96
  ```
97
97
 
98
98
  ### 5. 验证过的 release tarball(离线)
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.11", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.2.1", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -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
- function TargetSelect({ sessionId, locked: _locked, t }) {
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
- if (machineId === chosen) return;
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), so it is shown where they are looking rather than swallowed.
1862
- setError(result.error);
1863
- setReloadSeq((seq) => seq + 1);
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;