better-dsh 0.2.2-a → 0.2.2-b

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.
Files changed (60) hide show
  1. package/docs/50_test-reports/v0.2.4-ios-focus-zoom-suppression/345/256/236/346/265/213/346/212/245/345/221/212.md +158 -0
  2. package/docs/60_exploration-and-research/bun-compile-cordis-runtime-bootstrap-research.md +348 -0
  3. package/docs/60_exploration-and-research/dsh-mobile-spa-ios-input-experience-research.md +160 -0
  4. package/lib/index.d.ts +9 -1
  5. package/lib/index.js +253 -3
  6. package/package.json +1 -1
  7. package/docs/REPL-/345/267/245/345/205/267/350/260/203/347/224/250-/346/210/252/346/226/255/350/257/212/346/226/255.md +0 -110
  8. package/docs/adr/0001-bridge-tool-layer-not-service-layer.md +0 -14
  9. package/docs/adr/0002-masking-is-presentation-only.md +0 -15
  10. package/docs/plans/A2A-messaging-channel-test-archive.md +0 -256
  11. package/docs/plans/code-mode-vs-rlm-ipython-comparison.md +0 -137
  12. package/docs/plans/dashr-blueprint-review.md +0 -201
  13. package/docs/plans/dashr-blueprint.md +0 -561
  14. package/docs/plans/dashr-compaction-window-and-archive.md +0 -307
  15. package/docs/plans/dashr-profile-layer-feasibility.md +0 -367
  16. package/docs/plans/dashr-sandbox-escalation-semantics-gap.md +0 -171
  17. package/docs/plans/dashr-security-sandbox-analysis.md +0 -187
  18. package/docs/plans/dashr-surface-invariant-and-omp-imports.md +0 -97
  19. package/docs/plans/ipython-kernel-interactive-interface-test-report.md +0 -152
  20. package/docs/plans/kernel-refactoring/Dash-IPython-Control-Prompt-draft.md +0 -146
  21. package/docs/plans/kernel-refactoring/Dash-IPython-Control-Prompt-draft_v3.md +0 -50
  22. package/docs/plans/kernel-refactoring/Dash-IPython-Control-Prompt-draft_v4.md +0 -79
  23. package/docs/plans/kernel-refactoring/Dash-vs-PrimeAgent-systemprompt-toolcatalog-comparison.md +0 -138
  24. package/docs/plans/kernel-refactoring/RLM-system-prompt-injection-gap-report.md +0 -161
  25. package/docs/plans/kernel-refactoring/V0.1.5-development-plan.md +0 -109
  26. package/docs/plans/kernel-refactoring/actinoable-surface-to-llm-in-agent-runtime_dsh.md +0 -50
  27. package/docs/plans/kernel-refactoring/actinoable-surface-to-llm-in-agent-runtime_prime.md +0 -113
  28. package/docs/plans/recallable-compaction.md +0 -147
  29. package/docs/plans/spike-tag-repro.mjs +0 -102
  30. package/docs/plans/upstream-analysis.md +0 -128
  31. package/docs/v0.1.8-improved-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -142
  32. package/docs/v0.1.8-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -193
  33. package/docs/v0.1.8b-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -96
  34. package/docs/v0.1.8c-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -127
  35. package/docs/v0.1.8d-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -150
  36. package/docs/v0.1.8d_artifacts/README.md +0 -138
  37. package/docs/v0.1.8d_artifacts/code-mode-repl-only.observation.md +0 -74
  38. package/docs/v0.1.8d_artifacts/dsh-session-session-4a293388-9ae1-474b-87a0-9e17bb556d94.jsonl +0 -3890
  39. package/docs/v0.1.8d_artifacts/dsh-session-session-4a293388-9ae1-474b-87a0-9e17bb556d94.w-sample-0435.jsonl +0 -544
  40. package/docs/v0.1.8d_artifacts/functions.json +0 -592
  41. package/docs/v0.1.8d_artifacts/skills-catalog.snapshot.md +0 -30
  42. package/docs/v0.1.8d_artifacts/tools-sdk.output-schemas.json +0 -1236
  43. package/docs/v0.1.8d_artifacts/tools-sdk.python.txt +0 -592
  44. package/docs/v0.1.8d_artifacts/tools-sdk.typescript.txt +0 -516
  45. package/docs/v0.1.8d_artifacts/wire-vs-transcription.diff.md +0 -54
  46. package/docs/v0.1.8e-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -224
  47. package/docs/v0.1.9a-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -168
  48. package/docs/v0.2.0b-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -123
  49. package/docs/v0.2.0b_artifacts/f2probe/Cargo.lock +0 -7
  50. package/docs/v0.2.0b_artifacts/f2probe/Cargo.toml +0 -6
  51. package/docs/v0.2.0b_artifacts/f2probe/src/bin/messy.rs +0 -8
  52. package/docs/v0.2.0b_artifacts/f2probe/src/main.rs +0 -4
  53. package/docs/v0.2.0b_artifacts/hashline-probe.md +0 -5
  54. package/docs/v0.2.0b_artifacts/slowprobe/Cargo.lock +0 -7
  55. package/docs/v0.2.0b_artifacts/slowprobe/Cargo.toml +0 -7
  56. package/docs/v0.2.0b_artifacts/slowprobe/build.rs +0 -4
  57. package/docs/v0.2.0b_artifacts/slowprobe/src/main.rs +0 -13
  58. package/docs/v0.2.1-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -110
  59. package/docs/v0.2.1b-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -86
  60. package/docs/v0.2.1c-/345/256/236/346/265/213/346/212/245/345/221/212.md +0 -66
@@ -0,0 +1,160 @@
1
+ # DSH Web UI 移动端(iOS Safari)输入体验研究 — focus 放大 / 键盘遮蔽(v2)
2
+
3
+ - 日期:2026-09-03(v1 初稿;v2 同日晚修订,含 TypingMind 逆向实录与裁决更新)
4
+ - 范围:DSH Web UI(`upstream/deepseek-harness` checkout,tag `dsh-v0.1.2-alpha.5`)在 iOS Safari 上的输入体验。**遵 user 2026-09-03 裁决:只解决 ①focus 放大、②虚拟键盘遮挡两个问题;左栏挤压会话区(原 D4)no-go** —— 那是框架级问题,改它是无底洞;侧栏弹出时内容完整即可,隐藏侧栏后自然回到会话区,挤压是暂时性的,由它去。
5
+ - 参照物:typingmind.com(逆向实录见 §2);上游源码逐行取证;WebKit Bugzilla 现状核查(2026-09-03)。
6
+ - 关联:`docs/50_test-reports/v0.2.1f-plugin-shipped-ui-patches实测报告.md`(手势/mobile CSS 已发布态)、`ios-chat-app-bridge-research.md`(native 壳路线)。逆向工作产物:`work/typingmind-re/`(case: `work/typingmind-web-re`,reverse-skill offline-sample)。
7
+
8
+ ---
9
+
10
+ ## 0. 结论(TL;DR)
11
+
12
+ 两个目标问题全部可在 better-dsh 插件内闭环,**零上游改动**。v2 关键更新:
13
+
14
+ 1. **16px 论据已从"推断"升级为"实测"**:TypingMind 聊天输入框 **手机 16px / 桌面 14px**(CDP 活体测量,§2.3)—— 它不是"字体小也没事",而是刻意在手机端维持 16px、桌面才降到 14px(Tailwind `text-base sm:text-sm`)。你看它"字也不大"是桌面印象。
15
+ 2. **行业存在两条正路**(§3.2):A) 移动端字号地板 16px(TypingMind 现行);B) JS 在 iOS 窄屏把 `user-scalable` 翻成 `no`(**iOS 10+ 并不禁双指缩放,只杀 focus 自动放大**;Discourse 曾用 A 后整体迁移到 B,原因是 A 的视觉膨胀)。DSH 选 A/B/A+B 是待讨论的决策点。
16
+ 3. **键盘问题存在两层事实**(§3.3):浏览器内 Safari = 键盘 overlay 无 opt-out(WebKit 259770 仍 NEW),必须 visualViewport shim;**PWA standalone 态 = 引擎原生 resize(innerHeight/dvh 随键盘收缩)**,无需 shim —— 这就是 TypingMind 零键盘代码的原因(它的推荐移动形态是 PWA)。DSH manifest 已是 fullscreen,"推荐 PWA + 保留浏览器内 shim"可作组合策略。
17
+ 4. **动态岛假设有真实对应物但不是本症状的机制**(§4):WebKit 300523(iOS 26.0 仅动态岛机型,键盘关闭/滚动后 viewport 上移数像素侵入安全区,26.1 beta 已修,应用侧无法绕过)—— 证明"动态岛参与 viewport 计算出错"这类 bug 存在,但其症状是**几像素上移**,不是 120% 宽度放大;放大是 font-size 机制(16/14≈1.14 起步,与观察值吻合)。
18
+
19
+ ---
20
+
21
+ ## 1. 症状与根因(v1 取证维持有效,摘要)
22
+
23
+ ### D1 — focus/JS 定位输入框 → 页面放大到 115–120%
24
+
25
+ - viewport:`apps/web/index.html:5` = `width=device-width, initial-scale=1`,全仓无 `maximum-scale`/`user-scalable` 处理。
26
+ - 字号:composer `.card { font-size: var(--dsh-content-font-size, 14px) }`(`InputBar.module.css:55`,`.input` 继承;contenteditable 锚点 `[data-composer-input]`);`--dsh-content-font-size` 由 `ui-theme/src/boot-theme.ts:21` 写 body,**默认 14px**;permission/model 原生 `<select>` 13px。全仓可聚焦控件 13–14px,全部低于 iOS 16px 阈值 → focus 必放大。16/14 ≈ 1.14,与观察到的 115–120% 吻合。
27
+
28
+ ### D2 — 键盘弹出时页面不上推,input 被 overlay 遮住
29
+
30
+ - WebKit 未实现 `interactive-widget`([bug 259770](https://bugs.webkit.org/show_bug.cgi?id=259770),2026-09-03 核查仍 NEW/P2/Nobody)→ iOS 浏览器内键盘 overlay layout viewport,无 opt-out。
31
+ - DSH 布局:`html/body/#root {height:100%}`(`client/web/src/base.css:6`)+ `.frame` grid overflow hidden,composer 在文档流底部 → Safari 只做不可控 page pan,经常 pan 不到位。
32
+ - 唯一引擎 API:`window.visualViewport`(`resize`/`scroll` + `height`/`offsetTop`/`scale`)。`100dvh` 无济于事(响应工具栏不响应键盘)。
33
+
34
+ ### D3 — 切会话自动 focus(D1+D2 的连锁触发器,保留在方案内待裁决)
35
+
36
+ `InputBar.tsx` unlock effect:`useEffect(..., [locked, sessionId, editor])` → `editor.getRootElement()?.focus()` —— 注释原文 "Unlock (mount / session switch) returns focus to the box"。切会话/首载 hero 必触发程序化聚焦 = 无人请求的键盘 + 放大。桌面这是特性(键盘用户续打),移动端是 bug。**user 裁决聚焦两症状,D3 正是两症状在"切会话"场景的共同触发层**,修它属于两症状的修复范围,但是否要"移动端切会话后不聚焦"仍留作决策点(§6)。
37
+
38
+ ### ~~D4 — 左栏挤压~~(no-go,user 2026-09-03 裁决)
39
+
40
+ 不再处理。机制留档备查:`narrowExpanded` 仅跨 1024 断点清除;`computeColumns` sidebar 永不让步(`SIDEBAR_MIN=264`),center 吸收全部赤字。v1 里的 M4(点击 treeitem 自动收栏)随之撤销。
41
+
42
+ ---
43
+
44
+ ## 2. TypingMind 逆向实录(2026-09-03,work/typingmind-re)
45
+
46
+ ### 2.1 分发形态定性:无 DMG,现行 = PWA only
47
+
48
+ 官方 install 文档(docs.typingmind.com/install-typingmind-app)明示安装方式 = **PWA**:桌面 Chrome/Edge 地址栏安装图标、iOS Safari Add to Home Screen,"No app store, no download required"。历史上的 macOS app(changelog "MacOS app v1.15.0",Setapp 渠道)已非现行分发。GitHub `typingmind/typingmind` 是 issue/docs 门面,应用本体闭源。**结论:web bundle(typingmind.com 的 Next.js chunks + PWA 全家桶)就是完整 app package** —— 逆向它 = 逆向完整应用。
49
+
50
+ ### 2.2 静态扫描(149 个 JS chunk ≈11MB + 4 个 CSS,样本 tarball 已存 case)
51
+
52
+ | 检索 | 结果 |
53
+ |---|---|
54
+ | `visualViewport` | 仅 1 处,Floating UI 定位库内部偏移计算 —— **无键盘 shim** |
55
+ | `maximum-scale` / `user-scalable` / viewport 改写 | **无**(JS 与 CSS 均零命中) |
56
+ | `fontSize:"16px"` JS | 2 处 = Prism 代码高亮主题(噪音) |
57
+ | `safe-area-inset` | **真实使用**:CSS `env(safe-area-inset-bottom/left/right)`;JS 侧 workspace bar 高度 `calc(58px + env(safe-area-inset-bottom))`(chunk 3a4r…)—— 标准全面屏适配通道 |
58
+ | "dynamic island"/"notch" | 零真实命中(唯一 "notch" 是用户评价文案) |
59
+ | 表单基线 | Tailwind Forms 全局:`[type=text],…,textarea,select { font-size:1rem }` = 16px(无 html 根字号覆写) |
60
+ | PWA | manifest `display:standalone`;全套 iPhone/iPad splash;`apple-mobile-web-app-capable` |
61
+
62
+ ### 2.3 活体测量(CDP 双宽度,google-chrome --remote-debugging-port + Node 22 原生 WebSocket,`Emulation.setDeviceMetricsOverride`)
63
+
64
+ 主输入框 `<textarea id="chat-input-textbox">`,类名含 `text-base ... sm:text-sm`:
65
+
66
+ | viewport | computed font-size(#chat-input-textbox) | 机制 |
67
+ |---|---|---|
68
+ | 390×844(手机) | **16px** | `text-base`(1rem)生效 |
69
+ | 1280×900(桌面) | **14px** | `sm:text-sm`(≥640px 才降档) |
70
+
71
+ 同页实测:viewport meta 活体值 `initial-scale=1, viewport-fit=cover`;搜索框 16px;根字号 16px(未覆写)。
72
+
73
+ **结论:TypingMind 对 focus 放大的对策 = 手机端输入面 16px(Tailwind 响应式降档手法)+ 不动 viewport meta + 无任何 JS 键盘/缩放处理。** 它的移动端键盘体验依赖 PWA standalone 的引擎原生行为(§3.3)。
74
+
75
+ ### 2.4 行业演化旁证:Discourse PR #30877
76
+
77
+ Discourse 曾实现方案 A:`--font-size-ios-input: max(1em, 16px)`(其注释原话 "inputs/textareas in iOS need to be at least 16px to avoid triggering zoom on focus"),后整体替换为方案 B:iOS 上 JS 把 `user-scalable=yes` 翻成 `no`,注释原话:"**In iOS Safari, setting user-scalable=no doesn't actually prevent the user from zooming in. But, it does prevent the annoying 'auto zoom' when focussing input fields with small font-sizes.**" —— 迁移动机是 A 造成输入框视觉膨胀。两条路都被大型产品实证有效。
78
+
79
+ ---
80
+
81
+ ## 3. 机制结论与充要性(v2 修正)
82
+
83
+ ### 3.1 放大机制的准确表述
84
+
85
+ iOS Safari(默认配置)在 `initial-scale=1` 且未禁缩放时,对 computed font-size **< 16px** 的可聚焦控件(input/select/textarea/contenteditable)在 focus 时自动放大 visual viewport 至文本 ≥16px 可读级。这是充分条件级的行业共识(TypingMind 刻意工程 + Discourse 注释 + 大量社区文献),且数值与 DSH 症状吻合(14px→×1.14)。**充要性的诚实边界**:
86
+ - 16px 在**默认 Safari 配置**下充分;非绝对必要(maximum-scale/user-scalable=no 亦阻断)。
87
+ - 例外残存:用户系统级辅助功能(更大文本、Safari 每站 Page Zoom 设置)可抬高实际阈值或残留缩放;`<select>` 聚焦在个别 iOS 版本有独立报告(如 SO #64076385 "not prevented with 16px",403 未能取全文,标题即反例存在性证明)。**这恰是 A+B 双保险的理由**(§6 决策点 1)。
88
+
89
+ ### 3.2 方案空间(放大问题)
90
+
91
+ | 方案 | 手段 | 代价 | 先例 |
92
+ |---|---|---|---|
93
+ | **A 字号地板** | 窄屏 CSS:`[data-composer-input],[data-composer-placeholder],input,select,textarea { font-size: max(16px, var(--dsh-content-font-size,14px)) }` | 输入面视觉变大(13/14→16px);对字号偏好用户保序 | TypingMind 现行;Discourse v1 |
94
+ | **B 禁缩放标记** | 窄屏 JS 改写 viewport meta 追加 `maximum-scale=1, user-scalable=no` | iOS 10+ **不禁双指缩放**(Discourse 注释实证),只杀 focus 自动放大;桌面/Android 不动 | Discourse v2(现行) |
95
+ | A+B | 地板兜字义,标记兜例外 | 叠加 | —— |
96
+
97
+ B 的实现要点:只在 narrow + touch 检测下改写(避免桌面与 Android 误伤),boot script 早期执行(先于任何 focus)。
98
+
99
+ ### 3.3 键盘机制的两层事实(v2 关键更新)
100
+
101
+ - **浏览器内 Safari**:键盘 overlay,无 opt-out(259770),必须 `visualViewport` shim(v1 M2 方案维持):`intrusion = innerHeight − visualViewport.height`,>阈值且 `scale≈1` 时以 `--dashr-vvh` 收缩 `#root` + `scrollTo(0,0)` 抗 pan。
102
+ - **PWA standalone(Add to Home Screen)**:dev.to 2026-07 实测文(iOS 17/18):键盘弹出时 **`window.innerHeight`、`visualViewport.height`、`100dvh` 全部收缩**(引擎原生 resize,等价 `resizes-content`),`interactive-widget` 在 standalone 被忽略。已知 bug:**键盘关闭后 viewport 卡在小尺寸不恢复**(直到杀进程);社区解法 = blur 后 140ms 对全高元素做 `display:none→reflow→restore` 翻转强制重测(配 backdrop-filter 遮罩隐藏闪跳)。**TypingMind 零键盘代码成立的原因 = 其推荐移动形态是 PWA standalone**。DSH 的 manifest 已是 `display:fullscreen`,具备同路线条件。
103
+ - 策略组合(待讨论):浏览器内 Safari 用户 → M2 shim;PWA 用户 → 引擎原生 + 可选 viewport-stuck 自愈;是否把"装成 PWA"作为官方推荐移动用法(对齐 typingmind)是产品决策点(§6 决策点 3)。
104
+
105
+ ---
106
+
107
+ ## 4. 动态岛假设验证(user 2026-09-03 提出方向)
108
+
109
+ **方向部分成立 —— 动态岛确实参与了一类真实 viewport bug,但不是本症状的机制:**
110
+
111
+ - [WebKit bug 300523](https://bugs.webkit.org/show_bug.cgi?id=300523)(REGRESSION, iOS 26.0,iPhone 15 Pro 实测 100% 复现,非动态岛机型 iPhone 13 Pro Max iOS 18 不复现):键盘关闭或滚动/重渲染后,Safari **错误计算 visual viewport 高度,内容上移数像素侵入动态岛安全区**(fixed/sticky 头部漂移)。`viewport-fit=cover/contain`、`env(safe-area-inset-top)` padding、visualViewport JS 重算**均无法绕过**;Simon Fraser 确认 iOS 26.1 beta 已修。
112
+ - 该 bug 的症状是**纵向几像素漂移**,不是横向 115–120% 放大,且只在 iOS 26.0 存在(26.1 已修)。DSH 若在 iOS 26.0 真机观察到顶栏上漂数像素,即此 bug,升级即愈,应用侧无动作空间。
113
+ - **机制结论**:布局视口宽度由 viewport meta 决定(390pt 机型 = 390 CSS px),动态岛裁剪通过 `env(safe-area-inset-*)` 暴露、不改变布局宽度与缩放比;"focus 后重新拿 2000px 物理高度再按旧比例放大"无证据支持(按此假设放大应与焦点控件字号无关,而 TypingMind 16px 输入框在同一机型上不放大 —— 反证)。**120% 放大维持 font-size 机制定性**;动态岛类 bug 作为独立 bug class 记录在案。
114
+
115
+ ---
116
+
117
+ ## 5. 实现方案(v2,全部 better-dsh 插件增量,零上游改动;待批准后动工)
118
+
119
+ 配置通道沿用 `__DASHR_MOBILE__`(`web-trust.ts` boot script)扩键:`zoomGuard: 'font' | 'meta' | 'both' | 'off'`(默认待裁决)、`keyboardShim: true`、`focusGate: true`(若裁决保留 D3 修复)。
120
+
121
+ - **M1 放大防护**(对应 §3.2 A/B,二选一或叠加,boot script 装 B、claimStyles 装 A)。
122
+ - **M2 浏览器内键盘 shim**(§3.3;visualViewport → `--dashr-vvh` 收缩 `#root`;阈值 + scale guard + rAF 合帧)。
123
+ - **M2b standalone viewport 自愈**(可选):blur 后 display-flip 重测,防 PWA 态卡小 viewport。
124
+ - **M3 focus gate**(D3 触发层,裁决点 2):boot script 包 `HTMLElement.prototype.focus`,只拦窄屏 `[data-composer-input]` 的非用户发起聚焦(pointerdown 在 `[data-composer-card]`/弹层内放行)。若裁决"移动端切会话保留自动聚焦",则 M3 撤销,症状由 M1+M2 兜底(键盘弹出但可见、不放大)。
125
+ - ~~M4 自动收左栏~~ —— **撤销**(D4 no-go)。
126
+
127
+ 桌面零影响(全部 narrow-gated);验证计划维持 v1 §5(4999 预演 + client spec + 真机 iOS 清单),真机清单新增:iOS 26.0 顶栏上漂观察项(对照 300523)、PWA standalone 态键盘 + 卡死自愈验证。
128
+
129
+ ---
130
+
131
+ ## 6. 待讨论决策点(user 明确先讨论后开发)
132
+
133
+ 1. **放大防护选型**:A(字号地板,视觉变大)/ B(user-scalable=no 标记,iOS 10+ 不禁双指)/ A+B 双保险。倾向建议:**B 为主 + A 只保 composer**(B 零视觉扰动且 Discourse 实证;composer 16px 同时改善手机可读性)——待你裁决。
134
+ 2. **切会话自动聚焦(D3)**:移动端是否取消?(取消 = 切会话后纯净阅读态;保留 = 现状行为,靠 M1+M2 兜底症状。)
135
+ 3. **移动端官方形态**:是否把"Add to Home Screen(PWA standalone)"作为推荐用法(键盘问题在引擎层消失,对齐 typingmind 路线)?浏览器内 Safari 用户仍由 M2 覆盖。
136
+ 4. M2b(standalone 卡死自愈)是否纳入首版。
137
+
138
+ ## 7. 证据索引(v2 增补)
139
+
140
+ | 事实 | 位置/来源 |
141
+ |---|---|
142
+ | TypingMind 分发 = PWA only(无 DMG) | docs.typingmind.com/install-typingmind-app(2026-09-03) |
143
+ | 输入框 16px@390 / 14px@1280(实测) | CDP 活体测量,脚本 `work/typingmind-re/measure.mjs`,样本 `work/typingmind-re/typingmind-web-bundle.tar.gz` |
144
+ | Tailwind Forms 基线 1rem=16px | 其 CSS chunk(case 存档) |
145
+ | 无键盘 shim / 无 viewport 改写 | bundle 全量 grep(case 存档) |
146
+ | safe-area = env() 标准通道 | 其 CSS + chunk 3a4r…(case 存档) |
147
+ | Discourse A→B 迁移及 B 不禁双指缩放 | Discourse PR #30877 diff(注释原话) |
148
+ | standalone PWA 键盘 resize + 卡死 bug + display-flip 自愈 | dev.to/cederhook 2026-07(iOS 17/18 实测) |
149
+ | 动态岛 viewport bug(上移数像素,26.1 修) | WebKit bug 300523 |
150
+ | 浏览器内无 interactive-widget | WebKit bug 259770(仍 NEW/Nobody) |
151
+ | DSH 侧全部源码锚点 | 见 v1 §6(`apps/web/index.html:5`、`InputBar.module.css:55`、`boot-theme.ts:21`、`InputBar.tsx` unlock effect、`base.css:6`、`columns.ts`、`stores.ts`) |
152
+
153
+ ## 8. 参考文献
154
+
155
+ - [WebKit Bug 259770 – interactive-widget](https://bugs.webkit.org/show_bug.cgi?id=259770) · [WebKit Bug 300523 – Dynamic Island viewport shift](https://bugs.webkit.org/show_bug.cgi?id=300523)
156
+ - [Discourse PR #30877 – Replace font-size-ios-input workaround](https://github.com/discourse/discourse/pull/30877)
157
+ - [Fixing the iOS standalone-PWA keyboard bug (dev.to, 2026-07)](https://dev.to/cederhook/fixing-the-ios-standalone-pwa-keyboard-bug-that-shrinks-your-viewport-for-good-63d)
158
+ - [Chromium: viewport resize behavior](https://developer.chrome.com/blog/viewport-resize-behavior/) · [CSS Viewport §interactive-widget](https://drafts.csswg.org/css-viewport-1/#interactive-widget-section)
159
+ - [TIL: Avoid text-sm on inputs (guidefari)](https://guidefari.com/safari-ios-input-zoom/) · [SO #64076385 – 16px 反例存在性](https://stackoverflow.com/questions/64076385/input-zoom-on-iphone-safari-not-prevented-with-16px)
160
+ - typingmind.com(bundle/manifest/viewport 实测);docs.typingmind.com(install 文档)
package/lib/index.d.ts CHANGED
@@ -547,7 +547,6 @@ type ToolCallId = ToolExecutionInput['callId'];
547
547
  interface WebTrustConfig {
548
548
  /** Hostnames this operator declares their own devices' pages run on. */
549
549
  trustedPageAuthorities?: readonly string[];
550
- /** Mobile responsiveness knobs (client half consumes via page global). */
551
550
  mobile?: {
552
551
  enabled?: boolean;
553
552
  breakpoint?: number;
@@ -556,6 +555,15 @@ interface WebTrustConfig {
556
555
  leftEdgeBandPx?: number;
557
556
  rightZoneRatio?: number;
558
557
  swipeVelocityPxPerMs?: number;
558
+ /**
559
+ * iOS focus auto-zoom suppression mode (change
560
+ * `2026-09-03-ios-focus-zoom-suppression`): `'meta'` (default) rewrites
561
+ * the viewport meta on iOS-class narrow viewports; `'off'` is the escape
562
+ * hatch. `'font'` (16px floor, solution A) is a RESERVED value slot,
563
+ * deliberately not in the schema enum — unimplemented values fail loud
564
+ * at config load rather than no-op silently.
565
+ */
566
+ zoomGuard?: 'meta' | 'off';
559
567
  };
560
568
  }
561
569
  //#endregion
package/lib/index.js CHANGED
@@ -10707,6 +10707,251 @@ function installFailover(ctx, config = defaultFailoverConfig) {
10707
10707
  });
10708
10708
  }
10709
10709
 
10710
+ //#endregion
10711
+ //#region src/mobile/zoom-guard.ts
10712
+ /**
10713
+ * Whether a UA string + touch-capability pair is an iOS-class browser
10714
+ * (design D2, mirroring Discourse `capabilities.isIOS`): iPhone/iPod/iPad,
10715
+ * plus iPadOS 13+ desktop-Safari masquerade (`Macintosh` UA with a
10716
+ * multi-touch digitizer). Deliberately NOT `pointer:coarse` — Android Chrome
10717
+ * never focus-zooms and `maximum-scale` has historical side effects there,
10718
+ * so the gate is tightened to the iOS family. UA-shape drift degrades
10719
+ * benignly: a novel UA simply reads as non-iOS and no rewriting happens.
10720
+ *
10721
+ * @param ua - `navigator.userAgent`.
10722
+ * @param maxTouchPoints - `navigator.maxTouchPoints` (0 when absent).
10723
+ * @returns `true` for iOS-class browsers.
10724
+ */
10725
+ function isIOSClassUA(ua, maxTouchPoints) {
10726
+ return /iPhone|iPod|iPad/.test(ua) || /Macintosh/.test(ua) && maxTouchPoints > 1;
10727
+ }
10728
+ /**
10729
+ * Token-level merge of viewport directives into a viewport meta `content`
10730
+ * string (design D4): existing tokens keep their positions, a token whose key
10731
+ * (compared case-insensitively) is in `tokens` is REPLACED by the configured
10732
+ * key=value, and configured keys not present are APPENDED in insertion order.
10733
+ * Idempotent by construction — merging always re-derives from the input, so
10734
+ * repeated evaluation cannot accumulate duplicates.
10735
+ *
10736
+ * mergeViewportTokens('width=device-width, initial-scale=1', T)
10737
+ * → 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'
10738
+ * mergeViewportTokens('width=device-width, maximum-scale=5', T)
10739
+ * → 'width=device-width, maximum-scale=1, user-scalable=no' // same-key override, position kept
10740
+ *
10741
+ * @param content - the stock (or stock-equivalent) content string.
10742
+ * @param tokens - the directives to merge, keyed lowercase.
10743
+ * @returns the merged content string.
10744
+ */
10745
+ function mergeViewportTokens(content, tokens) {
10746
+ var out = [];
10747
+ var seen = {};
10748
+ var parts = content.split(",");
10749
+ for (var i = 0; i < parts.length; i++) {
10750
+ var part = parts[i];
10751
+ var raw = (part === void 0 ? "" : part).replace(/^\s+|\s+$/g, "");
10752
+ if (raw === "") continue;
10753
+ var eq = raw.indexOf("=");
10754
+ var key = eq < 0 ? raw : raw.slice(0, eq).replace(/^\s+|\s+$/g, "");
10755
+ var hit = null;
10756
+ for (var k in tokens) if (tokens.hasOwnProperty(k) && k.toLowerCase() === key.toLowerCase()) {
10757
+ hit = k;
10758
+ break;
10759
+ }
10760
+ if (hit === null) {
10761
+ out[out.length] = raw;
10762
+ continue;
10763
+ }
10764
+ out[out.length] = hit + "=" + tokens[hit];
10765
+ seen[hit] = true;
10766
+ }
10767
+ for (var j in tokens) if (tokens.hasOwnProperty(j) && !seen[j]) out[out.length] = j + "=" + tokens[j];
10768
+ return out.join(", ");
10769
+ }
10770
+ /**
10771
+ * The double gate (design D2/D3 + the `zoomGuard` config): rewrite applies
10772
+ * only on an iOS-class browser AND a narrow viewport AND a non-`off`
10773
+ * `zoomGuard`. An absent payload (or absent key) means the `'meta'` default —
10774
+ * the same default-on posture as the mobile leg itself — so only an explicit
10775
+ * `'off'` disarms the guard. Unknown non-`off` values read as `'meta'`
10776
+ * (schema validation makes them unreachable in production; benign here).
10777
+ *
10778
+ * @param config - the `__DASHR_MOBILE__` payload (only `zoomGuard` is read).
10779
+ * @param isIOS - {@link isIOSClassUA} verdict for this page load.
10780
+ * @param isNarrow - `matchMedia('(max-width: breakpoint-0.02px)')` verdict.
10781
+ * @returns `true` when the viewport rewrite should be applied.
10782
+ */
10783
+ function shouldApplyZoomGuard(config, isIOS, isNarrow) {
10784
+ if (!isIOS || !isNarrow) return false;
10785
+ if (!config) return true;
10786
+ return config.zoomGuard !== "off";
10787
+ }
10788
+ /**
10789
+ * Standalone display-mode verdict (design D4 of change
10790
+ * `2026-09-03-zoomguard-standalone-font-floor`): standalone PWAs HONOR
10791
+ * `user-scalable=no` (the real-device pinch regression that motivated the
10792
+ * display-mode split), so they must never enter the meta-rewrite path. Two
10793
+ * sources, strict-`true` OR: the `(display-mode: standalone)` media query
10794
+ * (primary) and legacy `navigator.standalone` (older iOS Safari exposes only
10795
+ * the latter). Anything else — `undefined` (engine without the source),
10796
+ * `false`, or truthy junk — reads as NOT standalone.
10797
+ *
10798
+ * @param mqMatchesStandalone - `matchMedia('(display-mode: standalone)').matches`.
10799
+ * @param navStandalone - `navigator.standalone === true`.
10800
+ * @returns `true` in standalone display mode (either source).
10801
+ */
10802
+ function isStandaloneDisplay(mqMatchesStandalone, navStandalone) {
10803
+ return mqMatchesStandalone === true || navStandalone === true;
10804
+ }
10805
+ /**
10806
+ * The 16px font floor stylesheet (design D3): iOS auto-zooms on focus into
10807
+ * ANY control whose computed font-size is below 16px, so in standalone mode
10808
+ * — where the meta rewrite is off the table (pinch must stay
10809
+ * engine-available) — the floor IS the suppression. Scoped by the same
10810
+ * breakpoint band the meta leg uses and derived the same way
10811
+ * (`breakpoint - 0.02`: 768 → 767.98, 900 → 899.98); `!important` beats the
10812
+ * 13/14px CSS module rules and the body inline var chain;
10813
+ * `[contenteditable="true"]` matches only the editable state (React
10814
+ * serializes `contentEditable={bool}` to the literal attribute value
10815
+ * "true"/"false").
10816
+ *
10817
+ * @param breakpointPx - the configured `mobile.breakpoint` (raw — this
10818
+ * function derives the `-0.02` band itself, do NOT pre-subtract).
10819
+ * @returns the complete stylesheet text.
10820
+ */
10821
+ function buildFontFloorCss(breakpointPx) {
10822
+ return "@media (max-width:" + (breakpointPx - .02) + "px){ input,textarea,select,[contenteditable=\"true\"]{ font-size:16px !important } }";
10823
+ }
10824
+ /**
10825
+ * Build the boot script's zoomGuard section: the five decision predicates
10826
+ * embedded via `toString()` plus the DOM state machine that applies them.
10827
+ * The machine implements design D3/D4 with one discovered refinement —
10828
+ *
10829
+ * **The standalone fork (2026-09-03, change
10830
+ * `2026-09-03-zoomguard-standalone-font-floor`).** Real-device datum:
10831
+ * standalone (Add to Home Screen) iOS HONORS `user-scalable=no`, so the
10832
+ * meta rewrite that is benign in-browser (pinch stays engine-controlled)
10833
+ * kills pinch in the PWA — the user's primary surface. The section
10834
+ * therefore forks right after the iOS gate, BEFORE any meta machinery: a
10835
+ * one-shot startup verdict (display-mode never flips mid-session) via the
10836
+ * `(display-mode: standalone)` MQ or legacy `navigator.standalone` (D4).
10837
+ * Standalone → inject the `ios-zoom-font-floor` style element into the
10838
+ * head (16px floor, media-query-scoped to the same breakpoint band, D3)
10839
+ * and RETURN — no provisional meta, no reconcile observer, no ladder
10840
+ * timer, no listeners, byte-identical stock meta (D1). The width dimension
10841
+ * lives inside the stylesheet's media query, so the injection itself is
10842
+ * NOT width-gated: a wide standalone load still gets the element; the rule
10843
+ * simply does not match above the breakpoint. Browser mode runs the v0.2.4
10844
+ * machinery unchanged.
10845
+ * **Why reconciliation exists.** The webserver splices head injections
10846
+ * immediately after the opening `<head>` tag, i.e. BEFORE the stock
10847
+ * `<meta name="viewport">` in upstream's `index.html`. At boot-script time
10848
+ * the stock meta is therefore NOT yet parsed, so the "meta missing"
10849
+ * branch is the NORMAL path on the served shell, not an edge case. A
10850
+ * provisional meta is created right away (the timing requirement: the
10851
+ * suppressive tokens are live before any application bundle can focus an
10852
+ * input), and a MutationObserver watches for the parser to insert the stock
10853
+ * meta; the moment it appears, the guard records the true stock content,
10854
+ * rewrites the STOCK meta (token merge, D4's actual semantics — restore
10855
+ * later writes these exact bytes back), and removes the provisional one —
10856
+ * leaving a single-meta document instead of relying on engines'
10857
+ * unspecified multi-meta merge behavior. Engines without MutationObserver
10858
+ * keep the provisional meta for the page's lifetime (degraded but still
10859
+ * suppressive); the observer is never started once the stock meta was
10860
+ * found synchronously.
10861
+ *
10862
+ * **Why the re-evaluation ladder exists (2026-09-03 rework, lead CDP
10863
+ * finding on the 4999 instance).** The boot script runs during head parsing,
10864
+ * BEFORE the engine applies the stock viewport meta — the layout viewport at
10865
+ * first evaluation is the pre-meta engine default (980px observed), so
10866
+ * `mq.matches` reads false on an initial narrow load, and the narrowing that
10867
+ * follows meta application does NOT reliably dispatch `resize` to the page
10868
+ * during load (headless Chrome: never). A ~10ms poll ladder therefore
10869
+ * re-evaluates until the guard IS applied, `document.readyState` reaches
10870
+ * `'complete'`, or ~2s elapse — the first layout always precedes deferred
10871
+ * scripts, so the ladder lands the guard before any application bundle can
10872
+ * focus an input. Steady-state ticks are idempotent no-ops; a wide load
10873
+ * idles to the readyState stop and leaves no timer behind (a pending tick
10874
+ * is also cancelled the moment the guard lands). MediaQuery `change` events
10875
+ * (with the legacy `addListener` fallback) form a second re-evaluation
10876
+ * channel alongside window `resize`.
10877
+ *
10878
+ * State machine (ES5, zero dependencies, synchronous first evaluation):
10879
+ * `meta` is the element under management (provisional or reconciled stock),
10880
+ * `stock` its recorded stock content, `mine` whether we created it,
10881
+ * `applied` the last applied gate verdict. `apply(on)` is idempotent per
10882
+ * state (no-op when the verdict is unchanged); merges always re-derive
10883
+ * from the recorded stock, never from the live content, so resize storms
10884
+ * cannot accumulate tokens. Non-iOS pages never wire any listener.
10885
+ *
10886
+ * Emitted only when the mobile payload exists and `zoomGuard !== 'off'`.
10887
+ *
10888
+ * @returns the inline section text (truthy `var` declarations + IIFE).
10889
+ */
10890
+ function buildZoomGuardSection() {
10891
+ return [
10892
+ `var ZI=${isIOSClassUA.toString()};`,
10893
+ `var ZM=${mergeViewportTokens.toString()};`,
10894
+ `var ZS=${shouldApplyZoomGuard.toString()};`,
10895
+ `var ZD=${isStandaloneDisplay.toString()};`,
10896
+ `var ZF=${buildFontFloorCss.toString()};`,
10897
+ "(function(){",
10898
+ "var m=window.__DASHR_MOBILE__;",
10899
+ "var ios=ZI(navigator.userAgent,(navigator.maxTouchPoints||0));",
10900
+ "if(!ios)return;",
10901
+ "var bpx=(m&&typeof m.breakpoint==='number'&&m.breakpoint>0)?m.breakpoint:768;",
10902
+ "var bp=bpx-0.02;",
10903
+ "var sa=ZD((window.matchMedia&&window.matchMedia('(display-mode: standalone)').matches),(navigator.standalone===true));",
10904
+ "if(sa){var f=document.createElement('style');f.setAttribute('id','ios-zoom-font-floor');f.setAttribute('data-plugin','better-dsh');f.setAttribute('data-plugin-css','better-dsh/zoom-font-floor');f.textContent=ZF(bpx);headEl().appendChild(f);return}",
10905
+ "var mq=(typeof window.matchMedia==='function')?window.matchMedia('(max-width:'+bp+'px)'):null;",
10906
+ "var T={'maximum-scale':'1','user-scalable':'no'};",
10907
+ "var meta=null,stock=null,mine=false,applied=false,obs=null,lad=null,lN=0;",
10908
+ "function findStock(){var s=document.getElementsByTagName('meta');for(var i=0;i<s.length;i++){var e=s[i];if(e!==meta&&(e.getAttribute('name')||'').toLowerCase()==='viewport')return e}return null}",
10909
+ "function headEl(){return document.head||document.getElementsByTagName('head')[0]||document.documentElement}",
10910
+ "function stopWatch(){if(obs){obs.disconnect();obs=null}}",
10911
+ "function stopLadder(){if(lad){clearTimeout(lad);lad=null}}",
10912
+ "function dropMine(){if(meta&&mine){if(meta.parentNode)meta.parentNode.removeChild(meta);meta=null;mine=false}}",
10913
+ "function apply(on){",
10914
+ "if(on===applied)return;",
10915
+ "applied=on;",
10916
+ "if(on)stopLadder();",
10917
+ "if(on){",
10918
+ "var el=findStock();",
10919
+ "if(el){meta=el;mine=false;stock=el.getAttribute('content')||''}",
10920
+ "else if(!meta){meta=document.createElement('meta');meta.setAttribute('name','viewport');mine=true;stock='';headEl().appendChild(meta)}",
10921
+ "meta.setAttribute('content',ZM(stock,T));",
10922
+ "if(mine&&typeof MutationObserver==='function'&&!obs){obs=new MutationObserver(check);obs.observe(document.documentElement,{childList:true,subtree:true})}",
10923
+ "}else{",
10924
+ "stopWatch();",
10925
+ "if(!meta)return;",
10926
+ "if(mine)dropMine();",
10927
+ "else meta.setAttribute('content',stock);",
10928
+ "}",
10929
+ "}",
10930
+ "function check(){",
10931
+ "var el=findStock();",
10932
+ "if(!el)return;",
10933
+ "stopWatch();",
10934
+ "stock=el.getAttribute('content')||'';",
10935
+ "if(applied){dropMine();meta=el;mine=false;meta.setAttribute('content',ZM(stock,T))}",
10936
+ "else{dropMine();meta=null;stock=null}",
10937
+ "}",
10938
+ "function re(){apply(ZS(m,true,mq?mq.matches:false))}",
10939
+ "function tick(){",
10940
+ "lad=null;",
10941
+ "re();",
10942
+ "if(applied)return;",
10943
+ "lN++;",
10944
+ "if(document.readyState==='complete'||lN>=200)return;",
10945
+ "lad=setTimeout(tick,10);",
10946
+ "}",
10947
+ "re();",
10948
+ "window.addEventListener('resize',re);",
10949
+ "if(mq&&mq.addEventListener)mq.addEventListener('change',re);else if(mq&&mq.addListener)mq.addListener(re);",
10950
+ "if(!applied&&typeof setTimeout==='function')lad=setTimeout(tick,10);",
10951
+ "})()"
10952
+ ].join("");
10953
+ }
10954
+
10710
10955
  //#endregion
10711
10956
  //#region src/web-trust.ts
10712
10957
  /**
@@ -10752,12 +10997,16 @@ function buildBootScript(config) {
10752
10997
  ...mobile?.dominanceRatio !== void 0 ? { dominanceRatio: mobile.dominanceRatio } : {},
10753
10998
  ...mobile?.leftEdgeBandPx !== void 0 ? { leftEdgeBandPx: mobile.leftEdgeBandPx } : {},
10754
10999
  ...mobile?.rightZoneRatio !== void 0 ? { rightZoneRatio: mobile.rightZoneRatio } : {},
10755
- ...mobile?.swipeVelocityPxPerMs !== void 0 ? { swipeVelocityPxPerMs: mobile.swipeVelocityPxPerMs } : {}
11000
+ ...mobile?.swipeVelocityPxPerMs !== void 0 ? { swipeVelocityPxPerMs: mobile.swipeVelocityPxPerMs } : {},
11001
+ ...mobile?.zoomGuard !== void 0 ? { zoomGuard: mobile.zoomGuard } : {}
10756
11002
  } : void 0;
10757
11003
  if (authorities.length === 0 && mobilePayload === void 0) return void 0;
10758
11004
  const parts = ["(function(){try{"];
10759
11005
  if (authorities.length > 0) parts.push("if(!window.__DSH_TRANSPORT__){", `var h=location.hostname,a=${JSON.stringify(authorities)};`, "for(var i=0;i<a.length;i++)if(a[i]===h){window.__DSH_TRANSPORT__={ownsHost:true};break}", "}");
10760
- if (mobilePayload !== void 0) parts.push(`window.__DASHR_MOBILE__=${JSON.stringify(mobilePayload)};`);
11006
+ if (mobilePayload !== void 0) {
11007
+ parts.push(`window.__DASHR_MOBILE__=${JSON.stringify(mobilePayload)};`);
11008
+ if (mobile?.zoomGuard !== "off") parts.push(buildZoomGuardSection());
11009
+ }
10761
11010
  parts.push("}catch(e){}})();");
10762
11011
  return parts.join("");
10763
11012
  }
@@ -10802,7 +11051,8 @@ const MOBILE_CONFIG = z.object({
10802
11051
  dominanceRatio: z.number().min(1).default(1.3),
10803
11052
  leftEdgeBandPx: z.natural().min(8).default(120),
10804
11053
  rightZoneRatio: z.number().min(.05).max(.9).default(.25),
10805
- swipeVelocityPxPerMs: z.number().min(0).default(.15)
11054
+ swipeVelocityPxPerMs: z.number().min(0).default(.15),
11055
+ zoomGuard: z.union(["meta", "off"]).default("meta")
10806
11056
  });
10807
11057
  /** Runtime schema. */
10808
11058
  const Config = z.intersect([DashrRuntime.Config, z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-dsh",
3
- "version": "0.2.2-a",
3
+ "version": "0.2.2-b",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,110 +0,0 @@
1
- # REPL 工具调用截断诊断(v0.2.1b 实测中发现,2026-09-01)
2
-
3
- ## 结论先行
4
-
5
- **任何从 REPL(`eval` cell)内部发起的工具调用(`tool.<name>(...)`)都会让 dsh web daemon 进程崩溃并自动重启;会话日志在 `tool/code-dispatch-start` 处撕裂,恢复时 repair 机制合成「interrupted-tool-result」,模型看到的即「工具调用被截断、结果未知」。** 4 次探针 4 次复现,与 journald 守护进程崩溃记录逐条对应。
6
-
7
- 这不是 REPL 同步/异步的问题,也不是 subagent 特有问题——是 dashr 子分派桥接层的结构性缺陷 + 部署运行态注册表视图缺 symbol 的直接原因叠加。
8
-
9
- ## 一、复现矩阵(第一人称)
10
-
11
- | # | cell 内容 | 结果 |
12
- |---|---|---|
13
- | 1 | `await tool.subagent({… run_in_background: false })` | 截断(daemon 崩溃 17:39:17) |
14
- | 2 | `await tool.subagent({… run_in_background: true })` | 截断(daemon 崩溃 17:41:15) |
15
- | 3 | `await tool.bash({… run_in_background: true })` | 截断(daemon 崩溃 17:57:05) |
16
- | 4 | `await tool.read({path: "dashr/package.json"})` | 截断(daemon 崩溃 18:02:27) |
17
-
18
- 对照:纯 Python cell(`dir(tool)`、顶层 `return` 判错、`await asyncio.sleep(0)`)全部正常。**凡含 `tool.*` RPC 的 cell 必崩,与工具、与 background 与否无关。**
19
-
20
- ## 二、截断面定位(三层证据)
21
-
22
- ### 1. 会话日志(`~/.dsh/sessions/…/session.jsonl.zstd`)
23
- 4 次截断的共同指纹:`tool/call (eval)` → `tool/code-dispatch-start`(**已持久化**)→ 日志撕裂(无 `tool/code-dispatch`、无 `step/end`/`turn/end`)→ 重载时 `packages/core/session/src/repair.ts` 的 `interruptedTurnClosers` 合成 `interrupted-tool-result-<callId>-<seq>`(`TOOL_OUTCOME_UNKNOWN`)。会话内 **0 条 error 事件**——错误根本没走到会话层。
24
-
25
- ### 2. 守护进程日志(`journalctl --user -u dsh.service`)——铁证
26
- ```
27
- Sep 01 17:39:17 pnpm[414449]: dsh: fatal load failure: TypeError: Cannot read properties of undefined (reading 'prepare')
28
- at Object.start (…/better-dsh/lib/index.js:11281:41)
29
- at <anonymous> (…/lib/index.js:11191:21)
30
- at drive (…/lib/index.js:11207:7)
31
- at outcome (…/lib/index.js:11310:6)
32
- at new Promise (<anonymous>)
33
- at toolFunctions.<computed> (…/lib/index.js:11320:72)
34
- at Proxy.dispatchHostRequest (…/lib/index.js:2180:21)
35
- Sep 01 17:39:19 systemd: dsh.service: Main process exited, code=exited, status=1/FAILURE
36
- Sep 01 17:39:23 systemd: dsh.service: Scheduled restart job, restart counter is at 1.
37
- ```
38
- 17:39:17 / 17:41:15 / 17:57:05 / 18:02:27 四次崩溃,栈完全一致。`scheduler.prepare` 中 `scheduler` 为 `undefined`——即 `registry[TOOL_RUNTIME_SCHEDULER]` 在部署运行态取不到值。
39
-
40
- ### 3. 源码链(`dashr/src/index.ts`)
41
- - kernel 侧:`tool.<name>(...)` → `binding.call` RPC(`src/bootstrap.ts` `_dashr_callable`)。
42
- - host 侧:`dispatchHostRequest`(`src/runtime.ts:545`)→ `await fn(args)`(`src/index.ts` `binding()`)。
43
- - `binding()` 内部(`src/index.ts:666`):`const scheduler = registry[TOOL_RUNTIME_SCHEDULER]` → 单驱动道 `drive()`(`src/index.ts:591-621`)→ `start()` 内 `await scheduler.prepare(input)`(`src/index.ts:712`)。
44
- - **结构性缺陷**:`drive()` 的 async IIFE 是 `try { … } finally { … }`,**没有 catch**;调用处是 `void drive()`。任何 `start()`/`commit()` 抛错(如 `scheduler.prepare` TypeError)都会变成 **unhandled rejection**——binding promise 既不 resolve 也不 reject,cell 永久挂起,错误外泄给进程。
45
- **完整链条**:cell 工具调用 → dispatch 启动(`code-dispatch-start` 已入日志)→ `registry[TOOL_RUNTIME_SCHEDULER]` 为 undefined → `scheduler.prepare` TypeError → driver 无 catch → unhandled rejection → `installFailLoud` 判定 fatal → daemon exit(1) → systemd 重启 → 会话重载 repair 合成「interrupted-tool-result」→ 模型收到截断消息。**截断面:binding 桥 → registry symbol 查询 → driver 的未处理拒绝。**
46
-
47
- ## 三、命名澄清:REPL dispatch log 与 code-dispatch 是两个不同层(重要)
48
-
49
- 容易混淆,先分清:
50
-
51
- | 名字 | 层级 | 是谁 | 本次 change 是否改名 |
52
- |---|---|---|---|
53
- | `tool/code-dispatch-start` / `tool/code-dispatch` | **会话持久日志事件**(durable log,`agent.session.append`) | dashr `src/index.ts:689,712`;harness `known-event-types.ts:63-64` 注册 | **否**——v0.1.x 就有,历史遗留名 |
54
- | `dashr/repl-dispatch-log` | **waterfall 扩展点**(内容整形监听事件) | dashr 自注册(`src/index.ts:458`) | **是**——v0.2.1 从上游 `tools/code-dispatch-log`/`tools/ptc-dispatch-log` 改名为「自己的 REPL dispatch log」 |
55
- | `tools/ptc-dispatch-log` | waterfall(上游 PTC 用) | harness `src/ptc.ts:282` + spill policy 监听(`spill-policy/src/index.ts:217`) | dashr 已不再 emit(仅注释提及) |
56
-
57
- **结论**:
58
- - 「自己的 REPL dispatch log」`dashr/repl-dispatch-log` **在代码里存在且自注册**——目标形态已就位。它之所以「没被触发」,是因为崩溃发生在它之前的 `scheduler.prepare`(它在 settle 之后才跑)——**是崩溃的结果,不是原因**。
59
- - 会话日志里我用作定位证据的 `tool/code-dispatch-start` 是**持久日志事件名**,与 waterfall 改名无关,只是「撕裂点之前最后一条已写入事件」的定位标记。
60
- - 改名引入了一个**真实但非崩溃的副作用**:harness 的 spill policy 仍监听上游 `tools/ptc-dispatch-log`,dashr 不再 emit → 溢出限幅(oversized 结果的 preview+locator 替换)对 eval 子调用日志**静默失效**。**已补(2026-09-01 晚,见六)**:dashr `shapeDispatchLog` 跑完 `dashr/repl-dispatch-log` 后,再把同一 dispatch(载荷结构与上游 `PtcDispatchLog` 完全一致)喂进 `tools/ptc-dispatch-log` 载体,spill 臂恢复生效。改动在 `dashr/src/index.ts`,lib 已重建(`f0febba7…`),**部署位同步 + daemon 重启待用户执行**。
61
-
62
- ## 四、为什么 `registry[TOOL_RUNTIME_SCHEDULER]` 是 undefined(直接原因)
63
-
64
-
65
- - 已排除:dsh-tools 双副本 symbol 不一致(实测插件与 daemon 均解析到同一 realpath `/home/u1/workspaces/dsh-alpha/packages/core/tools`,symbol 严格相等);dashr 分派代码改动(266d232/06e516a/HEAD 逐字节一致);dsh-tools alpha.1 vs alpha.3 差异(仅 import 整理/ToolCallId brand 等外观差异)。
66
- - 关键观察:`classify()` 调用的 `registry.executionMode(input)` **正常**(`code-dispatch-start` 已写入日志,证明 `start()` 之前的分派逻辑跑通),而 `registry[TOOL_RUNTIME_SCHEDULER]` **取不到**。`executionMode` 是 ToolRuntime 类方法、`TOOL_RUNTIME_SCHEDULER` 是类实例 symbol 字段——即部署运行态 `runtimeCtx.tools` 返回的**不是原生 ToolRuntime 实例**,而是一个有方法、缺实例 symbol 字段的服务视图(cordis 作用域服务解析 / plugin tree 挂载路径差异)。
67
- - 测试为什么全绿:dashr 测试组合(`test/helpers.ts`)把 `ctx.tools` 解析到**根作用域的原生 ToolRuntime 实例**,symbol 在位;生产组合经 harness plugin tree / scope 层解析,拿到的是视图。
68
- - **何时坏的(重要修正)**:v0.1.8 实测报告(2026-08-24)实证 DASHR 自己的 `eval` 桥当时**正常工作**——同 cell 并发 `tool.read` + `tool.bash` 完成。2026-08-28 会话用的是 harness 自带 PTC `run_cell`(`tools.bash`,复数命名空间 = PTC SDK 形态;该 PTC 核心经 `@deepseek-ai/dsh-code-runtime-python` 执行 **Python**,非 TS),不能代表 dashr 桥状态。**崩溃由 2026-09-01 的 harness alpha(0.1.2-alpha.1)升级引入**——升级后 plugin tree 里 `runtimeCtx.tools` 变成缺实例 symbol 字段的服务视图;dashr v0.2.1 的 dispatch-log 改名不是崩溃原因。
69
-
70
- ## 五、REPL 同步/异步问题的回答(顺带闭环)
71
-
72
- - 模型层:`eval` 是同步工具调用,模型等 cell 结果——这是设计使然,与缺陷无关。
73
- - cell 内:`tool.<name>(...)` 是 kernel→host 的 `binding.call` RPC,被 cell `await`;host 经与原生调用同一 registry 分派器执行。`run_in_background: true` 的工具实现**快速返回 id**(subagent `{kind:'continuable', subagentId}`、bash 返回 job id),background 语义本应保留、子任务与 cell 结束无关。
74
- - 但**当前运行时该路径整体坏掉**:任何子分派在 `scheduler.prepare` 即 TypeError,与 background 无关——所以「在 REPL 里 background 是否异步」目前是伪问题,路径不通;修好后 background 语义会按上述设计成立。
75
-
76
- ## 六、修复方向(分层)
77
-
78
- 1. **dashr 立即修(阻断 daemon 崩溃)**:`drive()` 加 catch / `start()`、`commit()` 失败必须 settle binding promise 为错误结果——cell 内工具调用失败应变成可见的 `ToolCallError`,**绝不允许以 unhandled rejection 外泄杀 daemon**。这是把「任何分派错误」放大成「整个运行时崩溃」的放大器,无论直接原因是什么都必须先修。
79
- 2. **dashr 防御**:`registry[TOOL_RUNTIME_SCHEDULER]` 为 undefined 时抛带上下文的 loud 错误(cell 可见),而不是裸 `undefined.prepare` TypeError。
80
- 3. **根因定位(harness 接线)**:在活体 daemon 上探针确认 plugin tree 中 `runtimeCtx.tools` 的实际对象(cordis 作用域服务视图 / 第二个 tools 实例),修正挂载使插件拿到原生 ToolRuntime(symbol 在位)。这一步需要 daemon 侧可写环境(本会话 bwrap 沙箱只读宿主,无法直接探查活体)。
81
- 4. **测试补强(部分完成)**:已加「`tools/ptc-dispatch-log` 载体回喂」回归用例(`test/bridge.spec.ts`,全量 396 绿)。仍缺「生产形态」挂载测试——按 harness 的 plugin-tree/scope 方式挂载插件,断言 `ctx.tools[TOOL_RUNTIME_SCHEDULER]` 已定义 + 一个真实工具 RPC 从 cell 完成;防止此环境差异回归。
82
- 5. **回归核验基线**:修复后重跑本文档第一节的 4 探针矩阵(4/4 应全部返回结果而非截断),并观察 `journalctl --user -u dsh.service` 无新崩溃。
83
-
84
- ## 附:证据文件
85
- - 会话日志:`~/.dsh/sessions/--home-u1-workspaces-dashr--/session-ad9ac311-3400-4304-b501-e7303da850df/session.jsonl.zstd`
86
- - 守护进程日志:`journalctl --user -u dsh.service`(17:39:17 / 17:41:15 / 17:57:05 / 18:02:27 四条 fatal load failure)
87
- - 对照工作会话:`06ac8b2c-fb2e-458f-80d6-a0d28e50f193`(2026-08-28,9 对完整 code-dispatch,PTC run_cell 路径)
88
-
89
- ---
90
-
91
- ## 七、v0.2.1c 修复执行记录(2026-09-01,change v0-2-1c-repl-tool-crash-fix)
92
-
93
- ### 六.1 + 六.2 已实现(dashr 侧)
94
-
95
- - `drive()` async IIFE 补 `catch`:lane 级 backstop——任何逃逸 start()/commit() 自身包裹的错误(或 classify() 抛错)settle 所有 queued-but-unsettled 分派为可见错误、清空队列、复位 exclusive、记 warn,**绝不外泄 unhandled rejection**。
96
- - `PendingDispatch` 新增 `fail(error)`:把 binding promise settle 为 `{ isError: true, message }`(经 `settleError` → `settle`,kernel 转 `ToolCallError`)。
97
- - `start()` / `commit()` 各自 try/catch:prepare 失败、dispatch body rejection、finalize/finish 失败都 settle 自己的 binding,不再向 lane 抛。
98
- - `binding()` 内 `registry[TOOL_RUNTIME_SCHEDULER]` 判空:undefined 时抛带上下文的 loud 错误(点名 symbol、指向 harness 挂载接线),替代裸 `undefined.prepare` TypeError。
99
- - 回归测试 3 个(`test/bridge.spec.ts`,全量 399 绿含 3 新):view 缺 symbol → cell 收到含 `TOOL_RUNTIME_SCHEDULER` 的 loud 错误;`scheduler.prepare` 抛错 → binding settle 为可见错误;classify 抛错 → lane backstop settle 且进程存活。
100
-
101
- ### 六.3 根因探针(本轮结论,待活体 daemon 复核)
102
-
103
- - **根因已锁定并更正(2026-09-02 活体探针 + 受控实验;推翻本节 2026-09-01 的 shadow 分支推断)**:真实根因是**部署拓扑 dual-copy**——崩溃期(2026-09-01 17:39–18:02)部署位插件嵌套 `node_modules/@deepseek-ai/*` 的 17 条 symlink(已退役 dev 接线)指向 dsh-alpha 源码树,插件 import 的 `TOOL_RUNTIME_SCHEDULER` 来自 alpha 副本的 dsh-tools 模块实例;daemon 的 `ToolRuntime` 实例由 host vendored 副本构建,其 symbol 字段以 host 副本 symbol 为键。两 symbol 为不同 `Symbol()` 实例:跨副本读实例字段 → `undefined`(`executionMode` 等字符串键方法不受影响、照常工作——症状逐字吻合)。
104
- - 证据链(2026-09-02):① 活体 4 探针(`tool.bash` fg/bg、`tool.read`、`tool.subagent` bg:false/bg:true)经 `eval` cell 路径 4/4 真实结果——现行生产形态 symbol 在位;② 部署位 `require.resolve('@deepseek-ai/dsh-tools')` = `/home/u1/.local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools/lib/index.js`(host vendored,③→④ 层);③ 受控实验:host 与 z_dsh-alpha 两副本 `TOOL_RUNTIME_SCHEDULER` 严格不等,host 键实例经 alpha symbol 读 → `undefined`、经 host symbol 读 → object;④ 时间线闭环:改名 `dsh-alpha → z_dsh-alpha`(2026-09-01 22:29)→ symlink 悬空 → 2026-09-02 清理删除 → daemon 重启 → 自愈。
105
- - shadow 分支理论为何被否:它与症状吻合但从未被活体证实,且无法解释「拓扑清理 + 重启后自愈」;dual-copy 机制同时解释故障与恢复。v0.2.1c 报告引「symbol 严格相等」的排除测量与崩溃期拓扑矛盾(疑测同一副本两次或取于改名后),以直接证据为准。
106
- - cordis traceable proxy 本身无嫌疑(symbol 转发分支在位;现行活体 plain read 即返回带 symbol 原生实例)。
107
- - dashr 侧交付边界:六.1+六.2(不崩溃 + loud 错误)继续有效;dual-copy 已由部署拓扑收敛消除(部署纪律:插件 `node_modules/@deepseek-ai/` 只留 `schemastery`+`cosmokit`);v0.2.1d 给 guard 错误附 dsh-tools 解析路径实现事件自诊断。
108
- ### 六.4 生产形态挂载测试(说明)
109
-
110
- - 三个回归用例以「缺 symbol 视图」「prepare 抛错」「classify 抛错」三种形态驱动 `createRunCellTool`,覆盖六.1+六.2 全部防御面;测试组合(根作用域原生实例)本就 symbol 在位,断言 `runtimeCtx.tools[TOOL_RUNTIME_SCHEDULER]` 定义的用例属于测试组合自身形态(根挂载原生实例),无法复现生产 realm 差异——该差异须在活体 daemon 上复核(六.5)。
@@ -1,14 +0,0 @@
1
- # Bridge the tool layer, not the service layer
2
-
3
- DASHR's `rlm()` family dispatches upstream delegation tools through the tool registry (nested sub-dispatch), not `ctx.subagents` service methods. The tool layer carries the deployment's enforcement surface — approval pipeline, sandbox policy, per-instance config (maxDepth, backgroundMode, persona) — that a direct service call would silently bypass. The cost: per-call model selection is impossible, because the tool schema exposes no `model` parameter; `rlm(model=...)` from 0.1.4 is dropped, and `subagentModel` degrades to a static `agentOptions.model` in the preset patch.
4
-
5
- ## Considered Options
6
-
7
- - **Service layer direct** (0.1.4's approach): `ctx.subagents.start()` with a hand-built request. Full control over request fields (including `agentOptions.model` and `maxDepth`), but every policy the tool instance would have applied must be re-implemented or lost.
8
- - **Tool layer nested dispatch** (chosen): `rlm("spawn")` executes the registry's `subagent` tool with a parent token. Upstream policy is inherited wholesale; the schema boundary is the tool's own contract.
9
-
10
- ## Consequences
11
-
12
- - `rlm(mode, prompt, *, label, run_in_background)` — no `model` kwarg. Parent-model inheritance is the default; a different child model requires a preset patch, not a call argument.
13
- - Depth enforcement comes from the tool instance's `maxDepth` config, patched to 10 in the preset (see `dev/kernel-refactoring/V0.1.5-development-plan.md` Q22/Q23).
14
- - The upstream delegation tools (`subagent`, `subagent_fork`, `interrupt_agent`) must stay registered and executable even though the model never sees their names — which is why masking is presentation-only (ADR-0002).