dsh-remote 0.8.22 → 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 } },
@@ -1213,9 +1225,25 @@ window.__ModuleLoader__.load({
1213
1225
  const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
1214
1226
  const suggestTimer = React.useRef(null)
1215
1227
  const suggestRef = React.useRef(null)
1228
+ // Autocomplete performance (slow per-keystroke remote scans):
1229
+ // - postedCurrentRef/currentPromiseRef: POST /current once per machine
1230
+ // switch; concurrent callers share ONE in-flight promise and a failed
1231
+ // switch REJECTS (never run /ls — or cache its listing — on the wrong
1232
+ // machine)
1233
+ // - lsCacheRef/lsInflightRef: one /ls per (machine, dir), TTL-cached
1234
+ // - lsGenRef/fetchSeqRef: per-key fetch generation — a cache write from
1235
+ // a superseded fetch (fresh navigation, picker reopen) is dropped
1236
+ // - suggestSeqRef: drop stale suggestion responses (older keystrokes)
1237
+ const postedCurrentRef = React.useRef('')
1238
+ const currentPromiseRef = React.useRef(null)
1239
+ const lsCacheRef = React.useRef(new Map())
1240
+ const lsInflightRef = React.useRef(new Map())
1241
+ const lsGenRef = React.useRef(new Map())
1242
+ const fetchSeqRef = React.useRef(0)
1243
+ const suggestSeqRef = React.useRef(0)
1216
1244
  React.useEffect(() => {
1217
1245
  if (!suggestOpen) return
1218
- const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
1246
+ const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) { suggestSeqRef.current++; setSuggestOpen(false) } }
1219
1247
  document.addEventListener('mousedown', onDown)
1220
1248
  return () => document.removeEventListener('mousedown', onDown)
1221
1249
  }, [suggestOpen])
@@ -1247,26 +1275,125 @@ window.__ModuleLoader__.load({
1247
1275
  return b.replace(/[\\/]+$/, '') + sep + n
1248
1276
  }
1249
1277
 
1250
- const loadLevels = (id, p, toIndex) => {
1251
- if (!id) return
1252
- setLoading(true); setErr('')
1253
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1254
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
1255
- .then((res) => {
1256
- setWin(res && res.platform === 'windows')
1257
- const real = res && res.path ? res.path : (p || '')
1278
+ // Identity of the machine a listing was answered for (the /ls response
1279
+ // echoes it), checked against the machine the fetch INTENDED. The picker
1280
+ // memoizes "machine already active", but the settings page can switch
1281
+ // machines too — validate every listing before trusting the memo. A
1282
+ // machine may show up as its ~/.ssh/config alias OR its resolved host, so
1283
+ // accept either identity; nothing to compare against → accept.
1284
+ const echoMatches = (id, echo) => {
1285
+ if (!echo || !echo.host) return true // older host half without an echo
1286
+ const m = (machines || []).find((x) => x && x.id === id)
1287
+ if (!m) return true
1288
+ const cands = [{ host: String(m.host || ''), port: Number(m.port) || 22, username: String(m.username || '') }]
1289
+ const r = m.sshConfigResolved
1290
+ if (r && r.host) cands.push({ host: String(r.host), port: Number(r.port) || 22, username: String(r.username || m.username || '') })
1291
+ return cands.some((c) => c.host === String(echo.host) && c.port === (Number(echo.port) || 22) && c.username === String(echo.username || ''))
1292
+ }
1293
+
1294
+ // POST /current once per machine switch. The picker used to post it before
1295
+ // EVERY /ls; the host re-applies the machine on that route, which used to
1296
+ // close the live SSH connection — so each autocomplete keystroke paid a
1297
+ // full reconnect. The memo is set only on SUCCESS and concurrent callers
1298
+ // share ONE in-flight promise, so no request can overtake a pending switch
1299
+ // and read the OLD machine's listing. A failed switch REJECTS: the
1300
+ // caller's fetch chain aborts WITHOUT writing the cache.
1301
+ const ensureCurrent = (id) => {
1302
+ if (!id) return Promise.resolve()
1303
+ if (postedCurrentRef.current === id) return Promise.resolve()
1304
+ const inflight = currentPromiseRef.current
1305
+ if (inflight) {
1306
+ if (inflight.id === id) return inflight.promise
1307
+ // Serialize switches: ours must land AFTER any pending one (the host
1308
+ // applies whichever POST arrives last), never race it.
1309
+ return inflight.promise.then(() => ensureCurrent(id), () => ensureCurrent(id))
1310
+ }
1311
+ const p = api('POST', '/dsh-remote/current', { id }).then(() => {
1312
+ postedCurrentRef.current = id
1313
+ })
1314
+ const entry = { id, promise: p }
1315
+ currentPromiseRef.current = entry
1316
+ const clear = () => { if (currentPromiseRef.current === entry) currentPromiseRef.current = null }
1317
+ p.then(clear, clear)
1318
+ return p
1319
+ }
1320
+
1321
+ const LS_TTL_MS = 30000
1322
+ // Cached remote listing per (machine, dir): typing inside one parent
1323
+ // directory used to re-fetch the identical list on every keystroke.
1324
+ // `fresh` bypasses the cache (browse navigation / post-mkdir refresh) and
1325
+ // SUPERSEDES older fetches for the key: a cache write from a superseded
1326
+ // fetch is dropped (it still resolves its own callers), so a pre-mkdir
1327
+ // listing can never overwrite the post-mkdir one. Concurrent identical
1328
+ // requests share one in-flight fetch.
1329
+ const fetchDirList = (id, dirParam, fresh) => {
1330
+ const key = String(id) + '\u0000' + String(dirParam)
1331
+ const hit = lsCacheRef.current.get(key)
1332
+ if (!fresh && hit && Date.now() - hit.at < LS_TTL_MS) return Promise.resolve(hit.node)
1333
+ const inflight = fresh ? null : lsInflightRef.current.get(key)
1334
+ if (inflight) return inflight
1335
+ const gen = ++fetchSeqRef.current
1336
+ lsGenRef.current.set(key, gen)
1337
+ const toNode = (res) => {
1338
+ const real = res && res.path ? res.path : String(dirParam)
1258
1339
  // Server returns [{ type, name, size, mtime, path }] where `path` is
1259
1340
  // the FULL display-form path (C:\Users\dev on Windows) — the client
1260
1341
  // never joins paths itself, so drives and Windows paths just work.
1261
1342
  const list = Array.isArray(res && res.items)
1262
1343
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
1263
1344
  : parseLs(res && res.text)
1264
- const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
1345
+ return {
1346
+ path: real,
1347
+ platform: !!(res && res.platform === 'windows'),
1348
+ dirs: sortItems(list.filter((it) => it.dir)),
1349
+ all: sortItems(list),
1350
+ }
1351
+ }
1352
+ const run = (retried) => ensureCurrent(id)
1353
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(dirParam)))
1354
+ .then((res) => {
1355
+ // External-switch defense: on an identity-echo mismatch the host is
1356
+ // no longer on the machine this fetch intended — bust the memo, force
1357
+ // one re-switch and retry ONCE. A mismatched listing is never cached.
1358
+ if (!echoMatches(id, res && res.machine)) {
1359
+ if (retried) return toNode(res)
1360
+ postedCurrentRef.current = ''
1361
+ return run(true)
1362
+ }
1363
+ const node = toNode(res)
1364
+ if (lsGenRef.current.get(key) === gen) lsCacheRef.current.set(key, { at: Date.now(), node })
1365
+ return node
1366
+ })
1367
+ const p = run(false)
1368
+ .finally(() => { if (lsInflightRef.current.get(key) === p) lsInflightRef.current.delete(key) })
1369
+ lsInflightRef.current.set(key, p)
1370
+ return p
1371
+ }
1372
+
1373
+ // Fresh session on every open: forget everything a previous open left
1374
+ // behind. A stale in-flight response must not repopulate the cache
1375
+ // (generation fence) and a stale suggestion response must not reopen the
1376
+ // dropdown on the now-live tree.
1377
+ const resetPickerFetchState = () => {
1378
+ postedCurrentRef.current = ''
1379
+ lsCacheRef.current.clear()
1380
+ lsInflightRef.current.clear()
1381
+ lsGenRef.current.clear()
1382
+ suggestSeqRef.current++
1383
+ }
1384
+
1385
+ const loadLevels = (id, p, toIndex) => {
1386
+ if (!id) return
1387
+ setLoading(true); setErr('')
1388
+ fetchDirList(id, p || '', true)
1389
+ .then((node) => {
1390
+ setWin(node.platform)
1391
+ const level = { path: node.path, dirs: node.dirs, all: node.all }
1265
1392
  setLevels((prev) => {
1266
1393
  const base = prev && prev.length ? prev.slice() : []
1267
1394
  let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
1268
- if (idx >= base.length) return base.concat([node])
1269
- base[idx] = node
1395
+ if (idx >= base.length) return base.concat([level])
1396
+ base[idx] = level
1270
1397
  return base.slice(0, idx + 1)
1271
1398
  })
1272
1399
  })
@@ -1279,6 +1406,12 @@ window.__ModuleLoader__.load({
1279
1406
  // path — reset the path bar (and the browse state) on every open.
1280
1407
  setPath(''); setLevels(null); setWin(false)
1281
1408
  setLocalPop(null); setNewDirName('')
1409
+ // Fresh session: re-post /current on the first request and forget
1410
+ // everything a previous open left behind (TTL bounds staleness within
1411
+ // one open; reopening should see the live tree) — cached AND in-flight
1412
+ // listings, plus any pending suggestion response.
1413
+ resetPickerFetchState()
1414
+ if (suggestTimer.current) { clearTimeout(suggestTimer.current); suggestTimer.current = null }
1282
1415
  api('GET', '/dsh-remote/machines').then((r) => {
1283
1416
  setMachines(r.machines || [])
1284
1417
  const cur = (r.machines || []).find((m) => m.id === r.currentId)
@@ -1373,11 +1506,38 @@ window.__ModuleLoader__.load({
1373
1506
  if (par) loadLevels(machineId, par, 1)
1374
1507
  }
1375
1508
 
1509
+ // Drop any pending suggestion response and close the dropdown.
1510
+ const killSuggestions = () => {
1511
+ suggestSeqRef.current++
1512
+ setSuggest([])
1513
+ setSuggestOpen(false)
1514
+ }
1515
+
1516
+ // One seq-guarded "fetch listing → derive suggestion paths" pipeline
1517
+ // shared by loadSuggestions (typing: dirs matching a prefix) and
1518
+ // continueSuggest (expanding a picked dir: all its entries) — the
1519
+ // differing parts stay at the call sites as the `pick` parameter. Only
1520
+ // the newest request owns the dropdown: a slow older /ls must never
1521
+ // overwrite the suggestions for the text now in the input. The catch
1522
+ // keeps the seq-guarded empty fallback (a failed machine switch rejects
1523
+ // too, and must leave the dropdown closed and empty).
1524
+ const suggestFrom = (id, dirParam, pick, fresh) => {
1525
+ const seq = ++suggestSeqRef.current
1526
+ return fetchDirList(id, dirParam, fresh).then((node) => {
1527
+ if (seq !== suggestSeqRef.current) return
1528
+ const matches = pick(node).slice(0, 40)
1529
+ setSuggest(matches)
1530
+ setSuggestOpen(!!matches.length)
1531
+ }).catch(() => {
1532
+ if (seq !== suggestSeqRef.current) return
1533
+ setSuggest([]); setSuggestOpen(false)
1534
+ })
1535
+ }
1536
+
1376
1537
  const loadSuggestions = (raw, mid) => {
1377
1538
  const id = mid || machineId
1378
- if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
1379
1539
  const t = String(raw || '').trim()
1380
- if (!t) { setSuggest([]); setSuggestOpen(false); return }
1540
+ if (!id || !t) { killSuggestions(); return }
1381
1541
  // Separator-aware split: `\` when the input looks like a Windows path.
1382
1542
  const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
1383
1543
  const sep = backslash ? '\\' : '/'
@@ -1386,18 +1546,9 @@ window.__ModuleLoader__.load({
1386
1546
  if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
1387
1547
  else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
1388
1548
  else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
1389
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1390
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
1391
- .then((res) => {
1392
- const list = Array.isArray(res && res.items)
1393
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
1394
- : []
1395
- const matches = list
1396
- .filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
1397
- .slice(0, 40).map((it) => it.path)
1398
- setSuggest(matches)
1399
- setSuggestOpen(!!matches.length)
1400
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1549
+ suggestFrom(id, parent || '/', (node) => node.dirs
1550
+ .filter((it) => !lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
1551
+ .map((it) => it.path))
1401
1552
  }
1402
1553
 
1403
1554
  const onPathChange = (raw) => {
@@ -1407,18 +1558,13 @@ window.__ModuleLoader__.load({
1407
1558
  }
1408
1559
 
1409
1560
  const continueSuggest = (dir) => {
1410
- if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
1561
+ if (!machineId || !dir) { killSuggestions(); return }
1411
1562
  setSuggestOpen(false)
1412
- api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
1413
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
1414
- .then((res) => {
1415
- const list = Array.isArray(res && res.items)
1416
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
1417
- : []
1418
- const kids = list.slice(0, 40).map((it) => it.path)
1419
- setSuggest(kids)
1420
- setSuggestOpen(!!kids.length)
1421
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1563
+ const target = String(dir).replace(/[\\/]+$/, '') || '/'
1564
+ // Expanding a just-selected directory is a NAVIGATION step: the caching
1565
+ // spec only sanctions "typing inside one parent directory, local
1566
+ // filtering", so fetch fresh instead of serving up to 30s-stale entries.
1567
+ suggestFrom(machineId, target, (node) => node.all.map((it) => it.path), true)
1422
1568
  }
1423
1569
 
1424
1570
  const selectSuggestion = (s) => {
@@ -1434,21 +1580,34 @@ window.__ModuleLoader__.load({
1434
1580
  const commitPath = (p) => {
1435
1581
  const target = String(p || '').trim()
1436
1582
  if (!target || !machineId || busy) return
1437
- setPopOpen(false); setSuggestOpen(false)
1438
- api('POST', '/dsh-remote/mirror', { path: target }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
1583
+ setPopOpen(false); killSuggestions()
1584
+ // State the machine this pick was made on. The picker acts on the shared
1585
+ // active pool, and another actor (the settings page, or rw_connect in
1586
+ // another session) can switch it while the picker is open — the mirror
1587
+ // would then be created with THAT machine's identity while the user was
1588
+ // looking at this one's tree. The host re-asserts the named machine
1589
+ // instead of trusting whichever happens to be active.
1590
+ ensureCurrent(machineId)
1591
+ .then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId }))
1592
+ .then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || '')))
1593
+ .catch((e) => setErr(String((e && e.message) || e)))
1439
1594
  }
1440
1595
 
1441
1596
  const acceptBrowserPick = (p) => {
1442
1597
  setPath(String(p || ''))
1443
- setSuggestOpen(false)
1598
+ killSuggestions()
1444
1599
  setPopOpen(false)
1445
1600
  }
1446
1601
 
1447
1602
  const pickHome = () => {
1448
- api('POST', '/dsh-remote/home').then((r) => {
1449
- if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1450
- else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1451
- }).catch((e) => setErr(String((e && e.message) || e)))
1603
+ // Same identity contract as commitPath: /home is answered by the active
1604
+ // pool, so name the machine this picker is showing.
1605
+ ensureCurrent(machineId)
1606
+ .then(() => api('POST', '/dsh-remote/home', { machineId }))
1607
+ .then((r) => {
1608
+ if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1609
+ else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1610
+ }).catch((e) => setErr(String((e && e.message) || e)))
1452
1611
  }
1453
1612
 
1454
1613
  const mkdirHere = () => {
@@ -1584,7 +1743,7 @@ window.__ModuleLoader__.load({
1584
1743
  if (mkdirPrompt) return
1585
1744
  if (e.key === 'Escape') {
1586
1745
  if (popOpen) { e.preventDefault(); setPopOpen(false); return }
1587
- if (suggestOpen) { e.preventDefault(); setSuggestOpen(false); return }
1746
+ if (suggestOpen) { e.preventDefault(); suggestSeqRef.current++; setSuggestOpen(false); return }
1588
1747
  if (!busy) { e.preventDefault(); onCancel() }
1589
1748
  return
1590
1749
  }
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
@@ -360,18 +373,47 @@ export async function apply(ctx, config) {
360
373
  return machinePassword(m)
361
374
  }
362
375
 
376
+ /** The identity fields pool.setTarget diffs. Used to snapshot the diff
377
+ * baseline BEFORE applyMachine mutates the shared config in place. */
378
+ const targetFields = (c) => ({
379
+ host: c.host, port: c.port, username: c.username,
380
+ password: c.password, privateKeyPath: c.privateKeyPath,
381
+ passphrase: c.passphrase, workspace: c.workspace,
382
+ useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
383
+ proxy: c.proxy, hostKeyMode: c.hostKeyMode,
384
+ })
385
+
386
+ /** Fingerprint of the machine record last applied to the shared pool config.
387
+ * Re-applying an identical record is a no-op: the picker posts /current before
388
+ * every /ls and used to rewrite the registry + re-read credentials + re-point
389
+ * the pool on every autocomplete keystroke. A record change (settings edit)
390
+ * changes the fingerprint, so the next /current re-applies it. The value is
391
+ * maintained INSIDE applyActiveMachine / clearActiveMachine (not in
392
+ * setCurrent) so every apply path — boot restore, rw_connect save:true,
393
+ * POST /current — keeps the "fingerprint == applied" invariant without extra
394
+ * bookkeeping. */
395
+ let lastAppliedMachine = ''
396
+
397
+ /** Fingerprint of the registry record the shared config is bound to ('' when
398
+ * the binding is ephemeral or no machine is current). */
399
+ const appliedFingerprint = () => {
400
+ if (ephemeral || !store.currentId) return ''
401
+ const i = machineIndex(store.currentId)
402
+ return i >= 0 ? JSON.stringify(machines[i]) : ''
403
+ }
404
+
363
405
  const applyActiveMachine = async () => {
364
406
  const m = activeMachine()
365
407
  if (!m || !m.host) return
366
408
  const pw = await machinePassword(m)
409
+ // Snapshot the diff baseline BEFORE applyMachine mutates the shared config
410
+ // IN PLACE: pool.config IS this config object, so setTarget would otherwise
411
+ // compare the new values against themselves, find them equal and keep the
412
+ // OLD connection alive across a machine switch (issue #25 hazard).
413
+ const previous = targetFields(config)
367
414
  applyMachine(config, { ...m, password: pw || m.password || '' })
368
- pool.setTarget({
369
- host: config.host, port: config.port, username: config.username,
370
- password: config.password, privateKeyPath: config.privateKeyPath,
371
- passphrase: config.passphrase, workspace: config.workspace,
372
- useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
373
- proxy: config.proxy, hostKeyMode: config.hostKeyMode,
374
- })
415
+ pool.setTarget(targetFields(config), previous)
416
+ lastAppliedMachine = appliedFingerprint()
375
417
  }
376
418
 
377
419
  // ── per-machine SSH pools (session-bound) ─────────────────────────────────
@@ -501,6 +543,34 @@ export async function apply(ctx, config) {
501
543
  return created
502
544
  }
503
545
 
546
+ /**
547
+ * Re-assert the ACTIVE machine that a machine-scoped request names.
548
+ *
549
+ * The workspace picker (and the settings page's workspace flow) acts on the
550
+ * shared active pool, but it remembers its own `machineId` locally. Nothing
551
+ * on `/mirror` / `/home` / `/fs` used to carry that identity, so a switch
552
+ * performed by ANOTHER actor between the picker's last fetch and its commit —
553
+ * the settings page's "set as current", or an `rw_connect` in another agent
554
+ * session — silently redirected the commit onto that other machine. Before
555
+ * this PR the picker re-posted /current on every keystroke, which masked the
556
+ * window; the per-keystroke reconnect fix removes that accident. Let the
557
+ * request state its intent so the host can honour it atomically instead.
558
+ *
559
+ * An absent / empty `machineId` keeps the old behaviour (the caller is
560
+ * machine-scoped through the active pool by design). An UNKNOWN id is
561
+ * refused rather than ignored: quietly mirroring onto whichever machine
562
+ * happens to be active is exactly the hazard this closes.
563
+ *
564
+ * @returns {Promise<boolean>} false when the request was already answered.
565
+ */
566
+ const applyRequestedMachine = async (body, res) => {
567
+ const id = String((body && body.machineId) || '').trim()
568
+ if (!id) return true
569
+ if (await setCurrent(id)) return true
570
+ sendJson(res, 404, { ok: false, error: `machine not found: ${id}` })
571
+ return false
572
+ }
573
+
504
574
  const setCurrent = async (id) => {
505
575
  if (!id) {
506
576
  // Explicit "active remote = none": saved machines stay in the registry
@@ -513,6 +583,11 @@ export async function apply(ctx, config) {
513
583
  }
514
584
  const i = machineIndex(id)
515
585
  if (i < 0) return false
586
+ // Idempotent: same machine, no ephemeral override, already applied → keep
587
+ // the registry file, the credentials cache and the live pool untouched.
588
+ // (The fingerprint is maintained by applyActiveMachine itself.)
589
+ const fingerprint = JSON.stringify(machines[i])
590
+ if (store.currentId === id && !ephemeral && config.host && lastAppliedMachine === fingerprint) return true
516
591
  store.currentId = id
517
592
  ephemeral = null
518
593
  saveMachines(machines, id)
@@ -525,7 +600,13 @@ export async function apply(ctx, config) {
525
600
  * tool fallback, but no machine workspace leaks into this session. */
526
601
  const clearActiveMachine = async () => {
527
602
  ephemeral = null
603
+ // Nothing is applied anymore — reset the applied-machine fingerprint too.
604
+ lastAppliedMachine = ''
528
605
  if (!config.host) return
606
+ // Snapshot the diff baseline BEFORE zeroing: pool.config IS this config
607
+ // object, so setTarget would otherwise see an already-zeroed config and
608
+ // keep the old connection alive ("active remote = none" must disconnect).
609
+ const previous = targetFields(config)
529
610
  // Clear host + workspace directly (applyMachine keeps the old workspace
530
611
  // when the incoming value is empty — it must not be used for a full reset).
531
612
  config.host = ''
@@ -533,7 +614,7 @@ export async function apply(ctx, config) {
533
614
  config.username = ''
534
615
  config.password = ''
535
616
  config.workspace = ''
536
- pool.setTarget({ host: '', port: 22, username: '', workspace: '' })
617
+ pool.setTarget({ host: '', port: 22, username: '', workspace: '' }, previous)
537
618
  // Also stop any auto-push watcher bound to the old machine workspace.
538
619
  // (autoPushWatchers is declared later; read it lazily to avoid TDZ.)
539
620
  if (typeof autoPushWatchers !== 'undefined' && autoPushWatchers) {
@@ -1180,9 +1261,17 @@ export async function apply(ctx, config) {
1180
1261
  saveMachines(machines, store.currentId)
1181
1262
  }
1182
1263
  ephemeral = null
1264
+ // rw_connect means "connect now": force a fresh dial even for an
1265
+ // identical target (setTarget alone would reuse a possibly half-open
1266
+ // cached client — same reconnect-probe reasoning as /connect).
1267
+ pool.invalidate()
1183
1268
  await applyActiveMachine()
1184
1269
  } else {
1185
1270
  ephemeral = { id: machineId(), name: host, ...rec }
1271
+ // rw_connect means "connect now": force a fresh dial even for an
1272
+ // identical target (setTarget alone would reuse a possibly half-open
1273
+ // cached client — same reconnect-probe reasoning as /connect).
1274
+ pool.invalidate()
1186
1275
  pool.setTarget({
1187
1276
  host: eff.host, port: effPort, username: effUser,
1188
1277
  password: rec.password, privateKeyPath: eff.privateKeyPath || '',
@@ -2181,6 +2270,12 @@ export async function apply(ctx, config) {
2181
2270
  let payload = {}
2182
2271
  try {
2183
2272
  payload = parseJsonObject(await readBody(req))
2273
+ // This route is a reconnect PROBE ("Connect" button): with the
2274
+ // unchanged-target no-op in setTarget, an identical target would
2275
+ // reuse a possibly half-open cached client and the probe could hang.
2276
+ // invalidate() is the design's forced-reconnect escape hatch — drop
2277
+ // the cached client so the `echo ok` below always re-dials.
2278
+ pool.invalidate()
2184
2279
  pool.setTarget({
2185
2280
  host: payload.host,
2186
2281
  port: payload.port,
@@ -2230,6 +2325,11 @@ export async function apply(ctx, config) {
2230
2325
  const p = normalizeRemotePath(String(payload.path || ''))
2231
2326
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
2232
2327
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
2328
+ // The picker names the machine it was browsing. Re-assert it so a
2329
+ // switch made elsewhere between the listing and this commit cannot
2330
+ // mirror onto a machine the user never looked at (the picker sends
2331
+ // /current only once per machine now, so nothing else re-applies it).
2332
+ if (!(await applyRequestedMachine(payload, res))) return
2233
2333
  const okDir = await isRemoteDir(p)
2234
2334
  if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
2235
2335
  const local = ensureMirror(p, config.host, config.username, config.port, { alias: aliasOf(activeMachine()) })
@@ -2659,6 +2759,11 @@ export async function apply(ctx, config) {
2659
2759
  handler: async (req, res) => {
2660
2760
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2661
2761
  try {
2762
+ const body = parseJsonObject(await readBody(req))
2763
+ // The picker names the machine it is showing: re-assert it before
2764
+ // answering, so a switch made elsewhere cannot make /home describe a
2765
+ // different host than the tree the user is looking at.
2766
+ if (!(await applyRequestedMachine(body, res))) return
2662
2767
  const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
2663
2768
  const home = String(out.stdout || '').replace(/\s+/g, '').trim()
2664
2769
  if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
@@ -2673,17 +2778,26 @@ export async function apply(ctx, config) {
2673
2778
  path: '/dsh-remote/update-check',
2674
2779
  handler: async (req, res) => {
2675
2780
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2676
- const current = readVersion()
2677
- const latest = await fetchLatestVersion()
2678
- 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()
2679
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' })
2680
2796
  return sendJson(res, 200, {
2681
2797
  ok: true,
2682
- current,
2798
+ ...base,
2683
2799
  latest,
2684
- updateAvailable: gtVersion(latest, current),
2685
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2686
- updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2800
+ updateAvailable: gtVersion(latest, loaded),
2687
2801
  })
2688
2802
  },
2689
2803
  },
@@ -2693,16 +2807,45 @@ export async function apply(ctx, config) {
2693
2807
  handler: async (req, res) => {
2694
2808
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
2695
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
2696
2814
  const body = JSON.parse((await readBody(req)) || '{}')
2697
2815
  const target = String(body.version || '')
2698
2816
  if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
2699
2817
  const result = await applyUpdate(target)
2700
- 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
+ })
2701
2831
  } catch (err) {
2702
2832
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
2703
2833
  }
2704
2834
  },
2705
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
+ },
2706
2849
  {
2707
2850
  kind: 'exact',
2708
2851
  path: '/dsh-remote/update-mode',
@@ -2729,12 +2872,24 @@ export async function apply(ctx, config) {
2729
2872
  const rawMode = readUpdateMode() || config.updateMode || 'manual'
2730
2873
  const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2731
2874
  if (effectiveUpdateMode === 'auto') {
2732
- const currentVersion = readVersion()
2733
2875
  const checkAndApply = async () => {
2734
2876
  const latest = await fetchLatestVersion()
2735
- if (latest && gtVersion(latest, currentVersion)) {
2736
- 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
2737
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)
2738
2893
  }
2739
2894
  void checkAndApply()
2740
2895
  const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
package/lib/pool.js CHANGED
@@ -41,24 +41,48 @@ export class SshPool {
41
41
  return p
42
42
  }
43
43
 
44
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
45
- if (host !== undefined) this.config.host = String(host)
46
- if (port !== undefined && Number(port)) this.config.port = Number(port)
47
- if (username !== undefined) this.config.username = String(username)
48
- if (password !== undefined && password !== null) this.config.password = String(password)
49
- if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
50
- if (passphrase !== undefined) this.config.passphrase = String(passphrase)
51
- if (workspace !== undefined) this.config.workspace = String(workspace)
52
- if (useAgent !== undefined) this.config.useAgent = !!useAgent
53
- if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
54
- if (proxy !== undefined) this.config.proxy = proxy
55
- if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
56
- // the new target may be a different OS — re-detect on the next command
57
- this.platform = 'unknown'
58
- this.gitBashPath = ''
59
- this.shellMode = 'native'
60
- this._detecting = null
61
- this.close()
44
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
45
+ // Collect the requested changes first: an UNCHANGED target is a no-op. The
46
+ // old behavior closed the live connection and re-ran platform detection
47
+ // even for an identical target, so every picker keystroke
48
+ // (POST /current → applyActiveMachine → setTarget) paid a full SSH
49
+ // reconnect + re-detect — the "slow remote path autocomplete" bug.
50
+ //
51
+ // `previous` is the diff BASELINE. Callers whose shared config was already
52
+ // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
53
+ // live config IN PLACE, and pool.config IS that config object) pass a
54
+ // pre-mutation snapshot — otherwise the comparison runs the new values
55
+ // against themselves, is always equal and never closes even for a machine
56
+ // switch (the wrong-host hazard of issue #25). Defaults to the current
57
+ // config for callers that pass fresh values read independently of it.
58
+ const base = previous || this.config
59
+ const wanted = []
60
+ if (host !== undefined) wanted.push(['host', String(host)])
61
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
62
+ if (username !== undefined) wanted.push(['username', String(username)])
63
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
64
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
65
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
66
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
67
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
68
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
69
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
70
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
71
+ const differs = ([k, v]) => (k === 'proxy'
72
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
73
+ : base[k] !== v)
74
+ // Something actually changed → apply it and force a fresh connection.
75
+ // (A caller that WANTS a reconnect for an identical target uses
76
+ // invalidate(); setTarget semantics stay "re-point when moved".)
77
+ if (wanted.some(differs)) {
78
+ for (const [k, v] of wanted) this.config[k] = v
79
+ // the new target may be a different OS — re-detect on the next command
80
+ this.platform = 'unknown'
81
+ this.gitBashPath = ''
82
+ this.shellMode = 'native'
83
+ this._detecting = null
84
+ this.close()
85
+ }
62
86
  return this
63
87
  }
64
88
 
@@ -484,6 +508,13 @@ export class SshPool {
484
508
  if (client) {
485
509
  try { client.end() } catch {}
486
510
  }
511
+ // Same teardown as close(): the forwards manager must detach from the
512
+ // dying client, or its stale `close -> stopAll()` listener later tears
513
+ // down forwards that were re-attached to the NEW client (onReady does
514
+ // the re-attach on the next successful connect).
515
+ if (this.onCloseHook) {
516
+ try { this.onCloseHook() } catch {}
517
+ }
487
518
  }
488
519
 
489
520
  close() {
package/lib/routes-fs.js CHANGED
@@ -40,6 +40,17 @@ export function createFsRoutes({
40
40
  const hint = requestSessionHint(req)
41
41
  const b = await bindOrReply(req, res, hint)
42
42
  if (!b) return
43
+ // Identity echo (external-switch defense): name the machine this
44
+ // listing is answered for, straight from the pool's target — the
45
+ // picker validates every listing against the machine it INTENDED and
46
+ // re-switches on mismatch (the settings page can change the active
47
+ // machine under a memoized picker).
48
+ const tgt = (b.pool && b.pool.config) || {}
49
+ const machine = {
50
+ host: String(tgt.host || b.host || ''),
51
+ port: Number(tgt.port) || Number(b.port) || 22,
52
+ username: String(tgt.username || b.username || ''),
53
+ }
43
54
  await b.pool.detect()
44
55
  const q = new URL(req.url, 'http://localhost').searchParams
45
56
  const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
@@ -48,9 +59,9 @@ export function createFsRoutes({
48
59
  if (canon === '/' || raw === '' || raw === '/') {
49
60
  if (win || b.pool.platform !== 'posix') {
50
61
  const drives = await listRemoteDrives(b.pool)
51
- if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
62
+ if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
52
63
  }
53
- if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
64
+ if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
54
65
  }
55
66
  const sftp = await b.pool.sftp()
56
67
  const out = await listDirStructured(sftp, canon)
@@ -64,6 +75,7 @@ export function createFsRoutes({
64
75
  items,
65
76
  missing: !!out.missing,
66
77
  bound: b.bound,
78
+ machine,
67
79
  })
68
80
  } catch (err) {
69
81
  return sendJson(res, 500, { error: String((err && err.message) || err) })
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.22",
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>",