@gavinc-cn/touchstone-dsh 0.3.0 → 0.4.0

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/backend/server.js CHANGED
@@ -53,6 +53,7 @@ import { setMediaFetcher } from './lib/sessparse.js';
53
53
  import { pidAlive } from './lib/proc.js';
54
54
  import { respondError, respondUnsupportedMethod, sendRedirect } from './lib/http.js';
55
55
  import { acquireSingleInstance, resolveDbPath } from './lib/lock.js';
56
+ import { seedHint } from './lib/seed_hint.js';
56
57
  import {
57
58
  Runner, reconcileUnits, setBoardFinishHook, setBoardSelfcheckHooks, setInstance,
58
59
  setLivenessProbe, setUnitHandlers, startUnitSelfcheck,
@@ -272,11 +273,9 @@ async function main() {
272
273
  const executedSteps = boot.executed.length + (again ? again.executed.length : 0);
273
274
  db = openDb(dbPath);
274
275
  purgeExpiredSessions(db);
275
- const hint = !boot.seeded ? ''
276
- : boot.seedPassword
277
- ? ` (首次启动已创建默认账号 admin,一次性初始口令:${boot.seedPassword}`
278
- + ',首次登录须修改;仅本次显示,请立即记录)'
279
- : ' (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)';
276
+ // 横幅文案:三档与 seedAdmin 的口令来源一一对应(见 lib/seed_hint.js;
277
+ // 2026-10-16 前空口令档误落「初始口令取自 TS_ADMIN_PASSWORD」文案)
278
+ const hint = seedHint(boot);
280
279
  // 文案只报「检查了几步」而不说「执行了几步」(M3 评审 D5):声明式迁移在**已迁移完**的库上
281
280
  // 逐步 no-op(实测同一份快照库起停两次,表行数与文件 sha256 完全不变),旧文案
282
281
  // 「本次执行迁移 N 步」读起来像「又迁移了一遍」,排障时会误导。
@@ -312,20 +311,28 @@ async function main() {
312
311
  setBoardSelfcheckHooks({ unitManaged: board.unitManaged, refreshExtRows: board.refreshExtRows });
313
312
  const runnerInstance = new Runner(true, { dbPath });
314
313
  setInstance(runnerInstance);
315
- // ---- M6.5 接线:单元执行体(`setUnitHandlers`)-----------------------------------
316
- // `t:` 与 `c:` 两路**接真**:`processTask`(轮次执行体:script_retest / 复压 / retest_bug /
317
- // fix / pipeline / 轮次循环)与 `processCard`(看板卡片起跑)。接线前这两路一律抛具名错误
318
- // `UNIT_HANDLER_MISSING`(「任务单元轮次执行体未移植(M6)」)⇒ 实例级判据
314
+ // ---- M6.5/M10 接线:单元执行体(`setUnitHandlers`)--------------------------------
315
+ // 三路**接真**:`t:` → `processTask`(轮次执行体:script_retest / 复压 / retest_bug /
316
+ // fix / pipeline / 轮次循环)、`c:` → `processCard`(看板卡片起跑)、
317
+ // `a:` → `board.deliverAnswerUnit`(答案送达;`runner.py:1087` → `board._deliver_answer_unit`)。
318
+ // 接线前三路一律抛具名错误 `UNIT_HANDLER_MISSING` ⇒ 实例级判据
319
319
  // `cases-http/m6_5_runner.json` 接线前是**红**的(rounds 缺行 + error 文案不同),接线后才绿。
320
320
  //
321
- // **只接两路**:`a:`(答案送达,`board._deliver_answer_unit`)仍留空缝 ⇒ 命中即抛
322
- // `UnitHandlerMissing`(响亮,不静默)。这是 M5-2 划归「作答/归档族」的口,属 M7。
321
+ // **`a:` 是 2026-10-16 补接的(M10)**:M6.5 只接了 `t:`/`c:` 并把 `a:` 注明「属 M7」,
322
+ // 但 M7/M7.5/M8/M9 都没接 ⇒ 平台侧作答(TS 会话窗 / 飞书入站;「立即送达」以外的自动路径)
323
+ // 一律「入队成功、永远送不出去」:`runPickedUnit` 的 a: except 分支是「放回等待重投」,
324
+ // 于是**热旋**——真机实测后端 50% CPU、日志 5.5KB/s 涨到 3.9MB(几十万行同一句
325
+ // 「答案送达体未移植(M5)」,而实现体 M5 就在 `board.js` 里,缺的只是这张注册表)。
326
+ // 用户可见现象:在 TS 里答了没反应、答案永远停在「排队中」。与 `routes/sessions.js`
327
+ // 误用 M3 占位桩同属「已实现但没接线」类缺陷(判据缺口也相同:函数级单测照不到接线)。
323
328
  //
324
329
  // 位置:`setInstance` 之后、`recover()` 之前 —— 执行体在闸后(`releaseBootGate`)才可能被
325
330
  // 调用,此处注册只保证「任何一条被拾取的单元都有执行体」,不引入新的启动序。
326
331
  setUnitHandlers({
327
332
  processTask: (taskId) => runnerInstance.processTask(taskId),
328
333
  processCard: (cardId) => runnerInstance.processCard(cardId),
334
+ // `options` 与同段其它 board 口同款:显式传本轮解析出的 `dbPath`(缺省库口径见 db.js)
335
+ deliverAnswerUnit: (cardId) => board.deliverAnswerUnit(cardId, { dbPath }),
329
336
  });
330
337
  await runnerInstance.recover();
331
338
  await board.recover({ dbPath }); // ③ 硬顺序:runner.recover → board.recover → 对账
package/board.py CHANGED
@@ -1557,6 +1557,11 @@ _runs_lock = threading.Lock()
1557
1557
  # 单卡片 prompt 长度上限(与 chat.MESSAGE_MAX 对齐)
1558
1558
  PROMPT_MAX = 20000
1559
1559
 
1560
+ # 平台任务提示词的段标(`build_task_prompt` 的首段)。除拼提示词外还用作**判据**:
1561
+ # 会话首问以它开头 ⇒ 这个会话是平台按某张卡片的内容拼提示词启动的(2026-10-16 起,
1562
+ # 这类卡的标题/描述也跟随 DSH,见 `_is_platform_task_session`)。
1563
+ TASK_PROMPT_TAG = "【任务描述】"
1564
+
1560
1565
 
1561
1566
  def build_task_prompt(project, card, extra=""):
1562
1567
  """全量任务提示词:任务描述(卡片标题并入描述)+ 工作目录 + 附加段。
@@ -1580,7 +1585,7 @@ def build_task_prompt(project, card, extra=""):
1580
1585
  desc = (card["description"] or "").strip()
1581
1586
  body = "\n\n".join(seg for seg in (title, desc) if seg) or "(无)"
1582
1587
  workspace = card_workspace(project, card)
1583
- p = (f"【任务描述】{body}\n"
1588
+ p = (f"{TASK_PROMPT_TAG}{body}\n"
1584
1589
  f"【工作目录】{workspace}")
1585
1590
  wt = (_card_opt(card, "worktree") or "").strip()
1586
1591
  if wt:
@@ -2304,6 +2309,17 @@ def compact_session(project, card, sid):
2304
2309
  外部会话的 agent 已在宿主 store 注册,`/session` resume 必撞
2305
2310
  `agent "…" is already registered` ⇒ 500(C 批 T1 实测)。
2306
2311
 
2312
+ **会话存活前置(2026-10-17 修卡 907「TS 侧 /compact 对已结束会话必失败」)**:
2313
+ 上面那条「不能先接管」只对**宿主里仍活着**的会话成立;对宿主里**根本没有活 agent**
2314
+ 的会话(dsh web 重启、插件重载、久未接续的旧卡——用户报障卡 907 就是 9 天前的
2315
+ 平台自建卡),驱动 `/compact` 必 404「会话已结束(宿主无活动 agent)」,上面两跳
2316
+ 再怎么补声明也救不回来。而 dsh GUI 自己的 `/compact` 对冷会话是**按需 resume 再执行
2317
+ 命令**(`dsh-api-session-controller.resolve` → `agents.resume`),故这里与投递腿
2318
+ `_deliver_now` 同款:先 `_ensure_card_session` 接回(判据/边界同它——中枢未对齐按
2319
+ 「未知」不动、sync 卡不收养、回滚阀 `TS_HOST_SESSION_REVIVE=0` 时不动,均为零请求),
2320
+ 再走下面的压缩两跳。接不回**不在此收口**:照旧走两跳,由驱动 404 文案兜底(与修复前
2321
+ 完全一致)。
2322
+
2307
2323
  sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
2308
2324
  RuntimeError(server 转 400)。
2309
2325
  """
@@ -2314,6 +2330,8 @@ def compact_session(project, card, sid):
2314
2330
  # 单族化:非 dsh 即退场族项目(migrate_p7b 改写后不再出现),
2315
2331
  # 给「改绑 dsh 插件」的明确提示(B0 防呆口径)
2316
2332
  raise RuntimeError(agents.RETIRED_MSG)
2333
+ # 会话存活前置:宿主已失去该会话 ⇒ 先按 resume 语义接回,再压缩(与投递同款)
2334
+ _ensure_card_session(project, card, sid)
2317
2335
  # 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
2318
2336
  # 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
2319
2337
  # 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
@@ -3450,6 +3468,9 @@ SYNC_BUSY_MTIME_S = 120
3450
3468
  # 用户改名后,同步节拍跟着改卡标题)、描述 = 主会话第一次用户提问**全文**。
3451
3469
  # 旧口径(2026-10-07~10-10:「首问首行进标题、其余行进描述」)已退场,存量卡由
3452
3470
  # `_sync_card_follow` 在首个同步拍回填(详见 board spec §48)。
3471
+ # 2026-10-16 扩面(用户诉求「卡面标题/正文与 DSH 保持一致」):同一口径从
3472
+ # `origin='sync'` 卡扩到**平台从看板启动过会话的卡**(判据 = 会话首问是平台拼的
3473
+ # 任务提示词,见 `_is_platform_task_session`);列语义与存量收口仍只服务 sync 卡。
3453
3474
  SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
3454
3475
  SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
3455
3476
 
@@ -3611,19 +3632,45 @@ def _auto_title_forms(item, sid):
3611
3632
  return forms
3612
3633
 
3613
3634
 
3635
+ def _in_first_prompt(text, item):
3636
+ """卡面字段是否仍是「平台按卡片内容拼进首问」的**原文形态**(判自动写入用)。
3637
+
3638
+ 平台起会话时把卡片标题/描述原样拼进首问(`build_task_prompt`:标题并入
3639
+ 【任务描述】段、与描述空行分隔)⇒ 卡面字段只要没被用户改过,就必然能在首问
3640
+ 原文里原样找到。用户后来改写过的文本通常不在(⇒ 不覆盖,尊重用户编辑)。
3641
+ 空/纯空白一律 False(空标题由 `_auto_title_forms` 那一档负责)。
3642
+ """
3643
+ t = (text or "").strip()
3644
+ return bool(t) and t in (item.get("first_prompt") or "")
3645
+
3646
+
3647
+ def _is_platform_task_session(item):
3648
+ """该会话是否「平台按某张卡片的内容拼提示词启动的」(判据 = 首问以段标开头)。
3649
+
3650
+ 卡片启动腿(`enter_doing` → `build_task_prompt`)拼的首问以 `TASK_PROMPT_TAG`
3651
+ 开头;用户在 dsh GUI 里直跑的会话首问是用户原文、续跑腿是「继续」,都不会命中。
3652
+ 用于把正向同步(DSH 标题/首问 → 卡面)从 sync 卡扩到**平台从看板启动过会话的卡**
3653
+ (2026-10-16 用户诉求:卡面标题/正文与 DSH 保持一致;见 spec/board §48)。
3654
+ """
3655
+ return (item.get("first_prompt") or "").lstrip().startswith(TASK_PROMPT_TAG)
3656
+
3657
+
3614
3658
  def _sync_card_follow(card, item, sid):
3615
- """存量 sync 卡按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
3659
+ """卡面按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
3616
3660
 
3617
3661
  只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
3618
- 只在卡仍由平台自动写入时动(卡标题 ∈ `_auto_title_forms`:空 / sid 短码 / 任一枚
3619
- 会话标题事件 / 旧口径的首问首行)——用户在卡面行内改过标题就整张卡不再自动覆盖
3620
- (描述也不动)。描述仅在为空、或仍是**旧口径自动写入**(首问除首行)时改成首问
3621
- 全文;用户写过的描述不覆盖。
3662
+ 只在卡仍由平台自动写入时动——卡标题 ∈ `_auto_title_forms`(空 / sid 短码 / 任一枚
3663
+ 会话标题事件 / 旧口径的首问首行)**或** 仍是平台拼进首问的原文(`_in_first_prompt`,
3664
+ 2026-10-16 增:服务「平台从看板启动过会话的卡」——它的标题是用户在卡里敲的原文,
3665
+ 不在任何标题事件里);用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动)。
3666
+ 描述仅在为空、仍是**旧口径自动写入**(首问除首行)、或仍是平台拼进首问的原文时
3667
+ 改成首问全文;用户写过的描述不覆盖。
3622
3668
  """
3623
3669
  if (card["session_id"] or sid) != sid:
3624
3670
  return {}
3625
3671
  cur_title = (card["title"] or "").strip()
3626
- if cur_title not in _auto_title_forms(item, sid):
3672
+ if (cur_title not in _auto_title_forms(item, sid)
3673
+ and not _in_first_prompt(cur_title, item)):
3627
3674
  return {}
3628
3675
  title, desc = _sync_card_fields(item, sid)
3629
3676
  out = {}
@@ -3631,7 +3678,8 @@ def _sync_card_follow(card, item, sid):
3631
3678
  out["title"] = title
3632
3679
  cur_desc = (card["description"] or "").strip()
3633
3680
  legacy_desc = _split_first_prompt(item.get("first_prompt") or "")[1]
3634
- if desc and cur_desc != desc and cur_desc in ("", legacy_desc):
3681
+ if (desc and cur_desc != desc
3682
+ and (cur_desc in ("", legacy_desc) or _in_first_prompt(cur_desc, item))):
3635
3683
  out["description"] = desc
3636
3684
  return out
3637
3685
 
@@ -3653,6 +3701,10 @@ def sync_sessions(project):
3653
3701
  todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
3654
3702
  会话的存量 sync 卡收口进回收站(每项目一轮);上述两类误建卡的存量也按同款
3655
3703
  口径收口(每项目一轮,见函数尾注释)。
3704
+ **平台从看板启动过会话的卡**(用户建卡 → 开始/回车启动,首问 = 平台拼的任务提示词,
3705
+ 见 `_is_platform_task_session`)同样按上面同一口径补齐卡面——但**列语义不动**:
3706
+ 归档/取消归档的列映射与各类存量收口仍只服务 `origin='sync'` 卡(用户自己的卡,
3707
+ 平台只同步标题/描述,不替用户搬列、不软删)。用户改过卡名 ⇒ 整卡不动(含描述)。
3656
3708
  sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
3657
3709
  """
3658
3710
  if not settings_of(project["id"]).get("sync_sessions", True):
@@ -3699,13 +3751,15 @@ def sync_sessions(project):
3699
3751
  db.update_board_card(cid, session_id=sid, sessions=json.dumps([sid]),
3700
3752
  column_key=col, origin="sync")
3701
3753
  created.append(cid)
3702
- elif c["origin"] == "sync":
3703
- # 首问补齐(2026-10-07):标题/描述仍是平台自动写入形态时按首问覆盖
3704
- # (覆盖「建卡时提问还没到」的竞态与旧口径的存量卡;用户改过名不动)
3754
+ elif c["origin"] == "sync" or _is_platform_task_session(it):
3755
+ # 卡面补齐(2026-10-07 起服务 sync 卡;2026-10-16 起扩到「平台从看板启动过
3756
+ # 会话的卡」,见 `_sync_card_follow`):标题/描述仍是平台自动写入形态时按
3757
+ # DSH 当前标题 + 首问全文覆盖(用户改过名整卡不动)
3705
3758
  fields = _sync_card_follow(c, it, sid)
3706
3759
  if fields:
3707
3760
  db.update_board_card(c["id"], **fields)
3708
- if c["column_key"] in ("doing", "review") and not _has_active_run(c["id"]):
3761
+ if (c["origin"] == "sync" and c["column_key"] in ("doing", "review")
3762
+ and not _has_active_run(c["id"])):
3709
3763
  # dsh 的 busy/空闲 列映射已移交调和器(事件驱动,异常不搬列);
3710
3764
  # 此处保留归档→done(archived 来源=list_sessions 的宿主归档集文件读,
3711
3765
  # 与调和器读驱动帧是两条独立通道:文件读不依赖驱动链路,重启/断连时
package/chat.py CHANGED
@@ -399,6 +399,39 @@ def cancel_queued(sid=None, card_id=None):
399
399
  return sum(1 for r in rows if cancel(r["id"]))
400
400
 
401
401
 
402
+ def cancel_queued_detail(sid=None, card_id=None, msg_id=None):
403
+ """取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
404
+
405
+ 与 `cancel_queued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
406
+ 前端要把消息原文放回输入框,而 `msgs_of_sid` 下发给前端的展示文本被截到 120 字——
407
+ 回填必须由服务端给**全量原文**,否则长消息被静默截断。
408
+
409
+ 逐行只在 `cancel()` 真命中(chat_msgs 的 queued→cancelled 原子守卫是仲裁点,R12)
410
+ 时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
411
+ 前端不会把仍在执行的消息错放回输入框。返回按入队顺序(msg_rows 的 created_at,id)。
412
+
413
+ `msg_id` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
414
+ 返回 `{"cancelled": n, "msgs": [{"id", "text"}]}`;空过滤返回空(不误伤全库)。
415
+ """
416
+ if not sid and not card_id and not msg_id:
417
+ return {"cancelled": 0, "msgs": []}
418
+ if msg_id:
419
+ row = waitq.msg_get(msg_id)
420
+ if row is None or row["state"] != STATE_QUEUED:
421
+ return {"cancelled": 0, "msgs": []}
422
+ rows = [row]
423
+ else:
424
+ rows = waitq.msg_rows(sid=sid, card_id=card_id, states=(STATE_QUEUED,))
425
+ msgs = []
426
+ for r in rows:
427
+ # 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
428
+ text = r["message"] if r["message"] is not None else ""
429
+ if not cancel(r["id"]):
430
+ continue
431
+ msgs.append({"id": r["id"], "text": str(text)})
432
+ return {"cancelled": len(msgs), "msgs": msgs}
433
+
434
+
402
435
  def msgs_of_sid(sid):
403
436
  """本会话消息的展示视图(排队/执行中 + 近期失败):{id,text,state,error,created_at}。
404
437
 
@@ -279,10 +279,117 @@ function panelEntry() {
279
279
  return '/touchstone/app';
280
280
  }
281
281
 
282
- // 全屏面板: 无顶栏(iframe 吃满全屏) + 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
282
+ // ── 窗口装饰区让位(2026-10-17, 卡 987 方案 A) ─────────────────────────────────
283
+ // 血案: 面板是全窗口浮层(position:fixed; inset:0), 而桌面版窗口的顶带(Windows 的最小化/
284
+ // 最大化/关闭、macOS 的红绿灯)由桌面壳画在网页之上 —— 浮层顶部那行按钮正好落在它底下,
285
+ // 在 dsh desktop 里整块被盖住、点不到(现象与取证见
286
+ // doc_ai/debug_runtime/202610/20261017_0600_零项目首屏无可点建项目入口… .md)。
287
+ // 修法: 让整个浮层从窗口顶边**下移**「壳实际绘制的窗口 chrome 高度」—— 顶带完整露给壳
288
+ // (标题文字/拖拽区/窗口按钮都还在原位), 面板内容从它下面开始。等价于 dsh 自己主面板与
289
+ // 弹窗的处理口径(layout 发布 --dsh-frame-top-clearance / --dsh-frame-chrome-top),
290
+ // 而且**不需要 SPA 侧改任何代码**: iframe 拿到的视口自己就变矮了。
291
+ // 高度来源(优先级从高到低; 全部读不到 ⇒ 0 ⇒ 独立浏览器形态, 行为与今天完全一致):
292
+ // ① dsh 桌面壳发布在 <html> 上的约定量(见 @deepseek-ai/dsh-client-ui-layout 的
293
+ // README「窗口 chrome 顶带」; 普通浏览器文档不发布这些值):
294
+ // --dsh-frame-chrome-top 壳当前**实际绘制**的顶带高(全屏时归 0)
295
+ // --dsh-frame-top-clearance 顶带之下的固定下沉量(Windows = 标题栏高, macOS = 48px)
296
+ // **取两者较大**: 全屏时 chrome-top 归零, 但 dsh 的 Frame 仍按 titlebar 高度让位,
297
+ // 取 max 才能和主面板同口径、不会在全屏时又把窗口按钮盖回去。
298
+ // ② WCO 原生接口 navigator.windowControlsOverlay(Chromium/Electron; 旧桌面壳只开了
299
+ // overlay 但没发布 ① 的量时兜底): getTitlebarAreaRect() 的 y + height = 顶带高。
300
+ // ③ CSS env(titlebar-area-height) 探针(同一能力的老式访问面, 只支持 env 的壳兜底)。
301
+ // 仍读不到(普通浏览器/不支持窗口浮层的壳) ⇒ 0 ⇒ 现状(独立形态本来就没有窗口装饰)。
302
+ const CHROME_MAX_PX = 200; // 合法上限: 再大必然是脏值(标题栏不可能 200px), 一口拒绝
303
+
304
+ /** 读 <html> 上某个 CSS 自定义属性的像素值。空串/无法解析/越界 ⇒ null(0px 是合法值)。 */
305
+ function readChromePx(name) {
306
+ try {
307
+ if (typeof document === 'undefined' || !document.documentElement) return null;
308
+ const cs = window.getComputedStyle ? window.getComputedStyle(document.documentElement) : null;
309
+ if (!cs) return null;
310
+ const raw = cs.getPropertyValue(name);
311
+ if (!raw) return null;
312
+ const n = parseFloat(raw);
313
+ if (!Number.isFinite(n) || n < 0 || n > CHROME_MAX_PX) return null;
314
+ return n;
315
+ } catch { return null; } // 读样式异常绝不能拖垮面板本体
316
+ }
317
+
318
+ /** WCO 原生接口量顶带高(标题栏区域 rect 的 y + height); 不可见/不可用 ⇒ null。 */
319
+ function wcoChromePx() {
320
+ try {
321
+ const nav = window.navigator;
322
+ const wco = nav && nav.windowControlsOverlay;
323
+ if (!wco || !wco.visible) return null;
324
+ const rect = wco.getTitlebarAreaRect ? wco.getTitlebarAreaRect() : null;
325
+ if (!rect) return null;
326
+ const n = (rect.y || 0) + rect.height;
327
+ if (!Number.isFinite(n) || n <= 0 || n > CHROME_MAX_PX) return null;
328
+ return n;
329
+ } catch { return null; }
330
+ }
331
+
332
+ /** env(titlebar-area-height) 探针: 挂一个 0 宽、高度取 env 的隐形探针量出来; 不支持 ⇒ null。
333
+ * 探针始终会被摘掉(成功/失败都摘), 不往宿主 DOM 里留垃圾。 */
334
+ function envChromePx() {
335
+ try {
336
+ if (typeof document === 'undefined' || !document.body
337
+ || typeof document.createElement !== 'function'
338
+ || typeof document.body.appendChild !== 'function') return null;
339
+ const probe = document.createElement('div');
340
+ probe.style.cssText = 'position:fixed;left:0;top:0;width:0;'
341
+ + 'height:env(titlebar-area-height,0px);visibility:hidden;pointer-events:none';
342
+ document.body.appendChild(probe);
343
+ let h = 0;
344
+ try {
345
+ h = probe.getBoundingClientRect ? probe.getBoundingClientRect().height : 0;
346
+ } finally {
347
+ if (typeof probe.remove === 'function') probe.remove();
348
+ else if (typeof document.body.removeChild === 'function') document.body.removeChild(probe);
349
+ }
350
+ if (!Number.isFinite(h) || h <= 0 || h > CHROME_MAX_PX) return null;
351
+ return h;
352
+ } catch { return null; }
353
+ }
354
+
355
+ /** 窗口 chrome 顶带高(px): 面板浮层从窗口顶边下移这么多。0 = 没有桌面窗口装饰。 */
356
+ function windowChromeTop() {
357
+ // ① dsh 桌面壳的约定量(两者取大, 口径见本节头注释)
358
+ const chrome = readChromePx('--dsh-frame-chrome-top');
359
+ const clearance = readChromePx('--dsh-frame-top-clearance');
360
+ if (chrome !== null || clearance !== null) {
361
+ return Math.max(chrome || 0, clearance || 0);
362
+ }
363
+ // ②③ 旧壳/其它壳兜底: WCO 原生接口 → env() 探针
364
+ return wcoChromePx() || envChromePx() || 0;
365
+ }
366
+
367
+ /** chrome 顶带高的响应式读取: 渲染期现读(首帧就带真值, 不闪一帧顶格),
368
+ * 壳换档(resize/进出全屏/窗口几何变化)时自增重渲染。 */
369
+ function useWindowChromeTop() {
370
+ const [, setRevision] = React.useState(0);
371
+ React.useEffect(() => {
372
+ const bump = () => setRevision((n) => n + 1);
373
+ window.addEventListener('resize', bump);
374
+ document.addEventListener('fullscreenchange', bump);
375
+ // WCO 几何变化不是 DOM 事件(壳侧全屏/标题栏变高/贴边), 只能由原生接口通知
376
+ const wco = window.navigator && window.navigator.windowControlsOverlay;
377
+ if (wco && typeof wco.addEventListener === 'function') wco.addEventListener('geometrychange', bump);
378
+ return () => {
379
+ window.removeEventListener('resize', bump);
380
+ document.removeEventListener('fullscreenchange', bump);
381
+ if (wco && typeof wco.removeEventListener === 'function') wco.removeEventListener('geometrychange', bump);
382
+ };
383
+ }, []);
384
+ return windowChromeTop();
385
+ }
386
+
387
+ // 全屏面板: 浮层从窗口 chrome 顶带之下开始(没有桌面壳时 = 0 = 吃满全屏) +
388
+ // 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
283
389
  // iframe 同源, /touchstone/app 经薄壳反代到 server.py 的插件版 SPA
284
390
  function Panel() {
285
391
  const isOpen = useOpen();
392
+ const chromeTop = useWindowChromeTop(); // 窗口装饰顶带高: Windows 标题栏/macOS 红绿灯
286
393
  // 保活(2026-10-07): 打开过就在 DOM 里常驻, 关闭只把容器 display:none —— iframe 一旦
287
394
  // 从 DOM 摘掉, 内嵌 SPA 整篇文档就没了, 再打开是全新加载(路由/弹窗/滚动/未提交输入全丢)。
288
395
  // mounted 由「打开过」与「当前打开」合成: 首次打开的那一次渲染就带上 iframe(不等 effect
@@ -389,7 +496,9 @@ function Panel() {
389
496
  const visible = !zoneOk || shown; // 自动隐藏不可用时(zoneOk=false)保持常显
390
497
  return h('div', {
391
498
  style: {
392
- position: 'fixed', inset: 0, zIndex: 9999,
499
+ // 顶边让出窗口装饰带(见 useWindowChromeTop): 桌面壳里 = 标题栏/红绿灯的高度,
500
+ // 普通浏览器里 = 0(与旧版 inset:0 完全一致)。其余三边照旧贴边。
501
+ position: 'fixed', top: chromeTop, left: 0, right: 0, bottom: 0, zIndex: 9999,
393
502
  background: 'var(--dsw-alias-bg-base, #161617)',
394
503
  display: isOpen ? 'block' : 'none', // 关闭 = 只隐藏: iframe 与内嵌 SPA 常驻(保活)
395
504
  },
@@ -480,9 +480,32 @@ function renderNodeRuntimeHint({ execPath, version, detail, logPath }) {
480
480
  * 为什么需要:「入口缺失」只是新开关失败的一种;更常见的是入口在、跑不起来
481
481
  * (宿主 Node 版本不认 `--disable-warning`、`dist-plugin` 缺失、被执行策略拦、脚本自身报错)。
482
482
  * 那时 `backendPort` 一直是 0,proxy 只会回通用 503 JSON —— 面板里显示一段 JSON 原文,
483
- * 用户既不知道发生了什么、也不知道去哪看。提示页给三件事:失败原因、日志路径、怎么回退。
483
+ * 用户既不知道发生了什么、也不知道去哪看。提示页给四件事:失败原因、**后端最后几行输出**、
484
+ * 日志路径、怎么回退。
485
+ *
486
+ * 2026-10-17 桌面端真机事故的教训:提示页原先只教用户自己 `tail` 日志文件(Windows 用户得先
487
+ * 找到库目录、开终端),而失败原因(「同一数据库已有运行中的实例:pid=…」)就一行字。现改为
488
+ * 把**内存里收到的最后几行**直接内联(见 apply 里的 `crashTail`),并对 `code=3` 给出单实例锁
489
+ * 的专项提示 —— 提示页要能自解释,而不是把排障转嫁给用户。
484
490
  */
485
- function renderBackendCrashHint({ entryPath, logPath, code, signal }) {
491
+ function renderBackendCrashHint({ entryPath, logPath, code, signal, tailLines }) {
492
+ const logPathText = escapeHtml(logPath || '<库目录>/plugin-backend.log');
493
+ const tail = (Array.isArray(tailLines) ? tailLines : [])
494
+ .map((line) => String(line)).filter((line) => line.trim()).slice(-10);
495
+ const tailBlock = tail.length
496
+ ? '<p>后端进程最后输出(内存缓冲;启动失败原因通常就在最后几行):</p>'
497
+ + '<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;'
498
+ + `padding:.8rem 1rem;overflow:auto">${escapeHtml(tail.join('\n'))}</pre>`
499
+ : '';
500
+ // code=3 在**两版后端**里都是「同库单实例锁没拿到」(server.py 与 backend/server.js 的
501
+ // exit 3 同义,见 spec §十九.7)。把排障动作写进页面,别让用户对着 generic 文案猜。
502
+ const lockHint = String(code) === '3'
503
+ ? '<p><b>code=3 = 同库单实例锁没拿到</b>:可能另有 Touchstone 后端占着同一个库,'
504
+ + '或锁文件/归属记录残留。先确认没有实例在跑(任务管理器里找带 '
505
+ + '<code>backend/server.js</code> / <code>server.py</code> 的 node/python 进程),'
506
+ + '再停用→启用本插件重试;仍失败再把库目录下的 '
507
+ + '<code>*.lock</code> / <code>*.pid</code> 移走后重试。</p>'
508
+ : '';
486
509
  return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
487
510
  + '<title>Touchstone 后端未启动</title></head>'
488
511
  + '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
@@ -490,10 +513,13 @@ function renderBackendCrashHint({ entryPath, logPath, code, signal }) {
490
513
  + '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:Node 后端进程退出</h2>'
491
514
  + `<p>入口 <code>${escapeHtml(entryPath)}</code> 起来了但没有报出监听端口就退出`
492
515
  + `(code=${escapeHtml(String(code))}${signal ? `, signal=${escapeHtml(String(signal))}` : ''})。</p>`
493
- + '<p>下一步:看后端日志里的最后几行(启动失败原因就在那里)——</p>'
516
+ + tailBlock
517
+ + lockHint
518
+ + '<p>下一步:看完整后端日志里的最后几行(启动失败原因就在那里)——</p>'
494
519
  + `<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;padding:.8rem 1rem;`
495
- + `overflow:auto">tail -n 40 ${escapeHtml(logPath || '<库目录>/plugin-backend.log')}</pre>`
496
- + '<p>常见原因:<code>dist-plugin</code> 构建产物缺失、被执行策略拦下、入口脚本自身报错。'
520
+ + `overflow:auto">tail -n 40 ${logPathText}</pre>`
521
+ + '<p>常见原因:<code>dist-plugin</code> 构建产物缺失、被执行策略拦下、入口脚本自身报错、'
522
+ + '同库单实例锁被占/残留(<code>code=3</code>)。'
497
523
  + '若日志里是 <code>node:sqlite</code> / <code>ERR_UNKNOWN_BUILTIN_MODULE</code> 一类,'
498
524
  + '那是宿主 Node 太老 —— 升级到 22.19+ 或改回 <code>backend: python</code>。'
499
525
  + '改完把本插件<b>停用再启用</b>即会重试。</p>'
@@ -809,8 +835,18 @@ export async function apply(ctx, config = {}) {
809
835
  try { myLog.write(line + '\n'); } catch { /* 流已坏:忽略,绝不打断读取 */ }
810
836
  };
811
837
  let sawListen = false; // 是否报出过监听端口(判「起来就崩」用,见 renderBackendCrashHint)
838
+ // 崩溃提示页要内联「最后几行输出」:从管道现收一份内存环形缓冲。为什么不读日志文件——
839
+ // 日志是 append 写流(异步落盘),退出的那一刻盘上可能还差最后几行;只有这里保存的是
840
+ // pumpLines **已经交付**的完整行(2026-10-17 桌面端真机事故:失败原因就一行,但用户拿不到)。
841
+ const CRASH_TAIL_LINES = 12;
842
+ const crashTail = [];
843
+ const keepLine = (line) => {
844
+ crashTail.push(line);
845
+ if (crashTail.length > CRASH_TAIL_LINES) crashTail.shift();
846
+ };
812
847
  pumpLines(myChild.stdout, (line) => {
813
848
  writeLog(line);
849
+ keepLine(line);
814
850
  const m = line.match(/^TOUCHSTONE_LISTEN (\d+)/);
815
851
  if (m) {
816
852
  sawListen = true;
@@ -820,6 +856,7 @@ export async function apply(ctx, config = {}) {
820
856
  });
821
857
  pumpLines(myChild.stderr, (line) => {
822
858
  writeLog(line);
859
+ keepLine(line);
823
860
  if (line.trim()) logger.warn(`touchstone[${backend === BACKEND_NODE ? 'node' : 'py'}]: ${line.trim()}`);
824
861
  });
825
862
  // 「起来就崩」不再只留一行 dsh 日志:Node 分支给面板一个可读提示页(Python 分支行为不动)。
@@ -827,6 +864,7 @@ export async function apply(ctx, config = {}) {
827
864
  if (sawListen || backend !== BACKEND_NODE) return; // 只在 Node 分支、且从未就绪时给提示页
828
865
  backendHint = renderBackendCrashHint({
829
866
  entryPath, logPath: backendLogPath(extraEnv), code, signal: sig,
867
+ tailLines: crashTail.slice(),
830
868
  });
831
869
  };
832
870
  myChild.on('exit', (code, sig) => {
@@ -834,6 +872,9 @@ export async function apply(ctx, config = {}) {
834
872
  logger.warn(`touchstone: 后端退出 code=${code} sig=${sig}`);
835
873
  crashed(code, sig);
836
874
  });
875
+ // `exit` 可能先于 stdio 最后几个 chunk 交付(Node 文档:exit 后仍可能收到 data),
876
+ // `close` 才是「stdio 全部读空」。补渲染一次,让提示页里的最后几行是完整的。
877
+ myChild.on('close', (code, sig) => crashed(code, sig));
837
878
  myChild.on('error', (err) => {
838
879
  writeLog(`[touchstone] 后端拉起失败: ${err.message}`);
839
880
  logger.warn(`touchstone: 后端拉起失败: ${err.message}`);
@@ -272,10 +272,117 @@ function panelEntry() {
272
272
  return '/touchstone/app';
273
273
  }
274
274
 
275
- // 全屏面板: 无顶栏(iframe 吃满全屏) + 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
275
+ // ── 窗口装饰区让位(2026-10-17, 卡 987 方案 A) ─────────────────────────────────
276
+ // 血案: 面板是全窗口浮层(position:fixed; inset:0), 而桌面版窗口的顶带(Windows 的最小化/
277
+ // 最大化/关闭、macOS 的红绿灯)由桌面壳画在网页之上 —— 浮层顶部那行按钮正好落在它底下,
278
+ // 在 dsh desktop 里整块被盖住、点不到(现象与取证见
279
+ // doc_ai/debug_runtime/202610/20261017_0600_零项目首屏无可点建项目入口… .md)。
280
+ // 修法: 让整个浮层从窗口顶边**下移**「壳实际绘制的窗口 chrome 高度」—— 顶带完整露给壳
281
+ // (标题文字/拖拽区/窗口按钮都还在原位), 面板内容从它下面开始。等价于 dsh 自己主面板与
282
+ // 弹窗的处理口径(layout 发布 --dsh-frame-top-clearance / --dsh-frame-chrome-top),
283
+ // 而且**不需要 SPA 侧改任何代码**: iframe 拿到的视口自己就变矮了。
284
+ // 高度来源(优先级从高到低; 全部读不到 ⇒ 0 ⇒ 独立浏览器形态, 行为与今天完全一致):
285
+ // ① dsh 桌面壳发布在 <html> 上的约定量(见 @deepseek-ai/dsh-client-ui-layout 的
286
+ // README「窗口 chrome 顶带」; 普通浏览器文档不发布这些值):
287
+ // --dsh-frame-chrome-top 壳当前**实际绘制**的顶带高(全屏时归 0)
288
+ // --dsh-frame-top-clearance 顶带之下的固定下沉量(Windows = 标题栏高, macOS = 48px)
289
+ // **取两者较大**: 全屏时 chrome-top 归零, 但 dsh 的 Frame 仍按 titlebar 高度让位,
290
+ // 取 max 才能和主面板同口径、不会在全屏时又把窗口按钮盖回去。
291
+ // ② WCO 原生接口 navigator.windowControlsOverlay(Chromium/Electron; 旧桌面壳只开了
292
+ // overlay 但没发布 ① 的量时兜底): getTitlebarAreaRect() 的 y + height = 顶带高。
293
+ // ③ CSS env(titlebar-area-height) 探针(同一能力的老式访问面, 只支持 env 的壳兜底)。
294
+ // 仍读不到(普通浏览器/不支持窗口浮层的壳) ⇒ 0 ⇒ 现状(独立形态本来就没有窗口装饰)。
295
+ const CHROME_MAX_PX = 200; // 合法上限: 再大必然是脏值(标题栏不可能 200px), 一口拒绝
296
+
297
+ /** 读 <html> 上某个 CSS 自定义属性的像素值。空串/无法解析/越界 ⇒ null(0px 是合法值)。 */
298
+ function readChromePx(name) {
299
+ try {
300
+ if (typeof document === 'undefined' || !document.documentElement) return null;
301
+ const cs = window.getComputedStyle ? window.getComputedStyle(document.documentElement) : null;
302
+ if (!cs) return null;
303
+ const raw = cs.getPropertyValue(name);
304
+ if (!raw) return null;
305
+ const n = parseFloat(raw);
306
+ if (!Number.isFinite(n) || n < 0 || n > CHROME_MAX_PX) return null;
307
+ return n;
308
+ } catch { return null; } // 读样式异常绝不能拖垮面板本体
309
+ }
310
+
311
+ /** WCO 原生接口量顶带高(标题栏区域 rect 的 y + height); 不可见/不可用 ⇒ null。 */
312
+ function wcoChromePx() {
313
+ try {
314
+ const nav = window.navigator;
315
+ const wco = nav && nav.windowControlsOverlay;
316
+ if (!wco || !wco.visible) return null;
317
+ const rect = wco.getTitlebarAreaRect ? wco.getTitlebarAreaRect() : null;
318
+ if (!rect) return null;
319
+ const n = (rect.y || 0) + rect.height;
320
+ if (!Number.isFinite(n) || n <= 0 || n > CHROME_MAX_PX) return null;
321
+ return n;
322
+ } catch { return null; }
323
+ }
324
+
325
+ /** env(titlebar-area-height) 探针: 挂一个 0 宽、高度取 env 的隐形探针量出来; 不支持 ⇒ null。
326
+ * 探针始终会被摘掉(成功/失败都摘), 不往宿主 DOM 里留垃圾。 */
327
+ function envChromePx() {
328
+ try {
329
+ if (typeof document === 'undefined' || !document.body
330
+ || typeof document.createElement !== 'function'
331
+ || typeof document.body.appendChild !== 'function') return null;
332
+ const probe = document.createElement('div');
333
+ probe.style.cssText = 'position:fixed;left:0;top:0;width:0;'
334
+ + 'height:env(titlebar-area-height,0px);visibility:hidden;pointer-events:none';
335
+ document.body.appendChild(probe);
336
+ let h = 0;
337
+ try {
338
+ h = probe.getBoundingClientRect ? probe.getBoundingClientRect().height : 0;
339
+ } finally {
340
+ if (typeof probe.remove === 'function') probe.remove();
341
+ else if (typeof document.body.removeChild === 'function') document.body.removeChild(probe);
342
+ }
343
+ if (!Number.isFinite(h) || h <= 0 || h > CHROME_MAX_PX) return null;
344
+ return h;
345
+ } catch { return null; }
346
+ }
347
+
348
+ /** 窗口 chrome 顶带高(px): 面板浮层从窗口顶边下移这么多。0 = 没有桌面窗口装饰。 */
349
+ function windowChromeTop() {
350
+ // ① dsh 桌面壳的约定量(两者取大, 口径见本节头注释)
351
+ const chrome = readChromePx('--dsh-frame-chrome-top');
352
+ const clearance = readChromePx('--dsh-frame-top-clearance');
353
+ if (chrome !== null || clearance !== null) {
354
+ return Math.max(chrome || 0, clearance || 0);
355
+ }
356
+ // ②③ 旧壳/其它壳兜底: WCO 原生接口 → env() 探针
357
+ return wcoChromePx() || envChromePx() || 0;
358
+ }
359
+
360
+ /** chrome 顶带高的响应式读取: 渲染期现读(首帧就带真值, 不闪一帧顶格),
361
+ * 壳换档(resize/进出全屏/窗口几何变化)时自增重渲染。 */
362
+ function useWindowChromeTop() {
363
+ const [, setRevision] = React.useState(0);
364
+ React.useEffect(() => {
365
+ const bump = () => setRevision((n) => n + 1);
366
+ window.addEventListener('resize', bump);
367
+ document.addEventListener('fullscreenchange', bump);
368
+ // WCO 几何变化不是 DOM 事件(壳侧全屏/标题栏变高/贴边), 只能由原生接口通知
369
+ const wco = window.navigator && window.navigator.windowControlsOverlay;
370
+ if (wco && typeof wco.addEventListener === 'function') wco.addEventListener('geometrychange', bump);
371
+ return () => {
372
+ window.removeEventListener('resize', bump);
373
+ document.removeEventListener('fullscreenchange', bump);
374
+ if (wco && typeof wco.removeEventListener === 'function') wco.removeEventListener('geometrychange', bump);
375
+ };
376
+ }, []);
377
+ return windowChromeTop();
378
+ }
379
+
380
+ // 全屏面板: 浮层从窗口 chrome 顶带之下开始(没有桌面壳时 = 0 = 吃满全屏) +
381
+ // 关闭按钮悬浮在左下角(位置与 dsh 侧栏入口一致)
276
382
  // iframe 同源, /touchstone/app 经薄壳反代到 server.py 的插件版 SPA
277
383
  function Panel() {
278
384
  const isOpen = useOpen();
385
+ const chromeTop = useWindowChromeTop(); // 窗口装饰顶带高: Windows 标题栏/macOS 红绿灯
279
386
  // 保活(2026-10-07): 打开过就在 DOM 里常驻, 关闭只把容器 display:none —— iframe 一旦
280
387
  // 从 DOM 摘掉, 内嵌 SPA 整篇文档就没了, 再打开是全新加载(路由/弹窗/滚动/未提交输入全丢)。
281
388
  // mounted 由「打开过」与「当前打开」合成: 首次打开的那一次渲染就带上 iframe(不等 effect
@@ -382,7 +489,9 @@ function Panel() {
382
489
  const visible = !zoneOk || shown; // 自动隐藏不可用时(zoneOk=false)保持常显
383
490
  return h('div', {
384
491
  style: {
385
- position: 'fixed', inset: 0, zIndex: 9999,
492
+ // 顶边让出窗口装饰带(见 useWindowChromeTop): 桌面壳里 = 标题栏/红绿灯的高度,
493
+ // 普通浏览器里 = 0(与旧版 inset:0 完全一致)。其余三边照旧贴边。
494
+ position: 'fixed', top: chromeTop, left: 0, right: 0, bottom: 0, zIndex: 9999,
386
495
  background: 'var(--dsw-alias-bg-base, #161617)',
387
496
  display: isOpen ? 'block' : 'none', // 关闭 = 只隐藏: iframe 与内嵌 SPA 常驻(保活)
388
497
  },