@twinsearth/w2m-dsh-plugin 2.1.9 → 2.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.9"
8
+ "version": "2.1.11"
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.9",
15
+ "version": "2.1.11",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.1.9"
29
+ "version": "2.1.11"
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.9",
4
+ "version": "2.1.11",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ 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.1.11] - 2026-11-01
11
+
12
+ - 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.
13
+ - Tests: a stale-profile service yields the switched account's fleet key in test/chatops-account.test.mjs.
14
+
15
+ ## [2.1.10] - 2026-11-01
16
+
17
+ - Fleet binding: a DeepSeek account switch now re-pairs the machine instead of leaving it in the previous account's fleet. Startup compares the relay's fleet_id with the fleet id derived from the signed-in account, and a 60s watcher covers a switch without a process restart. A stored token is only valid for the account that minted it; it is no longer treated as proof of the current account.
18
+ - Fleet binding diagnostics: FLEET_MISMATCH names a binding to a different account's fleet; FLEET_UNBOUND keeps the existing re-admit rule.
19
+ - Remote view capture: read the `spawnSync` exit code from `status`, not the execFile-shaped `code`. `code` is `undefined` for `spawnSync`, so every successful screen capture was reported as CAPTURE_DENIED and the relay view target could never publish a frame. S2 used `status`, which is why its probe passed while the agent path failed.
20
+ - Tests: mismatch and unbound re-admit cases in test/fleet-binding.test.mjs; a capture test pins the `status` contract and would fail on the old `code` read.
21
+
10
22
  ## [2.1.9] - 2026-10-31
11
23
 
12
24
  - Plugin identity: exported locale/zh.json + locale/en.json meta.title/meta.description. DSH reads these without activating the plugin, so the manager list and detail page render the custom name and copy.
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.9", "w2m-mcp"]
95
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.11", "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.9", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.11", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "2.1.10",
3
+ "case": "ACCOUNT-SWITCH-21100",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-31T18:03:23.303Z",
9
+ "executor": "lead",
10
+ "task_id": "account-switch-21100-1793469803304",
11
+ "requirement": "Switching the signed-in DeepSeek account must move the machine to that account fleet, not leave it heartbeating in the previous fleet",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "mismatch": "FLEET_MISMATCH names a relay fleet_id that differs from the account-derived fleet_id",
16
+ "rejoin": "shouldRejoin re-admits on null and on mismatch, and not when the ids agree",
17
+ "watch": "a 60s account check re-derives the fleet key and re-pairs after a switch"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "2.1.10",
3
+ "case": "REMOTE-VIEW-CAPTURE-21100",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b1b19d49-20f1-4cdc-986e-f5630a5cd6f2"
7
+ ],
8
+ "executed_at": "2026-10-31T18:11:48.141Z",
9
+ "executor": "lead",
10
+ "task_id": "remote-view-capture-21100-1793470308142",
11
+ "requirement": "The relay view target must read spawnSync.status so a successful screen capture is published instead of CAPTURE_DENIED",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "status": "captureScreen reads result.status on darwin and win32",
16
+ "regression": "a runner returning {status:0,code:1} still captures",
17
+ "probe": "remote-s2-capture exit 162 on macmini"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "2.1.11",
3
+ "case": "ACCOUNT-SWITCH-PROFILE-21110",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "1bef9935-8c13-4dd9-9043-4e025320723f"
7
+ ],
8
+ "executed_at": "2026-10-31T19:11:34.238Z",
9
+ "executor": "lead",
10
+ "task_id": "account-switch-profile-21110-1793473894239",
11
+ "requirement": "After a DeepSeek account switch the plugin must refresh the cleared profile, derive the new fleet key and re-pair instead of keeping the previous fleet token",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "refresh": "identify() calls getProfile when getDeviceIdentity has no userId",
16
+ "key": "the switched account yields a different fleetKey",
17
+ "rebind": "the v2.1.10 fleet mismatch check then re-pairs"
18
+ }
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
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": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "2.1.9",
4
+ "version": "2.1.11",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 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.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -74,7 +74,9 @@ export function captureScreen(options = {}) {
74
74
  let height = null;
75
75
  if (platform === 'win32') {
76
76
  const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
77
- if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.code ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
77
+ // `spawnSync` reports the exit code as `status`; `code` is the execFile-shaped field
78
+ // and is undefined here, which made every successful capture look like a denial.
79
+ if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.status ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
78
80
  base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
79
81
  if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
80
82
  } else if (platform === 'darwin') {
@@ -82,7 +84,9 @@ export function captureScreen(options = {}) {
82
84
  const file = join(dir, 'frame.jpg');
83
85
  try {
84
86
  const result = run(['/usr/sbin/screencapture', '-x', '-t', 'jpg', file], { timeoutMs: 15000 });
85
- if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.code})`, capture_ms: Date.now() - started };
87
+ // Same `spawnSync` contract: `status`, never `code`. S2 used the right field, which is
88
+ // why the probe succeeded while the relay view target reported CAPTURE_DENIED.
89
+ if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.status})`, capture_ms: Date.now() - started };
86
90
  const data = readFileSync(file);
87
91
  base64 = data.toString('base64');
88
92
  const sips = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 5000 });
@@ -300,6 +300,23 @@ export function createAccountIdentity(options = {}) {
300
300
  let identity;
301
301
  try {
302
302
  identity = await service.getDeviceIdentity();
303
+ /**
304
+ * A switched account is invisible until the profile is fetched.
305
+ *
306
+ * `getDeviceIdentity()` reads `getPlatformSession().userId`, and that is only populated
307
+ * from a profile already cached for the current grant. After credentials/record-updated
308
+ * the old profile is discarded and the session's userId is null until someone calls
309
+ * `getProfile()`. A machine that was paired before the switch therefore derived no new
310
+ * fleet key and kept its old token -- the reported "still in the previous account's
311
+ * fleet" state. Fetch the profile once when the identity is missing; the second read then
312
+ * sees the new account.
313
+ */
314
+ if ((identity?.userId == null) && typeof service.getProfile === 'function') {
315
+ try {
316
+ await service.getProfile();
317
+ identity = await service.getDeviceIdentity();
318
+ } catch { /* keep the signed-out answer if the profile cannot be read */ }
319
+ }
303
320
  } catch (error) {
304
321
  return { ok: false, reason: 'identity_read_failed', detail: error instanceof Error ? error.message : String(error) };
305
322
  }
@@ -13,6 +13,7 @@
13
13
  export const FLEET_BINDING_CODE = Object.freeze({
14
14
  BOUND: 'FLEET_BOUND',
15
15
  UNBOUND: 'FLEET_UNBOUND',
16
+ MISMATCH: 'FLEET_MISMATCH',
16
17
  UNPAIRED: 'FLEET_UNPAIRED',
17
18
  TOKEN_REJECTED: 'FLEET_TOKEN_REJECTED',
18
19
  UNKNOWN: 'FLEET_UNKNOWN',
@@ -20,12 +21,22 @@ export const FLEET_BINDING_CODE = Object.freeze({
20
21
 
21
22
  /**
22
23
  * Should a start-up re-admit even though a token exists?
23
- * Only when the token is valid, the machine has no fleet, and a key is available to bind it.
24
+ *
25
+ * Two states need it: the machine has no fleet (a token issued before fleet binding, or a
26
+ * run where the pairing code carried none), or the fleet it is in is **not the fleet the
27
+ * current account derives**. The second is the account-switch defect: a stored token stays
28
+ * valid for the account that minted it, so after a different DeepSeek account signs in the
29
+ * machine keeps heartbeating into the old account's fleet unless its binding is compared
30
+ * with the key the new account derives.
31
+ *
32
+ * An absent expected fleet id means the caller could not derive one; equality cannot be
33
+ * tested then, so only the unbound state re-admits.
24
34
  */
25
- export function shouldRejoin({ token, fleetId, fleetKey }) {
26
- return typeof token === 'string' && token !== ''
27
- && (fleetId === null || fleetId === undefined || fleetId === '')
28
- && typeof fleetKey === 'string' && fleetKey.trim() !== '';
35
+ export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null }) {
36
+ if (typeof token !== 'string' || token === '') return false;
37
+ if (typeof fleetKey !== 'string' || fleetKey.trim() === '') return false;
38
+ if (fleetId === null || fleetId === undefined || fleetId === '') return true;
39
+ return typeof expectedFleetId === 'string' && expectedFleetId !== '' && fleetId !== expectedFleetId;
29
40
  }
30
41
 
31
42
  /**
@@ -35,14 +46,16 @@ export function shouldRejoin({ token, fleetId, fleetKey }) {
35
46
  * fleetKey: string|null, fetchImpl?: Function, timeoutMs?: number}} input
36
47
  * @returns {Promise<object>} a bounded, token-free status block
37
48
  */
38
- export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, fetchImpl = fetch, timeoutMs = 2500 }) {
49
+ export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, expectedFleetId = null, fetchImpl = fetch, timeoutMs = 2500 }) {
39
50
  const checkedAt = new Date().toISOString();
40
51
  const fleetKeyConfigured = typeof fleetKey === 'string' && fleetKey.trim() !== '';
52
+ const expected = typeof expectedFleetId === 'string' && expectedFleetId !== '' ? expectedFleetId : null;
41
53
  const base = {
42
54
  ok: true,
43
55
  state: 'unknown',
44
56
  code: FLEET_BINDING_CODE.UNKNOWN,
45
57
  fleet_id: null,
58
+ expected_fleet_id: expected,
46
59
  token_present: typeof token === 'string' && token !== '',
47
60
  fleet_key_configured: fleetKeyConfigured,
48
61
  machine_id: machineId,
@@ -93,6 +106,16 @@ export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKe
93
106
  : 'set fleetKey / W2M_FLEET_KEY, then restart the agent so the machine can join its fleet',
94
107
  };
95
108
  }
109
+ if (expected !== null && fleetId !== expected) {
110
+ return {
111
+ ...base,
112
+ state: 'mismatch',
113
+ code: FLEET_BINDING_CODE.MISMATCH,
114
+ fleet_id: fleetId,
115
+ detail: 'the relay records this machine in a different fleet than the signed-in account derives',
116
+ remedy: 're-admit this machine with the account-derived fleet key; the old token must not keep it in the previous fleet',
117
+ };
118
+ }
96
119
  return { ...base, state: 'bound', code: FLEET_BINDING_CODE.BOUND, fleet_id: fleetId };
97
120
  } catch (error) {
98
121
  return {
@@ -293,7 +293,7 @@ const MAX_STAGES = 16;
293
293
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
294
294
  * overrides it at runtime for tests and packagers.
295
295
  */
296
- const PLUGIN_VERSION = '2.1.9';
296
+ const PLUGIN_VERSION = '2.1.11';
297
297
 
298
298
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
299
299
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -744,7 +744,7 @@ function readConfig(config = {}) {
744
744
  // silent default when a value *is* supplied.
745
745
  const p2p = resolveP2PMode(config);
746
746
  const p2pMode = p2p.mode;
747
- const p2pPortResult = normalizeP2PPort(config.p2pPort);
747
+ const p2pPortResult = normalizeP2PPort(config.p2pPort);
748
748
  const p2pPublicPortResult = normalizeP2PPort(config.p2pPublicPort);
749
749
  const stun = resolveStunServers(config);
750
750
  const stunList = stun.servers;
@@ -934,7 +934,7 @@ function readConfig(config = {}) {
934
934
  : (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
935
935
  p2pMode,
936
936
  p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
937
- p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
937
+ p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
938
938
  p2pPublicPort: p2pPublicPortResult.ok ? p2pPublicPortResult.port : null,
939
939
  stunServers: stunList,
940
940
  };
@@ -2037,7 +2037,7 @@ async function initP2P(ctx, cfg, runtime, block) {
2037
2037
  getJson: transport.getJson,
2038
2038
  stunServers: cfg.stunServers,
2039
2039
  log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
2040
- ...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
2040
+ ...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
2041
2041
  ...(Number.isInteger(cfg.p2pPublicPort) ? { publicPort: cfg.p2pPublicPort } : {}),
2042
2042
  ...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
2043
2043
  ...block,
@@ -5021,11 +5021,25 @@ const result = await request({
5021
5021
  };
5022
5022
  })();
5023
5023
 
5024
+ /**
5025
+ * Compare against the key the stack actually resolved, not just `cfg.fleetKey`.
5026
+ * A machine joined by account has no configured key, and that is exactly the case
5027
+ * where a switched account used to look "bound" because only `fleet_id=null` was
5028
+ * treated as wrong.
5029
+ */
5030
+ let expectedFleetId = null;
5031
+ const statusFleetKey = resolvedFleetKey.value ?? cfg.fleetKey;
5032
+ try {
5033
+ const { planJoin } = await import('../chatops/fleet-join.mjs');
5034
+ const plan = planJoin({ fleetKeyEncoded: statusFleetKey });
5035
+ if (plan.ok === true) expectedFleetId = plan.fleetId;
5036
+ } catch { /* malformed key: report without an expectation */ }
5024
5037
  const fleetBinding = await diagnoseFleetBinding({
5025
5038
  relayUrl: cfg.rabbitUrl,
5026
5039
  token: device.device_token,
5027
5040
  machineId: device.machine_id,
5028
5041
  fleetKey: cfg.fleetKey,
5042
+ expectedFleetId,
5029
5043
  });
5030
5044
 
5031
5045
  return JSON.stringify(
@@ -8062,16 +8076,38 @@ async function startLocalStack(ctx, cfg) {
8062
8076
  if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
8063
8077
  ctx.logger?.warn?.('w2m: stored device token was rejected by ' + relayForToken + ' (HTTP ' + probe.status + '); pairing again with the fleet key');
8064
8078
  }
8065
- if (alreadyPaired && fleetKeyFromConfig !== null) {
8079
+ /**
8080
+ * Compare the relay's binding with the fleet the **current** key derives.
8081
+ *
8082
+ * The old check ran only for a configured `fleetKey`, so the account-derived key
8083
+ * (the normal case) was never compared. That is the account-switch defect: the
8084
+ * stored token stays valid for the account that minted it, and the machine keeps
8085
+ * heartbeating into the previous account's fleet. `fleetId=null` was the only
8086
+ * rejoin trigger, so a non-null mismatch looked perfectly healthy.
8087
+ */
8088
+ const effectiveFleetKey = fleetKey ?? fleetKeyFromConfig;
8089
+ if (alreadyPaired && effectiveFleetKey !== null) {
8090
+ let expectedFleetId = null;
8091
+ try {
8092
+ const { planJoin } = await import('../chatops/fleet-join.mjs');
8093
+ const plan = planJoin({ fleetKeyEncoded: effectiveFleetKey });
8094
+ if (plan.ok === true) expectedFleetId = plan.fleetId;
8095
+ } catch { /* malformed key: binding cannot be compared, keep the token */ }
8066
8096
  const binding = await diagnoseFleetBinding({
8067
8097
  relayUrl: relayForToken,
8068
8098
  token: existingToken,
8069
8099
  machineId: loaded.identity.machine_id,
8070
- fleetKey: fleetKeyFromConfig,
8100
+ fleetKey: effectiveFleetKey,
8101
+ expectedFleetId,
8071
8102
  });
8072
- if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: fleetKeyFromConfig })) {
8103
+ if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: effectiveFleetKey, expectedFleetId })) {
8073
8104
  alreadyPaired = false;
8074
- ctx.logger?.warn?.('w2m: FLEET_UNBOUND (token valid, fleet_id=null); re-admitting with the configured fleet key');
8105
+ loaded.identity.device_token = null;
8106
+ if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
8107
+ ctx.logger?.warn?.(
8108
+ `w2m: fleet binding changed (relay=${binding.fleet_id ?? 'none'}, account=${expectedFleetId ?? 'unknown'}); `
8109
+ + `re-admitting with the ${fleetKeySource === 'config' ? 'configured' : 'account-derived'} fleet key`,
8110
+ );
8075
8111
  }
8076
8112
  }
8077
8113
  } catch { /* network error: keep the token, do not force a re-pair */ }
@@ -8254,6 +8290,55 @@ async function startLocalStack(ctx, cfg) {
8254
8290
  */
8255
8291
  ensureFleetJoin = () => joinFleet();
8256
8292
 
8293
+ /**
8294
+ * Watch for an account switch while the process stays alive.
8295
+ *
8296
+ * The mount check repairs a switch that happened before start-up. This one covers the same
8297
+ * event without a restart: a different DeepSeek account signs in, the account service starts
8298
+ * returning a different user id, and the derived fleet key changes. The stored token is still
8299
+ * valid for the *previous* account, so without this the machine keeps heartbeating into the
8300
+ * old fleet -- the reported symptom. Re-pairing rotates the token and moves the machine.
8301
+ *
8302
+ * The account service is read on an interval rather than per request: it can touch the host's
8303
+ * profile store, and a switch is a human-paced event, not a per-dispatch one. An operator-set
8304
+ * `fleetKey` is not watched -- that is an explicit decision and must not be overwritten by
8305
+ * whichever account happens to be signed in.
8306
+ */
8307
+ const ACCOUNT_WATCH_MS = 60_000;
8308
+ let accountWatch = null;
8309
+ let accountWatchBusy = false;
8310
+ async function recheckAccountBinding() {
8311
+ if (accountWatchBusy || fleetKeyFromConfig !== null) return;
8312
+ accountWatchBusy = true;
8313
+ try {
8314
+ const { createAccountIdentity } = await import('../chatops/account.mjs');
8315
+ const identity = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
8316
+ const next = typeof identity?.fleetKey === 'string' && identity.fleetKey !== '' ? identity.fleetKey : null;
8317
+ if (next === null || next === fleetKey) return;
8318
+ ctx.logger?.warn?.('w2m: the signed-in account changed; leaving the previous fleet and re-pairing with the new account fleet key');
8319
+ fleetKey = next;
8320
+ fleetKeySource = 'account';
8321
+ resolvedFleetKey.value = next;
8322
+ resolvedFleetKey.source = 'account';
8323
+ resolvedFleetKey.at = Date.now();
8324
+ alreadyPaired = false;
8325
+ joinSucceeded = false;
8326
+ joinPromise = null;
8327
+ joinLastError = null;
8328
+ joinLastWasKeyless = false;
8329
+ loaded.identity.device_token = null;
8330
+ if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
8331
+ const outcome = await joinFleet();
8332
+ if (outcome.paired !== true) scheduleJoinRetry();
8333
+ } catch (error) {
8334
+ ctx.logger?.warn?.('w2m: account re-check failed: ' + (error instanceof Error ? error.message : String(error)));
8335
+ } finally {
8336
+ accountWatchBusy = false;
8337
+ }
8338
+ }
8339
+ accountWatch = setInterval(() => { void recheckAccountBinding(); }, ACCOUNT_WATCH_MS);
8340
+ accountWatch.unref?.();
8341
+
8257
8342
  if (alreadyPaired) {
8258
8343
  joinSucceeded = true;
8259
8344
  joinPromise = Promise.resolve({ paired: true, by: 'existing token' });
@@ -8362,6 +8447,10 @@ async function startLocalStack(ctx, cfg) {
8362
8447
 
8363
8448
  return async () => {
8364
8449
  clearJoinRetry();
8450
+ if (accountWatch !== null) {
8451
+ clearInterval(accountWatch);
8452
+ accountWatch = null;
8453
+ }
8365
8454
  ensureFleetJoin = null;
8366
8455
  inProcessAgentStatus = null;
8367
8456
  await agent.stop();