dsh-remote 0.8.23 → 0.8.24

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/lib/client.js CHANGED
@@ -139,8 +139,10 @@ window.__ModuleLoader__.load({
139
139
  'settings.updateFail': '检查更新失败',
140
140
  'settings.updateAvailable': '发现新版本 v{version}',
141
141
  'settings.updateLatest': '已是最新版本(v{version})',
142
- 'settings.updateConfirm': '将更新 dsh-remote 到 v{version},替换本机插件文件。更新后需重启 Harness 生效。继续?',
143
- 'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
142
+ 'settings.updateConfirm': '将更新 dsh-remote 到 v{version},替换本机插件文件并在线热切换宿主半(不重启 Harness)。继续?',
143
+ 'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}),宿主半已在线热切换。',
144
+ 'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
145
+ 'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
144
146
  'settings.updateFailed': '更新失败',
145
147
  'settings.updateFailedMsg': '更新失败: {msg}',
146
148
  'settings.registryUnreachable': '无法连接 npm registry',
@@ -363,8 +365,10 @@ window.__ModuleLoader__.load({
363
365
  'settings.updateFail': 'Failed to check for updates',
364
366
  'settings.updateAvailable': 'New version found: v{version}',
365
367
  'settings.updateLatest': 'Already up to date (v{version})',
366
- 'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and requires a Harness restart to take effect. Continue?',
367
- 'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
368
+ 'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and hot-swaps the host half in place (no Harness restart). Continue?',
369
+ 'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}); the host half hot-swapped in place.',
370
+ 'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
371
+ 'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
368
372
  'settings.updateFailed': 'Update failed',
369
373
  'settings.updateFailedMsg': 'Update failed: {msg}',
370
374
  'settings.registryUnreachable': 'Cannot reach the npm registry',
@@ -836,9 +840,13 @@ window.__ModuleLoader__.load({
836
840
  setUpdBusy(true); setUpdMsg('')
837
841
  api('POST', '/dsh-remote/update-apply', { version: upd.latest })
838
842
  .then((r) => {
839
- if (r && r.ok) setUpdMsg(tr('settings.updateApplied', { to: r.to, from: (r.from || '') }))
843
+ if (r && r.ok) setUpdMsg(tr(r.reloadScheduled ? 'settings.updateApplied' : 'settings.updateAppliedNoReload', { to: r.to, from: (r.from || '') }))
840
844
  else setUpdMsg((r && r.error) || tr('settings.updateFailed'))
841
- checkUpdate(true)
845
+ // The host half swaps itself ~300ms after answering, so the panel
846
+ // must not re-check immediately: it would still be talking to the
847
+ // outgoing fiber and report the pre-swap version, i.e. "update
848
+ // available" right after a successful update.
849
+ window.setTimeout(() => checkUpdate(true), 2000)
842
850
  })
843
851
  .catch((e) => setUpdMsg(tr('settings.updateFailedMsg', { msg: String((e && e.message) || e) })))
844
852
  .finally(() => setUpdBusy(false))
@@ -1127,6 +1135,10 @@ window.__ModuleLoader__.load({
1127
1135
  }, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
1128
1136
  ),
1129
1137
  ),
1138
+ upd && upd.pendingReload
1139
+ ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1140
+ tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
1141
+ : null,
1130
1142
  updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
1131
1143
  ),
1132
1144
  React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import z from '@deepseek-ai/schemastery'
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
23
  import { execFile } from 'node:child_process'
24
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch } from 'node:fs'
24
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch, rmSync } from 'node:fs'
25
25
  import { homedir } from 'node:os'
26
26
  import path from 'node:path'
27
27
  import iconv from 'iconv-lite'
@@ -41,7 +41,7 @@ import { searchRemote } from './search.js'
41
41
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
42
42
  import { TaskManager } from './tasks.js'
43
43
  import { ForwardManager } from './forwards.js'
44
- import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
44
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload } from './update.js'
45
45
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
46
46
  import { registerHttpTransports } from './http-transport.js'
47
47
  import { SshPool } from './pool.js'
@@ -130,6 +130,10 @@ export const Config = z.object({
130
130
  updateMode: z.string().default('manual'),
131
131
  /** How often (ms) auto mode checks the npm registry for a newer release. */
132
132
  updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
133
+ /** Whether a landed update also hot-swaps the running host half (default
134
+ * true). With `false` an update only takes effect on the next process start
135
+ * and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
136
+ updateAutoReload: z.boolean().default(true),
133
137
  })
134
138
 
135
139
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -258,6 +262,15 @@ export async function apply(ctx, config) {
258
262
 
259
263
  migrateLegacyData()
260
264
 
265
+ // The update marker means "a newer version landed on disk but is not the code
266
+ // running yet". Reaching apply() with a marker naming the version we just
267
+ // loaded means the swap already happened (or this is a plain boot into the
268
+ // updated files) → clear it, so the UI's "restart to finish" hint stays true.
269
+ try {
270
+ const marker = path.join(selfDir(), '.dsh-remote-updated')
271
+ if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === LOADED_VERSION) rmSync(marker, { force: true })
272
+ } catch {}
273
+
261
274
  // ── machine registry (multi-host) ─────────────────────────────────────────
262
275
  const store = loadMachines()
263
276
  const machines = store.list
@@ -2765,17 +2778,26 @@ export async function apply(ctx, config) {
2765
2778
  path: '/dsh-remote/update-check',
2766
2779
  handler: async (req, res) => {
2767
2780
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2768
- const current = readVersion()
2769
- const latest = await fetchLatestVersion()
2770
- if (latest === null) return sendJson(res, 200, { ok: false, current, error: '无法连接 npm registry' })
2781
+ // `loaded` is the code actually running; `disk` may already be newer.
2782
+ // They differ between an applied update and its hot swap (or a boot).
2783
+ const loaded = LOADED_VERSION
2784
+ const disk = diskVersion()
2771
2785
  const rawMode = readUpdateMode() || config.updateMode || 'manual'
2786
+ const base = {
2787
+ current: loaded,
2788
+ loaded,
2789
+ disk,
2790
+ pendingReload: gtVersion(disk, loaded),
2791
+ updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2792
+ updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2793
+ }
2794
+ const latest = await fetchLatestVersion()
2795
+ if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
2772
2796
  return sendJson(res, 200, {
2773
2797
  ok: true,
2774
- current,
2798
+ ...base,
2775
2799
  latest,
2776
- updateAvailable: gtVersion(latest, current),
2777
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2778
- updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2800
+ updateAvailable: gtVersion(latest, loaded),
2779
2801
  })
2780
2802
  },
2781
2803
  },
@@ -2785,16 +2807,45 @@ export async function apply(ctx, config) {
2785
2807
  handler: async (req, res) => {
2786
2808
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2787
2809
  try {
2810
+ // Capture the outgoing version BEFORE the swap: reading it afterwards
2811
+ // reports the version we just installed, not the one we replaced.
2812
+ const from = diskVersion()
2813
+ const loaded = LOADED_VERSION
2788
2814
  const body = JSON.parse((await readBody(req)) || '{}')
2789
2815
  const target = String(body.version || '')
2790
2816
  if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2791
2817
  const result = await applyUpdate(target)
2792
- return sendJson(res, 200, { ok: true, from: readVersion(), ...result })
2818
+ const wantReload = body.reload !== false && config.updateAutoReload !== false
2819
+ // Answer first, then swap: the swap disposes the fiber serving this
2820
+ // very request, so nothing may touch the plugin after it.
2821
+ if (wantReload) scheduleSelfReload(ctx.loader, 300)
2822
+ return sendJson(res, 200, {
2823
+ ok: true,
2824
+ from,
2825
+ loadedBefore: loaded,
2826
+ ...result,
2827
+ reloadScheduled: wantReload,
2828
+ // what the swap will land on, for a caller that wants to verify
2829
+ loadedAfterReload: wantReload ? result.to : loaded,
2830
+ })
2793
2831
  } catch (err) {
2794
2832
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2795
2833
  }
2796
2834
  },
2797
2835
  },
2836
+ {
2837
+ kind: 'exact',
2838
+ path: '/dsh-remote/update-reload',
2839
+ handler: async (req, res) => {
2840
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2841
+ // Hot-swap the host half to whatever is on disk now. Answers first (with
2842
+ // what was loaded and what it is about to become), then swaps.
2843
+ const loaded = LOADED_VERSION
2844
+ const disk = diskVersion()
2845
+ scheduleSelfReload(ctx.loader, 300)
2846
+ return sendJson(res, 200, { ok: true, scheduled: true, loaded, disk, willReloadTo: disk })
2847
+ },
2848
+ },
2798
2849
  {
2799
2850
  kind: 'exact',
2800
2851
  path: '/dsh-remote/update-mode',
@@ -2821,12 +2872,24 @@ export async function apply(ctx, config) {
2821
2872
  const rawMode = readUpdateMode() || config.updateMode || 'manual'
2822
2873
  const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2823
2874
  if (effectiveUpdateMode === 'auto') {
2824
- const currentVersion = readVersion()
2825
2875
  const checkAndApply = async () => {
2826
2876
  const latest = await fetchLatestVersion()
2827
- if (latest && gtVersion(latest, currentVersion)) {
2828
- try { await applyUpdate(latest) } catch {}
2877
+ if (!latest) return
2878
+ // The version in flight is whichever of "already on disk" / "already
2879
+ // loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
2880
+ // made every later interval re-download and re-apply the same tarball,
2881
+ // because the pinned value never moves past the version it installed.
2882
+ const disk = diskVersion()
2883
+ const current = gtVersion(disk, LOADED_VERSION) ? disk : LOADED_VERSION
2884
+ if (!gtVersion(latest, current)) return
2885
+ try {
2886
+ await applyUpdate(latest)
2887
+ } catch {
2888
+ return
2829
2889
  }
2890
+ // Landed: swap the running host half too, otherwise the code being served
2891
+ // and the code being run stay one version apart until the next boot.
2892
+ if (config.updateAutoReload !== false) scheduleSelfReload(ctx.loader, 500)
2830
2893
  }
2831
2894
  void checkAndApply()
2832
2895
  const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
package/lib/update.js CHANGED
@@ -8,15 +8,24 @@
8
8
  // • checkLatestVersion() — query the npm registry for the `latest` dist-tag;
9
9
  // • applyUpdate() — download the tarball, extract package/{lib,package.json,
10
10
  // cordis.patch.yml} with a minimal tar reader, verify the version, then
11
- // atomically copy over the installed files. A `.dsh-remote-updated` marker
12
- // is written so the UI can tell the user to reload.
11
+ // replace the installed files atomically (temp file + rename, never a
12
+ // half-written lib/*.js). A `.dsh-remote-updated` marker records the version
13
+ // that landed; the host half removes it once that version is the code it is
14
+ // actually running (see LOADED_VERSION), so the marker means "restart still
15
+ // required" rather than "an update once happened".
16
+ // • reloadSelf() — swap the RUNNING host half to the code now on disk: drop
17
+ // Node's module caches for this package's own files, then dispose + re-init
18
+ // the loader entry so apply() runs again from the fresh module. Without it
19
+ // an update only takes effect on the next process start, which is what left
20
+ // the served client half newer than the loaded host half.
13
21
  //
14
22
  // Every failure aborts without touching the installed files (a bad download or
15
23
  // a version mismatch must never break the running plugin).
16
24
 
17
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
25
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
26
+ import { createRequire } from 'node:module'
18
27
  import path from 'node:path'
19
- import { fileURLToPath } from 'node:url'
28
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
29
 
21
30
  const NPM_PACKAGE = 'dsh-remote'
22
31
  const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
@@ -40,6 +49,17 @@ export function readVersion() {
40
49
  }
41
50
  }
42
51
 
52
+ /** Version of the code this process actually loaded (snapshot at import time).
53
+ * `readVersion()` reads package.json from disk, so after an update lands it
54
+ * reports the NEW version while the running module is still the old one —
55
+ * comparing the two is the only way to tell "applied" from "active". */
56
+ export const LOADED_VERSION = readVersion()
57
+
58
+ /** Package version currently on disk (may be newer than {@link LOADED_VERSION}). */
59
+ export function diskVersion() {
60
+ return readVersion()
61
+ }
62
+
43
63
  /** Compare dotted versions; returns true when a > b. */
44
64
  export function gtVersion(a, b) {
45
65
  const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
@@ -96,9 +116,10 @@ function parseTar(buf) {
96
116
  * installed files untouched).
97
117
  * @param targetVersion - exact npm version to install (e.g. "0.8.0").
98
118
  */
99
- export async function applyUpdate(targetVersion) {
119
+ export async function applyUpdate(targetVersion, options = {}) {
100
120
  const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
101
- const dir = selfDir()
121
+ const dir = options.dir || selfDir()
122
+ const doFetch = options.fetchImpl || fetch
102
123
  const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
103
124
  const tmpPkg = path.join(tmpRoot, 'package')
104
125
  try {
@@ -106,7 +127,7 @@ export async function applyUpdate(targetVersion) {
106
127
  const timer = setTimeout(() => controller.abort(), 60000)
107
128
  let res
108
129
  try {
109
- res = await fetch(tarballUrl, { signal: controller.signal })
130
+ res = await doFetch(tarballUrl, { signal: controller.signal })
110
131
  } finally {
111
132
  clearTimeout(timer)
112
133
  }
@@ -135,20 +156,34 @@ export async function applyUpdate(targetVersion) {
135
156
  // break the next boot — check the file size and that it is not empty).
136
157
  const newIndex = path.join(tmpPkg, 'lib', 'index.js')
137
158
  if (!existsSync(newIndex) || statSize(newIndex) < 100) throw new Error('tarball missing lib/index.js')
138
- // Atomically swap: copy fresh files over the installed ones.
159
+ // Swap the installed files one at a time: atomic, and idempotent.
160
+ //
161
+ // Atomicity (temp file + rename) is not cosmetic here — since the browser
162
+ // half is hot-swapped by dsh-client-hmr, which stat-polls lib/client.js
163
+ // every 500 ms, a torn write would be re-hashed and served mid-copy.
164
+ // Skipping byte-identical files keeps a re-apply of the same version from
165
+ // churning mtimes (and the host half from reloading for nothing).
139
166
  const installLib = path.join(dir, 'lib')
140
167
  const tmpLib = path.join(tmpPkg, 'lib')
168
+ const changed = []
169
+ const install = (src, dest) => {
170
+ const bytes = readFileSync(src)
171
+ if (sameFileContent(dest, bytes)) return
172
+ try { mkdirSync(path.dirname(dest), { recursive: true }) } catch {}
173
+ writeFileAtomic(dest, bytes)
174
+ changed.push(path.relative(dir, dest).split(path.sep).join('/'))
175
+ }
141
176
  for (const name of readdirSafe(tmpLib)) {
142
177
  if (!name.endsWith('.js')) continue
143
178
  const src = path.join(tmpLib, name)
144
- if (existsSync(src)) copyFileSync(src, path.join(installLib, name))
179
+ if (existsSync(src)) install(src, path.join(installLib, name))
145
180
  }
146
181
  for (const rel of ['package.json', 'cordis.patch.yml']) {
147
182
  const src = path.join(tmpPkg, rel)
148
- if (existsSync(src)) copyFileSync(src, path.join(dir, rel))
183
+ if (existsSync(src)) install(src, path.join(dir, rel))
149
184
  }
150
185
  writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
151
- return { ok: true, to: targetVersion }
186
+ return { ok: true, to: targetVersion, changed }
152
187
  } catch (err) {
153
188
  throw new Error('update failed: ' + ((err && err.message) || err))
154
189
  } finally {
@@ -174,6 +209,145 @@ function readdirSafe(dir) {
174
209
  }
175
210
  }
176
211
 
212
+ /**
213
+ * Write `data` to `dest` atomically: the bytes land in a sibling temp file and
214
+ * are then renamed over the target, so a concurrent reader (the client-half HMR
215
+ * poll, or the next boot) never observes a truncated file. Falls back to an
216
+ * in-place copy if the platform refuses the replace, and never leaves the temp
217
+ * file behind.
218
+ * @param dest - absolute destination path.
219
+ * @param data - bytes to write.
220
+ */
221
+ export function writeFileAtomic(dest, data) {
222
+ const tmp = `${dest}.dsh-tmp-${process.pid}-${Date.now().toString(36)}`
223
+ writeFileSync(tmp, data)
224
+ try {
225
+ renameSync(tmp, dest)
226
+ return
227
+ } catch {
228
+ try {
229
+ copyFileSync(tmp, dest)
230
+ } finally {
231
+ try { rmSync(tmp, { force: true }) } catch {}
232
+ }
233
+ }
234
+ }
235
+
236
+ /** Whether the file at `p` already holds exactly `bytes` (missing/short → false). */
237
+ function sameFileContent(p, bytes) {
238
+ try {
239
+ return readFileSync(p).equals(bytes)
240
+ } catch {
241
+ return false
242
+ }
243
+ }
244
+
245
+ // ── hot swap of the running host half ─────────────────────────────────────
246
+
247
+ /** Loader entry ids this package can own. */
248
+ const SELF_ENTRY_NAMES = ['dsh-remote']
249
+
250
+ /**
251
+ * Find our own loader entry (by id, then by module specifier). Returns undefined
252
+ * when the tree does not contain us (e.g. a test loader, or a renamed row).
253
+ * @param loader - the cordis Loader service (`ctx.loader`).
254
+ */
255
+ function findSelfEntry(loader) {
256
+ let entries = []
257
+ try {
258
+ entries = [...(loader?.entries?.() ?? [])]
259
+ } catch {
260
+ return undefined
261
+ }
262
+ for (const want of SELF_ENTRY_NAMES) {
263
+ const byId = entries.find((entry) => entry?.options?.id === want)
264
+ if (byId) return byId
265
+ }
266
+ for (const want of SELF_ENTRY_NAMES) {
267
+ const byName = entries.find((entry) => entry?.options?.name === want)
268
+ if (byName) return byName
269
+ }
270
+ return undefined
271
+ }
272
+
273
+ /**
274
+ * Drop Node's module caches for this package's own files, so the next import of
275
+ * the plugin actually re-reads disk instead of handing back the cached module.
276
+ * Both caches matter: the ESM `loadCache` (a plain Map on Node 22/23, a
277
+ * `LoadCache extends Map` on Node 24 — hence the explicit `Map.prototype` calls)
278
+ * and, for CJS modules pulled in through `import()`, `require.cache`.
279
+ * @param loader - the cordis Loader service.
280
+ * @param dir - package directory whose `lib/` to evict (defaults to this
281
+ * package's own install dir; overridable so the swap can be exercised against
282
+ * a fixture install).
283
+ * @returns how many ESM entries were dropped.
284
+ */
285
+ export function clearSelfModuleCache(loader, dir = selfDir()) {
286
+ const libDir = path.join(dir, 'lib')
287
+ const loadCache = loader?.internal?.loadCache
288
+ const req = (() => {
289
+ try { return createRequire(import.meta.url) } catch { return undefined }
290
+ })()
291
+ let cleared = 0
292
+ for (const name of readdirSafe(libDir)) {
293
+ if (!name.endsWith('.js')) continue
294
+ const url = pathToFileURL(path.join(libDir, name)).href
295
+ try {
296
+ if (loadCache && Map.prototype.has.call(loadCache, url)) {
297
+ Map.prototype.delete.call(loadCache, url)
298
+ cleared += 1
299
+ }
300
+ } catch {}
301
+ try {
302
+ const file = fileURLToPath(url)
303
+ if (req?.cache && req.cache[file]) delete req.cache[file]
304
+ } catch {}
305
+ }
306
+ return cleared
307
+ }
308
+
309
+ /**
310
+ * Swap the RUNNING host half to the code now on disk: clear the module caches
311
+ * for our own files, dispose our loader entry (tools, JSON routes, SSH pools and
312
+ * every `ctx.effect` disposer go with the fiber), then re-init it so `apply()`
313
+ * runs again from the freshly imported module.
314
+ *
315
+ * Callers must not depend on this plugin after a successful reload — the fiber
316
+ * that served the current request is gone. Schedule it (see
317
+ * {@link scheduleSelfReload}) rather than awaiting it from a request handler.
318
+ * @param loader - the cordis Loader service (`ctx.loader`).
319
+ * @returns a small result object; never throws.
320
+ */
321
+ export async function reloadSelf(loader) {
322
+ const entry = findSelfEntry(loader)
323
+ if (!entry) return { ok: false, reason: 'own loader entry not found' }
324
+ try {
325
+ const cleared = clearSelfModuleCache(loader)
326
+ if (typeof entry._dispose === 'function') await entry._dispose()
327
+ else if (typeof entry.dispose === 'function') await entry.dispose()
328
+ else return { ok: false, reason: 'entry exposes no dispose hook' }
329
+ await entry.init()
330
+ return { ok: true, cleared }
331
+ } catch (err) {
332
+ return { ok: false, reason: String((err && err.message) || err) }
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Fire-and-forget {@link reloadSelf} after a short delay, so the caller can
338
+ * finish answering an HTTP request (or an update check) before its own module is
339
+ * torn down. Failures are swallowed: a failed swap leaves the running code in
340
+ * place, which is the pre-hot-update behaviour, not a crash.
341
+ * @param loader - the cordis Loader service.
342
+ * @param delayMs - grace period before the swap (default 300 ms).
343
+ * @returns true when the swap was scheduled.
344
+ */
345
+ export function scheduleSelfReload(loader, delayMs = 300) {
346
+ const timer = setTimeout(() => { void reloadSelf(loader).catch(() => {}) }, Math.max(0, delayMs))
347
+ if (typeof timer.unref === 'function') timer.unref()
348
+ return true
349
+ }
350
+
177
351
  /** Write the persisted update-mode override (settings UI). */
178
352
  export function persistUpdateMode(mode) {
179
353
  if (!['manual', 'auto', 'off'].includes(mode)) return false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.23",
3
+ "version": "0.8.24",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",