dsh-unlag 1.7.3 → 1.7.5

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/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # dsh-unlag(解除卡顿)
2
2
 
3
- 一个按钮,把「改完插件要重启才生效」变成点一下,顺便治好用久了的卡顿。
3
+ **改完插件点一下就生效。界面卡了点一下就顺。正在跑的任务不受影响。**
4
4
 
5
- 窗口右上角那个按钮只做两件事:把改过的插件代码原地换掉、把界面重建一次。全程不重启进程,所以会话、正在跑的轮次、排队的消息、没发出的输入都还在。
5
+ 窗口右上角一个按钮,不重启进程、不刷新掉会话:插件代码原地换掉,老旧卡顿的界面重建一次。
6
6
 
7
- ## 为什么有用
7
+ ## 两个日常场景
8
8
 
9
- - 开发插件:改完 `.js` 点一下就生效,不用重启 App,也不用刷新页面。
10
- - 装新插件:装完点一下让它进来,接着用。
11
- - 界面用久了发卡:点一下重建,滚动位置和草稿都保留。
9
+ | 情况 | 按一下 |
10
+ | --- | --- |
11
+ | 改了插件,手里还有任务在跑 | 大部分插件当场更新,任务继续跑完 |
12
+ | 界面越用越卡 | 直接恢复流畅,会话、队列、草稿都还在 |
12
13
 
13
- 以前这三种都得重启 App,现在都不用。有会话正在跑的时候也能点:**马上换,不等空闲**——轮次不会被打断;某一步正好调到被换掉的插件时会报错重来,会话继续。
14
+ 以前两种都得重启 App。现在不用,也不用挑空闲时间——马上换,不等。
14
15
 
15
16
  ## 装
16
17
 
@@ -18,36 +19,36 @@
18
19
  dsh plugin install dsh-unlag
19
20
  ```
20
21
 
21
- 装完重载一次 App 让它进来,之后所有更新都走按钮。
22
+ 重载一次 App 让它进来,之后所有更新都走按钮。
22
23
 
23
24
  ## 用
24
25
 
25
- 点右上角按钮即可。设置里两行决定它做什么:
26
+ 点右上角那个按钮。设置两行决定它做什么:
26
27
 
27
28
  - **按下刷新时换掉插件代码**(默认开)
28
29
  - **按下刷新时重建界面**(默认开)
29
30
 
30
- 都开=先换代码再重建;只开一个=另一项完全不动(只换代码时页面不刷新,渲染状态和滚动位置全保留);都不开=点击无反应。
31
+ 都开=先换代码再重建;只开一个=另一项完全不动;都不开=点击无反应。
31
32
 
32
33
  ## 不会丢什么
33
34
 
34
- - 轮次、子代理、终端进程:在宿主侧,不受影响。
35
- - 排队消息、引导队列、输入草稿和附件:重建前会先提交焦点里的内容。
36
- - 有未保存改动或还开着弹窗时:重建被拦下并提示,不硬刷。
37
- - 它自己从不重启或退出 App;缺启动模块时宁可放弃重建并提示,也不冒把 App 带下去的风险。
35
+ - 轮次、子代理、终端进程:在宿主侧,一路继续。
36
+ - 排队消息、输入草稿、附件:重建前先提交焦点里的内容。
37
+ - 有未保存改动或开着弹窗:重建被拦下并提示,不硬刷。
38
+ - 从不自己重启或退出 App;启动模块缺失时宁可放弃重建并提示,也不冒把 App 带下去的风险。
38
39
 
39
- ## 管不到的地方
40
-
41
- 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
40
+ 管不到:独立进程和进程级的东西(宠物窗口自己的配置、DSH 自身升级、原生依赖变化)。
42
41
 
43
42
  ## 排错
44
43
 
45
- - 日志:`~/.dsh/unlag-reload.log`,每次换代码/跳过/被拦各一行含原因。
46
- - 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true}` 是两个开关的当前值。
44
+ - 日志:`~/.dsh/unlag-reload.log`,换代码/跳过/被拦各一行,含原因。
45
+ - 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true}`。
47
46
 
48
47
  ## 测试
49
48
 
50
49
  ```sh
51
- node test/button.test.mjs # 开关、接口、忙时立刻换、无宿主不崩
52
- node test/safety.test.mjs # 崩坏路径与未处理拒绝
50
+ node test/button.test.mjs
51
+ node test/bundle-shim.test.mjs
52
+ node test/client-guard.test.mjs
53
+ node test/safety.test.mjs
53
54
  ```
package/client.js CHANGED
@@ -122,6 +122,81 @@ window.__ModuleLoader__.load({
122
122
  return true;
123
123
  }
124
124
 
125
+ /**
126
+ * The startup list the desktop shell hands the next document, exactly as it hands it. The
127
+ * shell reads that list once when the app starts and replays the same copy for every document
128
+ * after that, so this is the only way to see what a rebuild would really boot from.
129
+ * @returns the list rows, or null when this page is not running inside the desktop shell.
130
+ */
131
+ async function desktopStartupPayload() {
132
+ const bridge = window.dshDesktopBoot;
133
+ if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
134
+ try {
135
+ const payload = await bridge.ready();
136
+ const rows = Array.isArray(payload?.injections) ? payload.injections : [];
137
+ return rows.length === 0 ? null : rows;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Whether a document booting from the desktop shell's saved startup list would come up. Every
145
+ * script in that list is read back from where the document would load it, and every plugin it
146
+ * lists must still be one the running app serves: a missing script ends the desktop app, which
147
+ * would take every running turn with it, so this is answered before anything navigates.
148
+ * @param rows - the saved startup list.
149
+ * @returns true when each saved script loads and still contains its plugins.
150
+ */
151
+ async function frozenStartupLooksServable(rows) {
152
+ const scripts = [];
153
+ let frozen = null;
154
+ for (const row of rows) {
155
+ if (row === null || typeof row !== 'object') continue;
156
+ if (typeof row.src === 'string' && row.src !== '') scripts.push(row.src);
157
+ if (row.kind !== 'global' || row.name !== '__DSH_BOOT__') continue; // ui-copy-ignore
158
+ let value = row.value;
159
+ if (typeof value === 'string') {
160
+ try {
161
+ value = JSON.parse(value);
162
+ } catch {
163
+ value = null;
164
+ }
165
+ }
166
+ if (value !== null && typeof value === 'object') frozen = value;
167
+ }
168
+ if (scripts.length === 0) return false;
169
+ const live = await hostBootGraph();
170
+ if (live !== null && frozen !== null) {
171
+ const liveIds = new Set((Array.isArray(live.entries) ? live.entries : []).map((entry) => (typeof entry === 'string' ? entry : entry?.id)));
172
+ const kept = Array.isArray(frozen.entries) ? frozen.entries : [];
173
+ for (const entry of kept) {
174
+ const id = typeof entry === 'string' ? entry : entry?.id;
175
+ if (typeof id === 'string' && id !== '' && !liveIds.has(id)) return false;
176
+ }
177
+ }
178
+ for (const src of scripts) {
179
+ let target;
180
+ try {
181
+ target = new URL(src, location.href).toString();
182
+ } catch {
183
+ return false;
184
+ }
185
+ try {
186
+ const response = await fetch(target, { cache: 'no-store', credentials: 'same-origin' });
187
+ if (!response.ok) return false;
188
+ const body = await response.text();
189
+ if (body.length === 0) return false;
190
+ for (const id of bundledIds(target)) {
191
+ if (!body.includes(id)) return false;
192
+ }
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ return true;
198
+ }
199
+
125
200
  /** The first balanced JSON object after `__DSH_BOOT__` in a served document. */
126
201
  function graphFromHtml(html) {
127
202
  const at = html.indexOf('__DSH_BOOT__'); // ui-copy-ignore
@@ -199,8 +274,12 @@ window.__ModuleLoader__.load({
199
274
  */
200
275
  async function startupLooksServable() {
201
276
  for (let attempt = 0; attempt < 2; attempt += 1) {
202
- const graph = (await servedBootGraph()) ?? window.__DSH_BOOT__;
203
- if (await bundlesCoverStartup(graph)) return true;
277
+ const saved = await desktopStartupPayload();
278
+ const servable =
279
+ saved === null
280
+ ? await bundlesCoverStartup((await servedBootGraph()) ?? window.__DSH_BOOT__)
281
+ : await frozenStartupLooksServable(saved);
282
+ if (servable) return true;
204
283
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 300)); // ui-copy-ignore
205
284
  }
206
285
  return false;
@@ -388,8 +467,16 @@ window.__ModuleLoader__.load({
388
467
  notice === null ? null : h('span', { role: 'status', 'aria-live': 'polite', style: PILL_STYLE }, notice));
389
468
  }
390
469
 
470
+ /**
471
+ * The guard as the press asks it. Exposed only to a headless harness: the failure this plugin
472
+ * must never cause is a navigation the saved startup list cannot survive, and that question is
473
+ * worth asking without starting a desktop app.
474
+ */
475
+ const PROBE = { prepareInterface, startupLooksServable, frozenStartupLooksServable, desktopStartupPayload, reloadFreshPage };
476
+
391
477
  return {
392
478
  inject: ['slots'], // ui-copy-ignore
479
+ ...(window.__DSH_UNLAG_PROBE__ === true ? { probe: PROBE } : {}), // ui-copy-ignore
393
480
  apply(ctx) {
394
481
  if (!desktop) return;
395
482
  // Leave a line in the log per rebuilt interface: it is the one place that shows which
package/index.js CHANGED
@@ -43,6 +43,12 @@ const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
43
43
  /** A turn that never goes idle must not turn this into an endless poll. */
44
44
  const MAX_DEFERRALS = 40
45
45
  const MIN_INTERVAL_MS = 1500
46
+ /** The route the app publishes client bundles under; the same key the module table registers. */
47
+ const BUNDLE_ROUTE = '/plugins'
48
+ /** Marks a route handler already wrapped here, so a plugin reload cannot wrap it twice. */
49
+ const SHIM_MARK = Symbol.for('dsh-unlag.bundle-shim')
50
+ /** Holds the wrapper across a module replacement, so a reload can put the route back. */
51
+ const SHIM_KEY = Symbol.for('dsh-unlag.shim')
46
52
 
47
53
  /**
48
54
  * The two rows this plugin shows in Settings, in the schema dialect the settings form reads.
@@ -399,6 +405,136 @@ function liveBootGraph(server) {
399
405
  }
400
406
  }
401
407
 
408
+ /**
409
+ * The plugin ids, revision and source-map flag one bundle request names, read from the
410
+ * `??<id>/client.js,…&rev=<rev>` form the startup list uses. A request for a package-local chunk
411
+ * carries a real query string instead and is left alone: it is read from disk, so it is current.
412
+ */
413
+ function bundleRequest(rawUrl) {
414
+ const url = String(rawUrl ?? '')
415
+ const start = url.indexOf(`${BUNDLE_ROUTE}/??`)
416
+ if (start < 0) return null
417
+ const rest = url.slice(start + BUNDLE_ROUTE.length + 1)
418
+ const revAt = rest.indexOf('&rev=')
419
+ const target = revAt < 0 ? rest : rest.slice(0, revAt)
420
+ if (!target.startsWith('??')) return null
421
+ const sourceMap = target.endsWith('.map')
422
+ const list = sourceMap ? target.slice(0, -4) : target
423
+ const ids = []
424
+ for (const part of list.slice(2).split(',')) {
425
+ let id = part
426
+ try {
427
+ id = decodeURIComponent(part)
428
+ } catch {}
429
+ id = id.endsWith('/client.js') ? id.slice(0, -'/client.js'.length) : id
430
+ if (id !== '') ids.push(id)
431
+ }
432
+ if (ids.length === 0) return null
433
+ return { ids, sourceMap, rev: revAt < 0 ? '' : rest.slice(revAt + '&rev='.length) }
434
+ }
435
+
436
+ /**
437
+ * The same plugin set under the revision the running app serves it at. The startup list a
438
+ * document is given names one revision; after a plugin is swapped, that revision stops
439
+ * resolving, and a document that boots from the list is refused by its own module loader. The
440
+ * ids are unchanged, so the set can be re-pointed at the current revision instead.
441
+ */
442
+ function currentBundleUrls(graph, request) {
443
+ const wanted = new Set(request.ids)
444
+ const revisions = new Set()
445
+ for (const batch of Array.isArray(graph?.batches) ? graph.batches : []) {
446
+ const entries = Array.isArray(batch?.entries) ? batch.entries : []
447
+ if (entries.length !== request.ids.length) continue
448
+ if (!entries.every((id) => wanted.has(id))) continue
449
+ if (typeof batch.rev === 'string' && batch.rev !== '') revisions.add(batch.rev)
450
+ }
451
+ if (request.ids.length === 1) {
452
+ for (const entry of Array.isArray(graph?.entries) ? graph.entries : []) {
453
+ if (entry?.id !== request.ids[0]) continue
454
+ if (typeof entry.rev === 'string' && entry.rev !== '') revisions.add(entry.rev)
455
+ }
456
+ }
457
+ const list = request.ids.map((id) => `${id}/client.js${request.sourceMap ? '.map' : ''}`).join(',')
458
+ return [...revisions].map((rev) => `${BUNDLE_ROUTE}/??${list}&rev=${rev}`)
459
+ }
460
+
461
+ /**
462
+ * Answer one request the running app cannot serve because the revision it names is gone. The
463
+ * bundle module table is asked for the same set at its current revision, and only a body that
464
+ * really contains every plugin it claims is handed back; anything else falls through to the
465
+ * route as before, so an unknown id still ends as a plain 404.
466
+ */
467
+ async function serveCurrentBundle(clientModules, req, res) {
468
+ try {
469
+ const method = String(req?.method ?? 'GET').toUpperCase()
470
+ if (method !== 'GET' && method !== 'HEAD') return false
471
+ const request = bundleRequest(req?.url)
472
+ if (request === null) return false
473
+ const urls = currentBundleUrls(clientModules.graph(), request)
474
+ if (urls.length === 0) return false
475
+ if (request.rev !== '' && urls.some((url) => url.endsWith(`&rev=${request.rev}`))) return false
476
+ for (const url of urls) {
477
+ const response = await clientModules.fetchBundle({ method: 'GET', url: `http://unlag.invalid${url}` })
478
+ if (response.status !== 200) continue
479
+ const body = Buffer.from(await response.arrayBuffer())
480
+ if (body.length === 0) continue
481
+ if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
482
+ if (res.writableEnded === true || res.destroyed === true) return true
483
+ res.writeHead(200, {
484
+ 'content-type': String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
485
+ 'cache-control': 'no-store',
486
+ 'content-length': String(body.length),
487
+ })
488
+ res.end(method === 'HEAD' ? undefined : body)
489
+ log(`served ${request.ids.join(', ')} at the current revision: a page asked for an older one`)
490
+ return true
491
+ }
492
+ return false
493
+ } catch (error) {
494
+ log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
495
+ return false
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Wrap the bundle route so a document booting from an older startup list still runs. The route
501
+ * belongs to the module table, and a second registration of the same prefix is refused, so the
502
+ * handler already there is wrapped in place and put back on dispose. Every path through the
503
+ * wrapper falls back to that handler: a bundle it cannot place is answered exactly as before.
504
+ */
505
+ function serveBundlesAtCurrentRevision(ctx) {
506
+ const server = ctx.get('webServer')
507
+ const clientModules = ctx.get('clientModules')
508
+ if (server?.prefixes?.get === undefined) return null
509
+ if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') return null
510
+ const route = server.prefixes.get(BUNDLE_ROUTE)
511
+ if (route === null || route === undefined || typeof route.handler !== 'function') return null
512
+ if (route[SHIM_MARK] === true) return null
513
+ const original = route.handler
514
+ const wrapped = (req, res) => {
515
+ const fallback = () => original(req, res)
516
+ const answered = serveCurrentBundle(clientModules, req, res).catch((error) => {
517
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
518
+ return false
519
+ })
520
+ if (typeof answered?.then !== 'function') return fallback()
521
+ return answered.then((served) => (served === true ? undefined : fallback()))
522
+ }
523
+ route[SHIM_MARK] = true
524
+ route.handler = wrapped
525
+ log('bundles asked for under an older revision are answered at the current one')
526
+ return { route, original, wrapped }
527
+ }
528
+
529
+ /** Put the bundle route back the way it was found; a route replaced since then is left alone. */
530
+ function releaseBundleShim(shim) {
531
+ try {
532
+ if (shim === null || shim === undefined) return
533
+ if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
534
+ if (shim.route[SHIM_MARK] === true) delete shim.route[SHIM_MARK]
535
+ } catch {}
536
+ }
537
+
402
538
  /**
403
539
  * The entry points the header button calls. Each answers with the current switches so the page
404
540
  * never guesses: with both off it does nothing, and a turn still running is reported rather than
@@ -474,10 +610,21 @@ export function apply(ctx, config) {
474
610
  try {
475
611
  closeAll(globalThis[WATCHER_KEY])
476
612
  closeAll(globalThis[ROUTES_KEY])
613
+ releaseBundleShim(globalThis[SHIM_KEY])
477
614
  globalThis[WATCHER_KEY] = null
478
615
  globalThis[ROUTES_KEY] = null
616
+ globalThis[SHIM_KEY] = null
479
617
 
480
618
  const controller = pluginReloader(ctx, config ?? {})
619
+ /**
620
+ * The bundle route can be wrapped as soon as the module table has registered it. Doing it here
621
+ * as well as on the service event covers both orders: a table that is already up when this
622
+ * half starts, and one that appears after it.
623
+ */
624
+ const syncBundles = () => {
625
+ if (globalThis[SHIM_KEY] !== null) return
626
+ globalThis[SHIM_KEY] = serveBundlesAtCurrentRevision(ctx)
627
+ }
481
628
  /** Watching needs HMR; start as soon as it exists, drop the watcher when it goes away. */
482
629
  const syncWatching = () => {
483
630
  if (config?.plugins === 'off') return
@@ -497,16 +644,20 @@ export function apply(ctx, config) {
497
644
  if (typeof ctx.on === 'function') {
498
645
  ctx.on('internal/service', (service) => {
499
646
  if (service === 'hmr') syncWatching()
647
+ if (service === 'clientModules') syncBundles()
500
648
  })
501
649
  }
502
650
  globalThis[ROUTES_KEY] = serveButton(ctx, controller, config ?? {})
651
+ syncBundles()
503
652
 
504
653
  ctx.on('dispose', () => {
505
654
  controller.stop()
506
655
  closeAll(globalThis[WATCHER_KEY])
507
656
  closeAll(globalThis[ROUTES_KEY])
657
+ releaseBundleShim(globalThis[SHIM_KEY])
508
658
  globalThis[WATCHER_KEY] = null
509
659
  globalThis[ROUTES_KEY] = null
660
+ globalThis[SHIM_KEY] = null
510
661
  })
511
662
  } catch (error) {
512
663
  log(`plugin reload did not start: ${error?.message ?? error}`)
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Unlag",
4
- "description": "When the interface gets sluggish: replace changed plugin code and rebuild the page in one press. Running turns, queued messages and unsent input are kept."
4
+ "description": "One press applies plugin edits right away and brings a laggy interface back. Running turns, queued messages and unsent input stay as they are."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "解除卡顿",
4
- "description": "用了很久开始卡顿时,一键换掉插件代码并重建界面。正在跑的任务、排队的消息、没发出去的输入都会保留。"
4
+ "description": "改完插件点一下,改动当场生效;界面卡了点一下,立刻恢复流畅。正在跑的任务、排队的消息、没发的输入都不受影响。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "type": "module",
5
- "description": "Clear a sluggish DeepSeek Harness workbench without restarting: rebuild the interface and swap plugin code in place, keeping running turns, queued messages and unsent drafts.",
5
+ "description": "One press applies plugin edits right away and brings a laggy interface back; running turns, queued messages and unsent input are untouched, and nothing restarts.",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./index.js",