dsh-instruction-memory 1.0.13 → 1.0.15

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/index.js CHANGED
@@ -10,9 +10,10 @@
10
10
  * plugin's fiber, so unmounting the row removes all of them.
11
11
  */
12
12
 
13
- import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
13
+ import { copyFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
14
+ import { watch } from 'node:fs'
14
15
  import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
16
+ import { basename, dirname, join } from 'node:path'
16
17
 
17
18
  export const name = 'instruction-memory'
18
19
 
@@ -105,6 +106,13 @@ const MAX_WHEN = 200
105
106
  */
106
107
  export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
107
108
 
109
+ /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
110
+ const LOCK_STALE_MS = 10_000
111
+ /** How long a write waits for the cross-process lock before proceeding unlocked. */
112
+ const LOCK_WAIT_MS = 2_000
113
+ /** Coalescing window for store-directory watcher events before a re-read. */
114
+ const WATCH_DEBOUNCE_MS = 250
115
+
108
116
  /* ------------------------------------------------------------------ *
109
117
  * Pure helpers (no ctx, fully unit-testable)
110
118
  * ------------------------------------------------------------------ */
@@ -187,18 +195,64 @@ export function truncatedFields(raw) {
187
195
  return cut
188
196
  }
189
197
 
198
+ /** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
199
+ const describeValue = (value) => {
200
+ const text = JSON.stringify(value)
201
+ return typeof text === 'string' ? text : String(value)
202
+ }
203
+
190
204
  /**
191
205
  * Load a parsed memory file. Returns the sanitized data plus how many array
192
206
  * items were discarded (blank entries, or valid ones past the cap). The count
193
207
  * is surfaced rather than swallowed because the next save rewrites the file
194
208
  * without those items — silently destroying user data is exactly what this
195
209
  * plugin must never do.
210
+ *
211
+ * `rev` is the store revision, bumped on every committed write and persisted
212
+ * in the file. Clients echo it back as `baseRev` so a write built on a stale
213
+ * snapshot (another window, another process) can be refused instead of
214
+ * silently overwriting it. Missing (pre-rev files) reads as 0.
215
+ *
216
+ * A numeric `version` other than 1 sets `rejectedVersion` and leaves the data
217
+ * untouched: a future format's fields cannot be best-effort parsed as v1, and
218
+ * adopting a misread would let the next save rewrite the file in the wrong
219
+ * shape. A MISSING version still loads as v1 — the field has been written as
220
+ * 1 since the first release, so absence is an old/hand-made file, not a
221
+ * foreign format.
222
+ *
223
+ * `optionFixes` lists options the file carried but the loader had to correct
224
+ * (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`).
225
+ * Each is a human-readable "field 原值 → 修正值 (原因)" string, surfaced the
226
+ * same way as `dropped`: the next save persists the corrected values, so the
227
+ * user must be told their hand edit did not survive verbatim.
196
228
  */
197
229
  function normalizeData(raw) {
198
- const data = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
199
- if (raw === null || typeof raw !== 'object') return { data, dropped: 0 }
200
- if (typeof raw.enabled === 'boolean') data.enabled = raw.enabled
201
- data.budgetChars = clampBudget(raw.budgetChars)
230
+ const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
231
+ if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
232
+ if (typeof raw.version === 'number' && raw.version !== 1) {
233
+ return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
234
+ }
235
+ if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
236
+ data.rev = Math.round(raw.rev)
237
+ }
238
+ // Hand-edited options the loader has to correct are disclosed, not silently
239
+ // adopted: the next save would otherwise rewrite the file with the fixed
240
+ // values and the user would never learn their edit had been ignored.
241
+ const optionFixes = []
242
+ if (typeof raw.enabled === 'boolean') {
243
+ data.enabled = raw.enabled
244
+ } else if ('enabled' in raw) {
245
+ optionFixes.push('enabled ' + describeValue(raw.enabled) + ' → true(需要 true/false)')
246
+ }
247
+ if ('budgetChars' in raw) {
248
+ const clamped = clampBudget(raw.budgetChars)
249
+ if (clamped !== raw.budgetChars) {
250
+ optionFixes.push(typeof raw.budgetChars === 'number' && Number.isFinite(raw.budgetChars)
251
+ ? 'budgetChars ' + raw.budgetChars + ' → ' + clamped + '(有效范围 ' + MIN_BUDGET + '–' + MAX_BUDGET + ')'
252
+ : 'budgetChars ' + describeValue(raw.budgetChars) + ' → ' + clamped + '(需要 ' + MIN_BUDGET + '–' + MAX_BUDGET + ' 的数字)')
253
+ }
254
+ data.budgetChars = clamped
255
+ }
202
256
  const list = Array.isArray(raw.entries) ? raw.entries : []
203
257
  const seen = new Set()
204
258
  let dropped = 0
@@ -216,7 +270,7 @@ function normalizeData(raw) {
216
270
  seen.add(entry.id)
217
271
  data.entries.push(entry)
218
272
  }
219
- return { data, dropped }
273
+ return { data, dropped, optionFixes }
220
274
  }
221
275
 
222
276
  /**
@@ -231,6 +285,7 @@ function normalizeData(raw) {
231
285
  function cloneData(data) {
232
286
  return {
233
287
  version: 1,
288
+ rev: data.rev || 0,
234
289
  enabled: data.enabled === true,
235
290
  budgetChars: data.budgetChars,
236
291
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
@@ -242,6 +297,22 @@ const droppedEntriesMessage = (count) =>
242
297
  ? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
243
298
  : null
244
299
 
300
+ /**
301
+ * The load-time disclosures for one normalizeData result: dropped entries and
302
+ * corrected hand-edited options, joined with a full-width semicolon.
303
+ * Returns null when neither applies, so callers can assign it directly to
304
+ * `state.storageWarning` (a null means "no warning", not an empty string).
305
+ */
306
+ const loadWarnings = (loaded) => {
307
+ const parts = []
308
+ const dropped = droppedEntriesMessage(loaded.dropped)
309
+ if (dropped !== null) parts.push(dropped)
310
+ if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
311
+ parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
312
+ }
313
+ return parts.length > 0 ? parts.join(';') : null
314
+ }
315
+
245
316
  /**
246
317
  * Hostnames a loopback-bound server may legitimately be reached by.
247
318
  *
@@ -269,8 +340,9 @@ export function hostnameOf(value) {
269
340
  }
270
341
 
271
342
  /**
272
- * Why the settings-page route checks `Host` and `Origin` at all, when it already
273
- * refuses any content-type that is not `application/json`.
343
+ * Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
344
+ * all, when it already refuses any content-type that is not
345
+ * `application/json`.
274
346
  *
275
347
  * The content-type rule stops a cross-site form post, and no CORS headers are
276
348
  * sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
@@ -283,10 +355,21 @@ export function hostnameOf(value) {
283
355
  * change is the `Host` header the browser sends: it still carries `evil.com`.
284
356
  *
285
357
  * So when the server is bound to loopback (the default) the route requires a
286
- * loopback `Host`. An operator who binds `0.0.0.0` has deliberately published
287
- * the whole server on every interface, where no Host value can be known in
288
- * advance — the check is disabled there rather than breaking every legitimate
289
- * client, and that trade-off is documented in the README.
358
+ * loopback `Host`. An operator who binds a non-loopback address (`0.0.0.0`,
359
+ * `::`, or a concrete one) has deliberately published the server beyond this
360
+ * machine, where no Host value can be known in advance — the check is disabled
361
+ * there rather than breaking every legitimate client, and that trade-off is
362
+ * documented in the README.
363
+ *
364
+ * `Sec-Fetch-Site` adds a second browser-only signal (absent from curl, Node,
365
+ * and pre-2020 browsers, which is exactly why it can only ADD rejections, never
366
+ * replace the checks above): a modern browser stamps every request with it, so
367
+ * a page at evil.com talking to this route — even through tricks that drop the
368
+ * `Origin` header — still says `cross-site`. Only `cross-site` is refused;
369
+ * `same-site` must pass because schemeful same-site ignores ports (the GUI and
370
+ * the route live on the same host but different ports), and local non-browser
371
+ * clients never send the header at all, so they are governed by the Host and
372
+ * content-type checks alone.
290
373
  */
291
374
  export function requestOriginVerdict(headers, guarded) {
292
375
  const read = (name) => {
@@ -301,6 +384,11 @@ export function requestOriginVerdict(headers, guarded) {
301
384
  return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
302
385
  }
303
386
 
387
+ const fetchSite = read('sec-fetch-site').trim().toLowerCase()
388
+ if (fetchSite === 'cross-site') {
389
+ return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
390
+ }
391
+
304
392
  const origin = read('origin').trim()
305
393
  if (origin !== '') {
306
394
  let originHostname = ''
@@ -435,6 +523,13 @@ export function apply(ctx) {
435
523
  loadedFrom: null,
436
524
  savedAt: null,
437
525
  migratedFrom: null,
526
+ /**
527
+ * Set while the store on disk declares an unsupported format version.
528
+ * Mutating commands are refused in that state: a save would rewrite a
529
+ * future-format file into v1, destroying whatever the newer format meant.
530
+ */
531
+ versionRejected: false,
532
+ rejectedVersion: null,
438
533
  sectionRegistered: false,
439
534
  sectionError: null,
440
535
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
@@ -590,14 +685,164 @@ export function apply(ctx) {
590
685
  console.error('[instruction-memory] cleanup failed:', err)
591
686
  }
592
687
  }
688
+ stopWatching()
689
+ disposeStreams()
593
690
  })
594
691
 
692
+ /* ---------------- external-change watching (SSE) ---------------- */
693
+
694
+ /**
695
+ * Live subscribers to the settings page's event stream. Each entry is the
696
+ * `send(payload)` writer for one open response.
697
+ */
698
+ const sseClients = new Set()
699
+ /** Per-connection teardown, so unmounting the plugin can end every stream. */
700
+ const sseCleanups = new Set()
701
+ let storeWatcher = null
702
+ let watchDebounce = null
703
+
704
+ const pushSnapshot = () => {
705
+ if (sseClients.size === 0) return
706
+ const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
707
+ for (const send of Array.from(sseClients)) {
708
+ try {
709
+ send(payload)
710
+ } catch {
711
+ sseClients.delete(send)
712
+ }
713
+ }
714
+ }
715
+
716
+ /**
717
+ * A store file changed on disk. Debounced (one atomic save fires rename +
718
+ * copy events), then serialised through the same diskQueue as every other
719
+ * mutation so a watcher re-read can never interleave with a save.
720
+ *
721
+ * The file is read WITHOUT adopting it first, because this plugin's own
722
+ * writes fire the watcher too: if the sanitised content is identical to the
723
+ * in-memory state, the event was ours and there is nothing to tell anyone.
724
+ * A failed read (the file is momentarily absent mid-replace, or corrupt) is
725
+ * skipped rather than recovered-from — recovery belongs to reload, which a
726
+ * human asked for.
727
+ */
728
+ const onStoreFileEvent = () => {
729
+ if (watchDebounce !== null) clearTimeout(watchDebounce)
730
+ watchDebounce = setTimeout(() => {
731
+ watchDebounce = null
732
+ void enqueue(async () => {
733
+ try {
734
+ const parsed = await readStoreFile(storePath)
735
+ if (parsed === null) return
736
+ const loaded = normalizeData(parsed)
737
+ if (loaded.rejectedVersion === undefined
738
+ && JSON.stringify(loaded.data) === JSON.stringify(state.data)) {
739
+ return
740
+ }
741
+ await readFromDisk()
742
+ syncSection()
743
+ pushSnapshot()
744
+ } catch {
745
+ // Transient: the next event, or the panel's 重新载入, catches up.
746
+ }
747
+ })
748
+ }, WATCH_DEBOUNCE_MS)
749
+ }
750
+
751
+ const stopWatching = () => {
752
+ if (watchDebounce !== null) {
753
+ clearTimeout(watchDebounce)
754
+ watchDebounce = null
755
+ }
756
+ if (storeWatcher !== null) {
757
+ const watcher = storeWatcher
758
+ storeWatcher = null
759
+ try {
760
+ watcher.close()
761
+ } catch {
762
+ // Already closed by the OS side.
763
+ }
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Watch the store DIRECTORY, not the file: this plugin's saves replace
769
+ * memory.json through an atomic rename, which on Windows can orphan a
770
+ * file-level watch handle (the watched inode is gone). Directory events
771
+ * survive replaces. A missing directory (first run, nothing materialised
772
+ * yet) throws and is retried by the first successful write.
773
+ */
774
+ const startWatching = () => {
775
+ if (storeWatcher !== null) return
776
+ try {
777
+ storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
778
+ // Only events naming memory.json matter; the .tmp/.bak/.lock churn of
779
+ // our own writes is filtered out here (the content compare in
780
+ // onStoreFileEvent catches what slips through).
781
+ if (typeof filename === 'string' && filename !== basename(storePath)) return
782
+ onStoreFileEvent()
783
+ })
784
+ storeWatcher.on('error', () => stopWatching())
785
+ } catch {
786
+ storeWatcher = null
787
+ }
788
+ }
789
+
790
+ const disposeStreams = () => {
791
+ for (const cleanup of Array.from(sseCleanups)) {
792
+ try {
793
+ cleanup()
794
+ } catch {
795
+ // The response is already gone.
796
+ }
797
+ }
798
+ sseCleanups.clear()
799
+ sseClients.clear()
800
+ }
801
+
595
802
  /* ---------------- storage ---------------- */
596
803
 
597
804
  const storePath = resolveStorePath()
598
805
 
599
806
  const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
600
807
 
808
+ /**
809
+ * Cross-process write lock: a `memory.json.lock` marker created exclusively
810
+ * (`wx`), held for the duration of one atomic replace, then removed.
811
+ *
812
+ * The in-process diskQueue serialises this plugin's own writes, but two DSH
813
+ * processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
814
+ * once) can interleave a copy-backup/atomic-rename sequence. The rename
815
+ * itself keeps the file uncorrupted; what the lock narrows is the
816
+ * last-write-wins window in which one process's edit silently erases the
817
+ * other's.
818
+ *
819
+ * Availability beats exclusion for a user-facing store: a lock that cannot
820
+ * be acquired within a short wait is NEVER allowed to fail the save — the
821
+ * write proceeds anyway (disclosed in the debug log). A lock older than
822
+ * LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
823
+ * process cannot wedge every later save behind a stale marker.
824
+ */
825
+ const acquireStoreLock = async () => {
826
+ const lockPath = storePath + '.lock'
827
+ const deadline = Date.now() + LOCK_WAIT_MS
828
+ for (;;) {
829
+ try {
830
+ const handle = await open(lockPath, 'wx')
831
+ await handle.close()
832
+ return lockPath
833
+ } catch (err) {
834
+ if (err.code !== 'EEXIST') throw err
835
+ const info = await stat(lockPath).catch(() => null)
836
+ if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
837
+ await unlink(lockPath).catch(() => {})
838
+ continue
839
+ }
840
+ if (Date.now() >= deadline) return null
841
+ await new Promise((resolve) => setTimeout(resolve, 50))
842
+ }
843
+ }
844
+ }
845
+
601
846
  const writeToDisk = async () => {
602
847
  // Atomic replace (write a sibling temp file, then rename) with a rolling
603
848
  // one-version backup: a crash mid-write must never leave a truncated
@@ -614,8 +859,18 @@ export function apply(ctx) {
614
859
  // any step leaves the previous store readable at its own path.
615
860
  const tmpPath = storePath + '.tmp'
616
861
  const bakPath = storePath + '.bak'
862
+ let lockPath = null
863
+ let previousRev = null
617
864
  try {
618
865
  await mkdir(dirname(storePath), { recursive: true })
866
+ lockPath = await acquireStoreLock()
867
+ if (lockPath === null) {
868
+ logDebug('store lock still held by another process; writing anyway (last write wins)')
869
+ }
870
+ // The rev is bumped before serialising so the file on disk and the state
871
+ // in memory agree on it; a failed write restores the old value.
872
+ previousRev = state.data.rev || 0
873
+ state.data.rev = previousRev + 1
619
874
  const text = serialize()
620
875
  await writeFile(tmpPath, text, 'utf8')
621
876
  try {
@@ -644,14 +899,20 @@ export function apply(ctx) {
644
899
  // A successful write is the one thing that makes the file real, so it is
645
900
  // also what clears the "not materialised yet" flag set by a first run.
646
901
  needsMaterialize = false
902
+ // ...and what guarantees the store directory exists, so the watcher can
903
+ // be (re)armed after a first run that had nothing to watch.
904
+ startWatching()
647
905
  state.savedAt = Date.now()
648
906
  logDebug('store written: ' + storePath + ' bytes=' + text.length
649
- + ' entries=' + state.data.entries.length
907
+ + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
650
908
  + (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
651
909
  return true
652
910
  } catch (err) {
911
+ if (previousRev !== null) state.data.rev = previousRev
653
912
  state.storageError = '保存失败:' + describeError(err)
654
913
  return false
914
+ } finally {
915
+ if (lockPath !== null) await unlink(lockPath).catch(() => {})
655
916
  }
656
917
  }
657
918
 
@@ -660,25 +921,57 @@ export function apply(ctx) {
660
921
  return text.trim() === '' ? null : JSON.parse(text)
661
922
  }
662
923
 
924
+ /**
925
+ * Load the store from disk. Most branches only read; two of them also write —
926
+ * the missing-primary recovery promotes the backup to memory.json, and the
927
+ * legacy-location migration writes the migrated store. The return value says
928
+ * which happened, because `reload` reports it as its `saved` field: the
929
+ * response envelope's `saved` means "this operation persisted the store",
930
+ * and a read-only reload claiming true would be indistinguishable from a
931
+ * real save.
932
+ *
933
+ * @returns whether this load performed a successful write.
934
+ */
663
935
  const readFromDisk = async () => {
664
936
  state.storagePath = storePath
665
937
  state.storageDisplay = storePath
666
938
  state.storageWarning = null
667
939
  state.loadedFrom = null
668
940
  state.storeExists = false
941
+ state.versionRejected = false
942
+ state.rejectedVersion = null
943
+
944
+ /**
945
+ * A store declaring an unsupported format version: adopt nothing, write
946
+ * nothing, and say why. The one thing that must NEVER happen in this
947
+ * state is a save — it would rewrite the file as v1 and destroy whatever
948
+ * the newer format meant (the mutating commands refuse while
949
+ * versionRejected is set).
950
+ */
951
+ const rejectVersion = (version, source) => {
952
+ state.versionRejected = true
953
+ state.rejectedVersion = version
954
+ state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
955
+ state.loadedFrom = source + ' (unsupported version ' + version + ')'
956
+ state.storeExists = true
957
+ }
669
958
 
670
959
  // 1. The canonical location under the harness home.
671
960
  try {
672
961
  const parsed = await readStoreFile(storePath)
673
962
  if (parsed !== null) {
674
963
  const loaded = normalizeData(parsed)
964
+ if (loaded.rejectedVersion !== undefined) {
965
+ rejectVersion(loaded.rejectedVersion, 'memory.json')
966
+ return false
967
+ }
675
968
  state.data = loaded.data
676
- state.storageWarning = droppedEntriesMessage(loaded.dropped)
969
+ state.storageWarning = loadWarnings(loaded)
677
970
  state.loadedFrom = 'memory.json'
678
971
  state.storeExists = true
679
972
  }
680
973
  state.storageError = null
681
- return
974
+ return false
682
975
  } catch (err) {
683
976
  if (err === null || err === undefined || err.code !== 'ENOENT') {
684
977
  // The main file is unreadable or corrupt. A previous good version
@@ -687,16 +980,27 @@ export function apply(ctx) {
687
980
  // itself stays untouched for hand repair; it gets overwritten on the
688
981
  // next save (the pre-existing and disclosed behaviour).
689
982
  if (err instanceof SyntaxError) {
983
+ // The file was READ and only failed to PARSE, so a store file is
984
+ // physically present on disk. Saying onDisk=no here contradicted
985
+ // the branch's own story ("损坏文件已保留") and produced a mounted
986
+ // log line reading "from=memory.json.bak (main file corrupt)
987
+ // onDisk=no". (Other non-ENOENT errors stay no: ENOTDIR, say, means
988
+ // nothing exists at the store path at all.)
989
+ state.storeExists = true
690
990
  try {
691
991
  const backup = await readStoreFile(storePath + '.bak')
692
992
  if (backup !== null) {
693
993
  const loaded = normalizeData(backup)
994
+ if (loaded.rejectedVersion !== undefined) {
995
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
996
+ return false
997
+ }
694
998
  state.data = loaded.data
695
- state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
696
- + (loaded.dropped > 0 ? ';' : '')
999
+ const warnings = loadWarnings(loaded)
1000
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
697
1001
  + 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
698
1002
  state.loadedFrom = 'memory.json.bak (main file corrupt)'
699
- return
1003
+ return false
700
1004
  }
701
1005
  } catch {
702
1006
  // No usable backup either; fall through to the error below.
@@ -706,7 +1010,7 @@ export function apply(ctx) {
706
1010
  state.storageError = err instanceof SyntaxError
707
1011
  ? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
708
1012
  : '读取失败:' + describeError(err)
709
- return
1013
+ return false
710
1014
  }
711
1015
  }
712
1016
 
@@ -722,14 +1026,18 @@ export function apply(ctx) {
722
1026
  const backup = await readStoreFile(storePath + '.bak')
723
1027
  if (backup !== null) {
724
1028
  const loaded = normalizeData(backup)
1029
+ if (loaded.rejectedVersion !== undefined) {
1030
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
1031
+ return false
1032
+ }
725
1033
  state.data = loaded.data
726
1034
  const promoted = await writeToDisk()
727
- state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
728
- + (loaded.dropped > 0 ? ';' : '')
1035
+ const warnings = loadWarnings(loaded)
1036
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
729
1037
  + 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
730
1038
  + (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
731
1039
  state.loadedFrom = 'memory.json.bak (main file missing)'
732
- return
1040
+ return promoted
733
1041
  }
734
1042
  } catch {
735
1043
  // An unusable backup must never block a fresh store; fall through.
@@ -742,15 +1050,21 @@ export function apply(ctx) {
742
1050
  const parsed = await readStoreFile(legacyPath)
743
1051
  if (parsed !== null) {
744
1052
  const loaded = normalizeData(parsed)
1053
+ if (loaded.rejectedVersion !== undefined) {
1054
+ // A legacy file in a future format is not ours to migrate or judge.
1055
+ state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
1056
+ return false
1057
+ }
745
1058
  state.data = loaded.data
746
- state.storageWarning = droppedEntriesMessage(loaded.dropped)
747
- if (await writeToDisk()) {
1059
+ state.storageWarning = loadWarnings(loaded)
1060
+ const migrated = await writeToDisk()
1061
+ if (migrated) {
748
1062
  state.migratedFrom = legacyPath
749
1063
  // Keep the original as evidence instead of deleting user data.
750
1064
  await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
751
1065
  }
752
1066
  state.loadedFrom = 'legacy cwd file (migrated)'
753
- return
1067
+ return migrated
754
1068
  }
755
1069
  } catch (err) {
756
1070
  // A missing or unreadable legacy file must never block a fresh store.
@@ -770,6 +1084,7 @@ export function apply(ctx) {
770
1084
  needsMaterialize = true
771
1085
  state.loadedFrom = 'first run (no store on disk yet)'
772
1086
  state.storeExists = false
1087
+ return false
773
1088
  }
774
1089
 
775
1090
  /**
@@ -809,6 +1124,9 @@ export function apply(ctx) {
809
1124
  + ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
810
1125
  + ' pulls=' + sectionPulls
811
1126
  + (state.storageError !== null ? ' error=' + state.storageError : ''))
1127
+ // Arm the external-change watcher once the store is known to exist. A
1128
+ // first run has nothing to watch yet; the first successful write rearms.
1129
+ if (state.storeExists) startWatching()
812
1130
  })
813
1131
 
814
1132
  /**
@@ -833,6 +1151,7 @@ export function apply(ctx) {
833
1151
  return {
834
1152
  data: {
835
1153
  version: 1,
1154
+ rev: state.data.rev || 0,
836
1155
  enabled: state.data.enabled === true,
837
1156
  budgetChars: clampBudget(state.data.budgetChars),
838
1157
  entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
@@ -859,7 +1178,13 @@ export function apply(ctx) {
859
1178
  pulls: sectionPulls,
860
1179
  },
861
1180
  preview,
862
- limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES },
1181
+ /**
1182
+ * `maxBodyChars` is the route's request-body ceiling, published so the
1183
+ * Client can refuse an obviously oversized import file BEFORE reading it
1184
+ * into memory. It is a character budget against byte sizes, so the
1185
+ * Client applies a generous multiplier — see importSizeVerdict there.
1186
+ */
1187
+ limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
863
1188
  }
864
1189
  }
865
1190
 
@@ -872,6 +1197,41 @@ export function apply(ctx) {
872
1197
 
873
1198
  /* ---------------- commands (shared by route and callers) ---------------- */
874
1199
 
1200
+ /**
1201
+ * Optimistic concurrency: a mutating command may carry `baseRev` — the
1202
+ * `data.rev` its caller last saw. A mismatch means another window or process
1203
+ * committed in between, and applying this command now would build on a stale
1204
+ * view (e.g. resurrect an entry another tab just deleted). The refusal hands
1205
+ * back the FRESH snapshot so the panel resyncs immediately; the user retries
1206
+ * and the retry carries the new rev.
1207
+ *
1208
+ * Absent `baseRev` (older clients) disables the check entirely — legacy
1209
+ * behaviour, not an error.
1210
+ */
1211
+ const isStaleBaseRev = (baseRev) =>
1212
+ typeof baseRev === 'number' && Number.isFinite(baseRev) && baseRev !== (state.data.rev || 0)
1213
+
1214
+ const conflictResponse = () => ({
1215
+ ok: false,
1216
+ saved: false,
1217
+ conflict: true,
1218
+ message: '指令记忆刚被其他窗口或进程修改,已为你载入最新内容;请检查后重试本次操作',
1219
+ snapshot: snapshot(),
1220
+ })
1221
+
1222
+ /**
1223
+ * Refusal while the store on disk declares an unsupported format version:
1224
+ * a save here would rewrite the file as v1 and destroy whatever the newer
1225
+ * format meant. The file stays untouched until the user upgrades the plugin
1226
+ * or restores a v1 file and hits 重新载入.
1227
+ */
1228
+ const versionRefusal = () => ({
1229
+ ok: false,
1230
+ saved: false,
1231
+ message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
1232
+ snapshot: snapshot(),
1233
+ })
1234
+
875
1235
  /**
876
1236
  * Apply an in-memory mutation, persist it, and UNDO it when the write fails.
877
1237
  *
@@ -896,6 +1256,14 @@ export function apply(ctx) {
896
1256
  // dropped-entries warning from load time ("they will be removed on the
897
1257
  // next save") has played out and must not keep showing.
898
1258
  state.storageWarning = null
1259
+ // The write is real, so every OTHER open panel must hear about it. The
1260
+ // file watcher stays silent for this plugin's own writes (disk now
1261
+ // equals memory, see onStoreFileEvent), which is correct for recovery —
1262
+ // but a second window on the same host would otherwise sit on its stale
1263
+ // view until someone clicks 重新载入. The initiating window receives the
1264
+ // same frame and drops it client-side (identical content), so pushing to
1265
+ // everyone is safe and closing the loop needs no per-client bookkeeping.
1266
+ pushSnapshot()
899
1267
  return true
900
1268
  }
901
1269
  logDebug('commit rolled back: the disk write failed, in-memory state restored')
@@ -905,7 +1273,9 @@ export function apply(ctx) {
905
1273
  return false
906
1274
  }
907
1275
 
908
- const saveEntry = (raw) => enqueue(async () => {
1276
+ const saveEntry = (raw, baseRev) => enqueue(async () => {
1277
+ if (state.versionRejected) return versionRefusal()
1278
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
909
1279
  const entry = sanitizeEntry(raw)
910
1280
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
911
1281
  entry.updatedAt = Date.now()
@@ -928,14 +1298,18 @@ export function apply(ctx) {
928
1298
  return settle(note, saved)
929
1299
  })
930
1300
 
931
- const deleteEntry = (id) => enqueue(async () => {
1301
+ const deleteEntry = (id, baseRev) => enqueue(async () => {
1302
+ if (state.versionRejected) return versionRefusal()
1303
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
932
1304
  const saved = await commit(() => {
933
1305
  state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
934
1306
  })
935
1307
  return settle(null, saved)
936
1308
  })
937
1309
 
938
- const setOptions = (patch) => enqueue(async () => {
1310
+ const setOptions = (patch, baseRev) => enqueue(async () => {
1311
+ if (state.versionRejected) return versionRefusal()
1312
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
939
1313
  const saved = await commit(() => {
940
1314
  if (patch !== null && typeof patch === 'object') {
941
1315
  if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
@@ -948,14 +1322,19 @@ export function apply(ctx) {
948
1322
  })
949
1323
 
950
1324
  const reload = () => enqueue(async () => {
951
- await readFromDisk()
1325
+ // `saved` is only true when the load itself wrote to disk (the
1326
+ // backup-promotion or legacy-migration branch); a plain re-read claiming
1327
+ // true would be indistinguishable from a real save on the wire.
1328
+ const wrote = await readFromDisk()
952
1329
  syncSection()
953
- return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
1330
+ return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
954
1331
  })
955
1332
 
956
1333
  const exportData = () => enqueue(async () => ({
957
1334
  ok: true,
958
- saved: true,
1335
+ // An export reads the store and never writes it; a truthful `saved:false`
1336
+ // keeps the field's meaning ("this operation persisted the store") intact.
1337
+ saved: false,
959
1338
  message: null,
960
1339
  // A self-describing envelope: re-importable by this plugin, and readable
961
1340
  // as plain JSON by anything else.
@@ -963,7 +1342,9 @@ export function apply(ctx) {
963
1342
  snapshot: snapshot(),
964
1343
  }))
965
1344
 
966
- const importData = (payload) => enqueue(async () => {
1345
+ const importData = (payload, baseRev) => enqueue(async () => {
1346
+ if (state.versionRejected) return versionRefusal()
1347
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
967
1348
  if (payload === null || typeof payload !== 'object') {
968
1349
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
969
1350
  }
@@ -1037,23 +1418,31 @@ export function apply(ctx) {
1037
1418
  // A first run writes nothing at boot (readFromDisk step 3), so the
1038
1419
  // materialisation happens here, on a deliberate user action, instead of
1039
1420
  // as a side effect of the host merely starting up.
1040
- await ensureStoreMaterialized()
1421
+ const materialized = await ensureStoreMaterialized()
1041
1422
  // Same envelope as every other method. One response shape means the
1042
1423
  // Client can never again be handed a payload it silently ignores.
1043
- return { ok: true, saved: true, message: null, snapshot: snapshot() }
1424
+ // `saved` is true only on the first run, when materialisation actually
1425
+ // wrote the file; every later read wrote nothing and says so.
1426
+ return { ok: true, saved: materialized === true, message: null, snapshot: snapshot() }
1044
1427
  }
1045
1428
  case 'save-entry':
1046
- return saveEntry(args !== null && typeof args === 'object' ? args.entry : null)
1429
+ return saveEntry(
1430
+ args !== null && typeof args === 'object' ? args.entry : null,
1431
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1047
1432
  case 'delete-entry':
1048
- return deleteEntry(args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '')
1433
+ return deleteEntry(
1434
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
1435
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1049
1436
  case 'set-options':
1050
- return setOptions(args)
1437
+ return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
1051
1438
  case 'reload':
1052
1439
  return reload()
1053
1440
  case 'export-data':
1054
1441
  return exportData()
1055
1442
  case 'import-data':
1056
- return importData(args !== null && typeof args === 'object' ? args.payload : null)
1443
+ return importData(
1444
+ args !== null && typeof args === 'object' ? args.payload : null,
1445
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1057
1446
  default:
1058
1447
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
1059
1448
  }
@@ -1068,7 +1457,73 @@ export function apply(ctx) {
1068
1457
  let guardHost = true
1069
1458
 
1070
1459
  let routeDispose = null
1460
+
1461
+ /**
1462
+ * The live-update stream for the settings page (GET + `Accept:
1463
+ * text/event-stream`, i.e. exactly what an EventSource sends).
1464
+ *
1465
+ * Why the method/accept pair instead of a sub-path: the route is registered
1466
+ * as a plain prefix and how the harness presents `req.url` to it is not part
1467
+ * of any contract, while `req.method` and the Accept header are. A plain GET
1468
+ * (someone navigating to the URL) keeps the old 405.
1469
+ *
1470
+ * Frames carry the bare snapshot — the same shape `state` answers with —
1471
+ * which the Client's normalizer already accepts.
1472
+ */
1473
+ const handleEventStream = (req, res) => {
1474
+ const verdict = requestOriginVerdict(req.headers, guardHost)
1475
+ if (!verdict.ok) {
1476
+ logInfo('route rejected a request: ' + verdict.reason)
1477
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1478
+ res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
1479
+ return
1480
+ }
1481
+ res.writeHead(200, {
1482
+ 'content-type': 'text/event-stream; charset=utf-8',
1483
+ 'cache-control': 'no-store',
1484
+ connection: 'keep-alive',
1485
+ })
1486
+ // Ask the browser to retry politely if the stream drops (e.g. the host was
1487
+ // restarted) instead of hammering the route.
1488
+ res.write('retry: 5000\n\n')
1489
+ const send = (payload) => { res.write(payload) }
1490
+ sseClients.add(send)
1491
+ // A snapshot on connect, so a late subscriber synchronises immediately
1492
+ // instead of waiting for the next change.
1493
+ send('data: ' + JSON.stringify(snapshot()) + '\n\n')
1494
+ const cleanup = () => {
1495
+ sseClients.delete(send)
1496
+ sseCleanups.delete(cleanup)
1497
+ try {
1498
+ res.end()
1499
+ } catch {
1500
+ // The response is already gone.
1501
+ }
1502
+ }
1503
+ sseCleanups.add(cleanup)
1504
+ req.on('close', cleanup)
1505
+ req.on('error', cleanup)
1506
+ res.on('error', cleanup)
1507
+ }
1508
+
1071
1509
  const handler = (req, res) => {
1510
+ if (req.method === 'GET') {
1511
+ const accept = String(
1512
+ (req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
1513
+ )
1514
+ if (accept.indexOf('text/event-stream') !== -1) {
1515
+ // A call aborted mid-flight makes these streams emit 'error'; a server
1516
+ // stream with no error listener would take the host process down over
1517
+ // one dead HTTP request.
1518
+ req.on('error', () => {})
1519
+ res.on('error', () => {})
1520
+ handleEventStream(req, res)
1521
+ return
1522
+ }
1523
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
1524
+ res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
1525
+ return
1526
+ }
1072
1527
  if (req.method !== 'POST') {
1073
1528
  res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
1074
1529
  res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
@@ -1149,12 +1604,16 @@ export function apply(ctx) {
1149
1604
  return
1150
1605
  }
1151
1606
  // The Host allowlist only makes sense when the operator bound the server to
1152
- // loopback. `0.0.0.0` means they deliberately published it on every
1153
- // interface, where no Host value can be known in advance — enforcing a
1154
- // loopback allowlist there would break every legitimate client, so the
1155
- // check is disabled and the trade-off is documented in the README.
1156
- const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : '127.0.0.1'
1157
- guardHost = boundHost !== '0.0.0.0'
1607
+ // loopback. Anything else — `0.0.0.0`/`::` (every interface) or a concrete
1608
+ // address such as `192.168.1.5` — is a deliberate publication beyond this
1609
+ // machine, where no Host value can be known in advance; enforcing a
1610
+ // loopback allowlist there would 403 every legitimate client of that
1611
+ // published deployment. (The previous check compared against `0.0.0.0`
1612
+ // only, which silently broke `::` and concrete-address binds.) The check is
1613
+ // disabled for all non-loopback binds and the trade-off is documented in
1614
+ // the README.
1615
+ const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
1616
+ guardHost = boundHost === '' || LOOPBACK_HOSTNAMES.has(boundHost)
1158
1617
  try {
1159
1618
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
1160
1619
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')