@gavinc-cn/touchstone-dsh 0.1.0 → 0.2.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.
Files changed (58) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +22 -1
  3. package/README.zh.md +12 -1
  4. package/agents.py +0 -0
  5. package/auth.py +0 -0
  6. package/board.py +1119 -93
  7. package/builtin_assets.py +0 -0
  8. package/builtin_prompts/free_style/flow.md +0 -0
  9. package/builtin_prompts/free_style/load_test.md +0 -0
  10. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  11. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  12. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  13. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  14. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  15. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  16. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  17. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  18. package/chat.py +590 -17
  19. package/db.py +63 -12
  20. package/dsh-plugin/cordis.patch.yml +7 -3
  21. package/dsh-plugin/install.sh +8 -5
  22. package/dsh-plugin/lib/agent-driver.js +895 -126
  23. package/dsh-plugin/lib/client.bundle.js +94 -9
  24. package/dsh-plugin/lib/index.js +399 -20
  25. package/dsh-plugin/scripts/build.mjs +0 -0
  26. package/dsh-plugin/scripts/prepack-check.mjs +0 -0
  27. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  28. package/dsh-plugin/src/client.js +94 -9
  29. package/dshdriver.py +74 -5
  30. package/dshevents.py +100 -6
  31. package/export_cases.py +0 -0
  32. package/extensions/dsh-autocommit/asset.json +0 -0
  33. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  34. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  35. package/feishu.py +1226 -42
  36. package/feishu_conv.py +920 -0
  37. package/lib.py +0 -0
  38. package/lifecycle.py +0 -0
  39. package/loadcase.py +0 -0
  40. package/loadgen.py +0 -0
  41. package/localbus.py +0 -0
  42. package/migrate_p7b.py +0 -0
  43. package/package.json +8 -3
  44. package/platcompat.py +0 -0
  45. package/prompts.py +0 -0
  46. package/rag.py +0 -0
  47. package/requirements.txt +2 -1
  48. package/runner.py +11 -0
  49. package/server.py +264 -30
  50. package/sessparse.py +244 -44
  51. package/touchstone.py +8 -4
  52. package/waitq.py +23 -0
  53. package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
  54. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
  55. package/webui/dist-plugin/index.html +2 -2
  56. package/worktree.py +100 -0
  57. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  58. package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/sessparse.py CHANGED
@@ -19,6 +19,11 @@
19
19
  tool_result {call_id, name, text, is_error, truncated}
20
20
  (dsh 事件流没有 usage 汇总事件与「本轮失败」独立通道,故不产出 usage / error 条目;
21
21
  响应里的 totals 字段保留为 0,维持 API 形状稳定。)
22
+
23
+ 上下文占用(会话窗底栏 ctx 圈,2026-10-10 修「恒为 0」):dsh 会话日志里有两个
24
+ 可用槽位——`request/context` 事件带**上下文窗口**(适配器声明,见 _parse_dsh)、
25
+ `assistant/message` 带本次调用的 **TokenUsage**;`load()` 在有窗口时附带
26
+ `ctx={"used","max"}`(分子口径=prompt 侧 token 数,见 _dsh_prompt_tokens)。
22
27
  """
23
28
 
24
29
  import base64
@@ -26,6 +31,7 @@ import glob
26
31
  import json
27
32
  import os
28
33
  import re
34
+ import threading
29
35
 
30
36
  import zstandard
31
37
 
@@ -70,13 +76,50 @@ def _obj(raw):
70
76
  return d if isinstance(d, dict) else None
71
77
 
72
78
 
79
+ def _nonneg_int(v):
80
+ """JSON 值 → 非负整数,其他(bool / 字符串 / 负数 / None)一律 None。
81
+
82
+ token 计数与窗口大小都必须是干净的整数:`True` 在 Python 里是 int 子类,
83
+ 直接参与算术会算出垃圾值。
84
+ """
85
+ return v if isinstance(v, int) and not isinstance(v, bool) and v >= 0 else None
86
+
87
+
88
+ def _dsh_prompt_tokens(usage):
89
+ """dsh `TokenUsage` → 本次请求的 **prompt 侧 token 数**(上下文占用分子)。
90
+
91
+ 口径照抄 dsh `dsh-token-meter` 的 `pressureFrom`:
92
+ `inputTokens + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)`=「这一次请求
93
+ 送进模型的上下文有多大」。**不能**用 totalTokens:它含本次回复的输出 token,
94
+ 环会随回复长度越画越大(且与上下文窗口不成比例)。inputTokens 缺失(老格式)
95
+ 退回 `totalTokens - outputTokens`;再取不到返回 None(调用方保留上一槽位值)。
96
+ """
97
+ if not isinstance(usage, dict):
98
+ return None
99
+ inp = _nonneg_int(usage.get("inputTokens"))
100
+ if inp is not None:
101
+ return inp + (_nonneg_int(usage.get("cacheReadTokens")) or 0) \
102
+ + (_nonneg_int(usage.get("cacheWriteTokens")) or 0)
103
+ total = _nonneg_int(usage.get("totalTokens"))
104
+ out = _nonneg_int(usage.get("outputTokens"))
105
+ if total is not None and out is not None and total >= out:
106
+ return total - out
107
+ return None
108
+
109
+
73
110
  class _Entries:
74
111
  """entry 收集器:自动分配 seq 并累计 totals。"""
75
112
 
76
113
  def __init__(self):
77
114
  self.entries = []
78
- # token/耗时汇总(API 契约字段):dsh 事件流无 usage 事件,恒为 0
115
+ # token/耗时汇总(API 契约字段):dsh 事件流无 usage 汇总事件,恒为 0
79
116
  self.totals = {"input": 0, "output": 0, "cache_read": 0, "duration_ms": 0}
117
+ # 上下文占用两槽位(会话窗底栏 ctx 圈,2026-10-10 修「恒为 0」):两个**独立
118
+ # last-wins** 槽位,口径与 dsh token-meter 的 `contextPressure` 投影一致:
119
+ # used = 最新一次 usage 的 prompt 侧 token 数(pressureTokens)
120
+ # max = 最新 `request/context` 事件的 contextWindow(适配器声明的路由窗口)
121
+ # None = 该槽位尚未出现过(老会话 / 适配器未声明窗口 / 还没有一次模型调用)。
122
+ self.ctx = {"used": None, "max": None}
80
123
  # dsh 回退锚点(仅 _parse_dsh 填充):[(mid, ese),...] —— 真实用户提问的
81
124
  # 合成 mid 与**会话事件 seq**;dsh 没有原地 undo,回退=按该 seq fork 新会话
82
125
  self.dsh_anchors = []
@@ -88,10 +131,30 @@ class _Entries:
88
131
 
89
132
  # ---------------------------------------------------------------- dsh 解析
90
133
 
91
- # dsh 会话文件为多帧 zstd 拼接(每帧一批 JSONL 事件行),用 zstandard 的
92
- # stream_reader(read_across_frames=True) 跨帧解压(项目唯一第三方依赖,见
93
- # requirements.txt)
94
- _DSH_DEC = zstandard.ZstdDecompressor()
134
+ # dsh 会话文件为多帧 zstd 拼接(每帧一批 JSONL 事件行),逐帧 `decompressobj` 解压
135
+ # (项目唯一第三方依赖,见 requirements.txt)。
136
+ #
137
+ # **解压器必须按线程隔离**(2026-10-09 定因的 SIGSEGV 实障):zstandard 的
138
+ # `ZstdDecompressor` **实例并发调用 `decompressobj()` 不是线程安全的**——原先这里
139
+ # 是模块级共享实例,而站点是多线程的(HTTP 请求线程池 + board 30s 调度线程 +
140
+ # 会话窗读口),并发解压直接 **SIGSEGV 打崩整个后端**(面板 502,薄壳不自愈)。
141
+ # 实测对照(8 线程 × 25s 反复解压真实会话文件):共享实例 **core dumped**;
142
+ # 每线程独立实例同负载 765 次解压**零崩溃**。崩溃现场见
143
+ # `doc_ai/bug_report/20261009_0600_插件后端两次SIGSEGV…`(faulthandler 栈落在
144
+ # `server.py:_api_get_board` 的 `session_title` 调用行,扩展模块清单只有
145
+ # `zstandard.backend_c`)。
146
+ _DEC_TLS = threading.local()
147
+
148
+
149
+ def _dec():
150
+ """取**本线程**的 zstd 解压器(无则新建并缓存,随线程回收)。
151
+
152
+ 不要改回模块级共享实例——那正是 SIGSEGV 的根因(见上方注释)。
153
+ """
154
+ d = getattr(_DEC_TLS, "d", None)
155
+ if d is None:
156
+ d = _DEC_TLS.d = zstandard.ZstdDecompressor()
157
+ return d
95
158
 
96
159
 
97
160
  def _dsh_decompressed(path):
@@ -115,7 +178,7 @@ def _dsh_decompressed(path):
115
178
  chunks = []
116
179
  rest = buf
117
180
  while rest:
118
- obj = _DSH_DEC.decompressobj()
181
+ obj = _dec().decompressobj()
119
182
  try:
120
183
  chunks.append(obj.decompress(rest))
121
184
  except zstandard.ZstdError:
@@ -139,7 +202,8 @@ def _dsh_session_file(sid):
139
202
  """按会话 id 跨工作区 bucket 定位 zstd 会话文件(返回路径或 None)。
140
203
 
141
204
  id 形态两代并存(本机实测 2026-10-02):`session-<uuid>`(dsh web/headless 新建)
142
- 与裸 `<uuid>`(早期/桌面端写入,如 64655aea-… 的会话目录名即其 id)。
205
+ 与裸 `<uuid>`(**绝大多数是子代理会话**,少数是老主会话,如 64655aea-…;
206
+ 2026-10-07 实测 82 个裸 uuid 目录里 73 个 origin=subagent、9 个主会话)。
143
207
  文件名也带格式版本:`session.v4.jsonl.zstd`(62 个)/ `session.v3.jsonl.zstd`(1 个)
144
208
  / 无版本的 `session.jsonl.zstd`(9 个存量)——**只认无版本名会让 v3/v4 会话全部
145
209
  found=false**(真机踩中),故按 `session*.jsonl.zstd` 通配并优先取版本号最大者。
@@ -160,6 +224,93 @@ def _dsh_format_version(path):
160
224
  return int(m.group(1)) if m else 0
161
225
 
162
226
 
227
+ # dsh 会话**头行**缓存:{path: (stamp, header)}。头行承载 origin/delegationDepth/cwd,
228
+ # 是判「子代理会话」的唯一可靠依据(目录名不是判据,见 is_subagent)。
229
+ _DSH_HEADER_CACHE = {}
230
+ _DSH_HEADER_FRAMES = 4 # 头行最多向前找几帧(真机 304 个会话全在第 1 帧第 1 行)
231
+
232
+
233
+ def _dsh_header(path):
234
+ """读 dsh 会话**头行**(`{"type":"session","id":…,"origin":…,"delegationDepth":…}`),
235
+ 读不到/无头行返回 {}(老样本与异常一律降级为空)。
236
+
237
+ 只解前 `_DSH_HEADER_FRAMES` 帧即停(头行就是第 1 帧第 1 行,实测 278 字节)——
238
+ 与 `_dsh_decompressed` 的全量解压相比,判一个会话是不是子代理不该付整段解压的
239
+ 代价(枚举 300+ 会话时差一个量级)。按 (mtime_ns, size) 缓存:会话只追加,
240
+ stamp 变即失效重读。
241
+ """
242
+ try:
243
+ st = os.stat(path)
244
+ stamp = (st.st_mtime_ns, st.st_size)
245
+ except OSError:
246
+ return {}
247
+ hit = _DSH_HEADER_CACHE.get(path)
248
+ if hit and hit[0] == stamp:
249
+ return hit[1]
250
+ header = {}
251
+ try:
252
+ with open(path, "rb") as f:
253
+ rest = f.read()
254
+ except OSError:
255
+ return {}
256
+ for _ in range(_DSH_HEADER_FRAMES):
257
+ if not rest:
258
+ break
259
+ obj = _dec().decompressobj()
260
+ try:
261
+ text = obj.decompress(rest).decode("utf-8", "replace")
262
+ except zstandard.ZstdError:
263
+ break # 坏帧:头行读不到就按「未知」(=主会话)
264
+ rest = obj.unused_data # 余量即下一帧(逐帧解压,同 _dsh_decompressed)
265
+ for line in text.split("\n"):
266
+ line = line.strip()
267
+ if not line:
268
+ continue
269
+ d = _obj(line)
270
+ if d is not None and d.get("type") == "session":
271
+ header = d
272
+ break
273
+ if header:
274
+ break
275
+ _DSH_HEADER_CACHE[path] = (stamp, header)
276
+ return header
277
+
278
+
279
+ def _header_subagent(header):
280
+ """头行 → 是否子代理会话:`origin=subagent` 为主判据,`delegationDepth>0` 兜底。
281
+
282
+ 2026-10-07 全机实测 304 个会话目录:73 个 `origin=subagent`(且 delegationDepth
283
+ 全为 1)、231 个主会话(origin 字段缺失、delegationDepth 全为 0),两集合严格互斥。
284
+ 两个判据都留,是防 dsh 后续只写其一。
285
+ """
286
+ if str(header.get("origin") or "") == "subagent":
287
+ return True
288
+ try:
289
+ return int(header.get("delegationDepth") or 0) > 0
290
+ except (TypeError, ValueError):
291
+ return False
292
+
293
+
294
+ def is_subagent(family, sid):
295
+ """该会话是否 dsh **子代理会话**(主会话派生的调研/审计子代理,非用户会话)。
296
+
297
+ 子代理会话与主会话同 bucket、同格式,只能靠头行区分(见 `_header_subagent`;
298
+ **目录名不是判据**——裸 uuid 目录 82 个里 73 个是子代理,另 9 个是老主会话)。
299
+ 子代理是主会话的实现细节:不建看板卡、不进「绑定已有会话」下拉、不按「外部会话
300
+ 在跑」占项目运行位(看板 sync 投影 / server 绑定下拉 / board 占用三处共用本读口)。
301
+
302
+ 族不在白名单 / 存储不存在 / 头行缺失一律 False(未知按主会话处理——占用判定
303
+ 宁多等不可误放行,与 `dshevents` 的「断连=未知」同向)。
304
+ """
305
+ if family not in FAMILIES:
306
+ return False
307
+ try:
308
+ path = _dsh_session_file(sid)
309
+ except Exception:
310
+ return False
311
+ return _header_subagent(_dsh_header(path)) if path else False
312
+
313
+
163
314
  def dsh_bucket(cwd):
164
315
  """工作区路径 → dsh 会话 bucket 目录。
165
316
 
@@ -193,8 +344,9 @@ def dsh_latest_session(cwd):
193
344
  return best
194
345
 
195
346
 
196
- # path -> (stamp, title, first_prompt):标题与首问同源同缓存——两者都要全量解压
197
- # 会话文件,一次扫描一起取(30s 同步节拍里同一文件只解压一次)
347
+ # path -> (stamp, title, first_prompt, titles):当前标题、首问与全部标题事件同源
348
+ # 同缓存——三者都要全量解压会话文件,一次扫描一起取(30s 同步节拍里同一文件只解压
349
+ # 一次)
198
350
  _DSH_TITLE_CACHE = {}
199
351
 
200
352
 
@@ -209,15 +361,31 @@ def _line_at(text, idx):
209
361
  return text[start:] if end < 0 else text[start:end]
210
362
 
211
363
 
212
- def _dsh_scan_title(text):
213
- """首个 session/title 事件的标题(无该事件 / 解析失败返回 '')。"""
214
- idx = text.find('"session/title"')
215
- if idx < 0:
216
- return ""
217
- d = _obj(_line_at(text, idx))
218
- if d is None or d.get("type") != "session/title":
219
- return ""
220
- return str((d.get("data") or {}).get("title") or "")
364
+ def _dsh_scan_titles(text):
365
+ """全部 `session/title` 事件的标题,按文件顺序(无事件返回 [])。
366
+
367
+ **末枚即 DSH 当前标题**(dsh 对同一会话会写多枚:先 fallback 截断句、再由 LLM
368
+ 标题覆盖、用户显式改名再追加;GUI 侧栏显示 last-wins 的那一枚)。平台侧一律
369
+ 取同一口径(会话窗 / 任务标题 / 看板 sync 卡标题);`list_sessions` 另把整串
370
+ 下发给看板,用于判定「卡面标题是否仍是平台自动写入的形态」。
371
+
372
+ 逐次 find + **整行 JSON 校验**(同 `_dsh_scan_prompt`):非事件行里出现同名
373
+ 字符串(坏行 / 别处文本)一律跳过;空标题事件跳过(dsh 不会写空标题,真出现
374
+ 时按「不表态」处理,避免把当前标题抹成空串)。
375
+ """
376
+ out = []
377
+ pos = 0
378
+ while True:
379
+ idx = text.find('"session/title"', pos)
380
+ if idx < 0:
381
+ return out
382
+ pos = idx + 1
383
+ d = _obj(_line_at(text, idx))
384
+ if d is None or d.get("type") != "session/title":
385
+ continue
386
+ title = str((d.get("data") or {}).get("title") or "")
387
+ if title:
388
+ out.append(title)
221
389
 
222
390
 
223
391
  def _dsh_scan_prompt(text):
@@ -225,7 +393,7 @@ def _dsh_scan_prompt(text):
225
393
 
226
394
  判定与会话窗口的 user entry 同口径:`user/message` 且 `source.kind == "user"`
227
395
  (系统注入的 plugin / agent-instructions 消息被过滤);文本块按换行拼接、
228
- **原样保留内部换行**——调用方(看板同步卡)再按「首行进标题、其余进描述」切。
396
+ **原样保留内部换行**——调用方(看板同步卡)直接作为卡描述(首问全文)。
229
397
  只有图片没有文本的提问返回 `[图片]`(沿用会话窗的图片占位口径)。
230
398
 
231
399
  「第一条」按**文件顺序**取(会话窗的展示顺序;真机 20 个会话核对:文件序首条
@@ -254,31 +422,33 @@ def _dsh_scan_prompt(text):
254
422
 
255
423
 
256
424
  def _dsh_meta(path):
257
- """一次解压取出 (会话标题, 主会话首问原文),按文件 stamp 缓存。
258
-
259
- - 会话标题 = **首个** `session/title` 事件的 title。dsh 对同一会话会写多枚标题
260
- 事件(先 fallback 截断句、再由 LLM 标题覆盖、用户显式改名再追加),本平台
261
- 此处沿用既有「首个」口径(会话窗 / 任务标题同源),与 dsh GUI 的 last-wins
262
- 当前标题不一致——差异与取舍见 doc_ai/spec/board/看板增强.md「sync 会话归类」。
263
- - 首问 = 见 `_dsh_scan_prompt`,是看板 sync 卡标题/描述的数据源。
425
+ """一次解压取出 (当前标题, 主会话首问原文, 全部标题事件),按文件 stamp 缓存。
426
+
427
+ - 当前标题 = `titles` 的**末枚**(`session/title` 事件 last-wins)= dsh GUI 侧栏
428
+ 显示的标题(口径说明见 `_dsh_scan_titles`);
429
+ - 首问 = 见 `_dsh_scan_prompt`,是看板 sync 卡描述(首问全文)的数据源;
430
+ - 全部标题事件(tuple)= 该会话历史上出现过的每个标题,看板 sync 卡据此判定
431
+ 「卡面标题是否仍是平台自动写入的形态」(建卡时只有兜底标题、LLM 标题后到的
432
+ 窗口里,旧的兜底标题只存在于事件历史中)。
264
433
  """
265
434
  try:
266
435
  st = os.stat(path)
267
436
  stamp = (st.st_mtime_ns, st.st_size)
268
437
  except OSError:
269
- return "", ""
438
+ return "", "", ()
270
439
  hit = _DSH_TITLE_CACHE.get(path)
271
440
  if hit and hit[0] == stamp:
272
- return hit[1], hit[2]
441
+ return hit[1], hit[2], hit[3]
273
442
  text = _dsh_decompressed(path)
274
- title = _dsh_scan_title(text)
443
+ titles = tuple(_dsh_scan_titles(text))
444
+ title = titles[-1] if titles else ""
275
445
  prompt = _dsh_scan_prompt(text)
276
- _DSH_TITLE_CACHE[path] = (stamp, title, prompt)
277
- return title, prompt
446
+ _DSH_TITLE_CACHE[path] = (stamp, title, prompt, titles)
447
+ return title, prompt, titles
278
448
 
279
449
 
280
450
  def dsh_title(sid):
281
- """读取 dsh 会话标题(首个 session/title 事件),无/不可读返回 ''。"""
451
+ """读取 dsh 会话当前标题(最后一枚 session/title 事件),无/不可读返回 ''。"""
282
452
  path = _dsh_session_file(sid)
283
453
  return _dsh_meta(path)[0] if path else ""
284
454
 
@@ -286,7 +456,7 @@ def dsh_title(sid):
286
456
  def dsh_first_prompt(sid):
287
457
  """读取主会话第一条真实用户提问的原文(可多行),无/不可读返回 ''。
288
458
 
289
- 看板 sync 卡的标题/描述数据源(见 board.sync_sessions):会话还没收到提问时
459
+ 看板 sync 卡的描述数据源(见 board.sync_sessions):会话还没收到提问时
290
460
  返回 '',调用方按会话标题 / sid 短码兜底,首问落盘后由同步节拍补齐。
291
461
  """
292
462
  path = _dsh_session_file(sid)
@@ -344,6 +514,18 @@ def _parse_dsh(path):
344
514
  col.add("think", tm, text=b.get("text", ""))
345
515
  elif b.get("type") == "text":
346
516
  col.add("assistant", tm, text=b.get("text", ""))
517
+ # 上下文占用分子(会话窗 ctx 圈):每次模型调用结算的 prompt 侧 token 数,
518
+ # last-wins(无 usage 的中断消息不参与,槽位保留上一次可用的值)
519
+ used = _dsh_prompt_tokens(data.get("usage"))
520
+ if used is not None:
521
+ col.ctx["used"] = used
522
+ elif t == "request/context":
523
+ # 上下文占用分母(会话窗 ctx 圈):宿主为该路由解析出的上下文窗口
524
+ # (适配器声明才有;中途 /model 换模型会再落一条,故 last-wins)。
525
+ # 0/缺失一律忽略——窗口必须为正才有意义(宿主 schema 也是 positive)。
526
+ win = _nonneg_int(data.get("contextWindow"))
527
+ if win:
528
+ col.ctx["max"] = win
347
529
  elif t == "tool/call":
348
530
  cid = data.get("callId", "")
349
531
  tool_names[cid] = data.get("name", "")
@@ -460,6 +642,8 @@ def load(family, sid, agent, after=0):
460
642
  或 {"found": False, "reason": "missing" | "unsupported"}。
461
643
  entries 为 seq >= after 的增量;total 为全量条数(变小时客户端应重置重拉)。
462
644
  dsh 为单线会话(固定 main,无子 agent)。
645
+ 另在**分母(上下文窗口)已知**时附带 `ctx`(会话窗底栏占用圈的数据源,
646
+ 见 _dsh_prompt_tokens / `request/context`);窗口未知时**不带**该键。
463
647
  """
464
648
  if family not in FAMILIES:
465
649
  return {"found": False, "reason": "unsupported"}
@@ -476,14 +660,21 @@ def load(family, sid, agent, after=0):
476
660
  cache = _CACHE.get(key)
477
661
  if cache is None or cache["stamp"] != stamp:
478
662
  col = _parse_dsh(zpath)
479
- cache = {"stamp": stamp, "entries": col.entries, "totals": col.totals}
663
+ cache = {"stamp": stamp, "entries": col.entries, "totals": col.totals,
664
+ "ctx": col.ctx}
480
665
  _CACHE[key] = cache
481
666
  except (OSError, ValueError):
482
667
  return {"found": False, "reason": "missing"}
483
668
  entries = cache["entries"]
484
669
  after = max(0, int(after or 0))
485
- return {"found": True, "agents": agents, "agent": agent,
486
- "entries": entries[after:], "total": len(entries), "totals": cache["totals"]}
670
+ out = {"found": True, "agents": agents, "agent": agent,
671
+ "entries": entries[after:], "total": len(entries), "totals": cache["totals"]}
672
+ # 上下文占用(会话窗底栏 ctx 圈,2026-10-10):只在**分母已知**时下发——前端按
673
+ # `+ctx.max > 0` 决定画不画进度弧,窗口未知时给了也只能画成 0%(本次修的旧形态)。
674
+ # 分子缺省 0:窗口已知但还没有一次模型调用=空上下文,0% 是真实读数。
675
+ if cache["ctx"]["max"]:
676
+ out["ctx"] = {"used": cache["ctx"]["used"] or 0, "max": cache["ctx"]["max"]}
677
+ return out
487
678
 
488
679
 
489
680
  def resolve_media(family, sid, agent, media_id):
@@ -564,7 +755,8 @@ def _dsh_archived_ids():
564
755
  def session_title(family, sid):
565
756
  """统一会话标题读取(异常静默返回 '',标题只是展示增强,不打断主流程)。
566
757
 
567
- dsh 标题取会话 `session/title` 事件;族不在白名单返回 ''。
758
+ dsh 标题取会话**当前**标题(末枚 `session/title` 事件,与 dsh GUI 侧栏一致);
759
+ 族不在白名单返回 ''。
568
760
  """
569
761
  if family not in FAMILIES:
570
762
  return ""
@@ -580,6 +772,10 @@ def _list_dsh(cwd):
580
772
 
581
773
  目录名两代并存:`session-<uuid>` 与裸 `<uuid>`;文件名带格式版本
582
774
  (session.v4.jsonl.zstd 等),故目录匹配与文件匹配都用通配(见 _dsh_session_file)。
775
+ **子代理会话不进列表**(2026-10-07 实障修复):它由主会话派生,被当成独立会话
776
+ 会各自建卡(实测 72 张、70 张永留「待审核」)并在跑时占项目运行位,判据取会话
777
+ 头行的 origin/delegationDepth(见 `is_subagent`)。裸 uuid 目录**不代表**是子代理:
778
+ 实测 82 个里 73 个是子代理、9 个是老主会话。
583
779
  归档只影响归档标记:会话目录仍在 bucket 里,列表照常枚举到它(平台据此把
584
780
  归档会话建成落在「已完成」的 sync 卡 / 在绑定下拉里标 [已归档])。
585
781
  """
@@ -594,20 +790,24 @@ def _list_dsh(cwd):
594
790
  except OSError:
595
791
  continue
596
792
  sid = os.path.basename(sdir)
597
- # 一次解压同时取标题与首问(同 stamp 缓存):看板 sync 卡建卡/补齐标题都要
598
- # 首问,逐会话各调一次读口会重复解压同一个文件(见 _dsh_meta)
599
- title, first_prompt = _dsh_meta(zfile)
600
- out.append({"sid": sid, "title": title, "first_prompt": first_prompt,
793
+ if _header_subagent(_dsh_header(zfile)):
794
+ continue # 子代理会话:先判头行再解全量(省一次整段解压)
795
+ # 一次解压同时取当前标题、首问与全部标题事件(同 stamp 缓存):看板 sync 卡
796
+ # 建卡/标题同步都要后两者,逐会话各调一次读口会重复解压同一个文件(见 _dsh_meta)
797
+ title, first_prompt, titles = _dsh_meta(zfile)
798
+ out.append({"sid": sid, "title": title, "titles": list(titles),
799
+ "first_prompt": first_prompt,
601
800
  "mtime": mtime, "archived": sid in archived})
602
801
  return out
603
802
 
604
803
 
605
804
  def list_sessions(family, cwd):
606
- """按工作区枚举 dsh 会话 [{sid, title, first_prompt, mtime秒, archived}],
805
+ """按工作区枚举 dsh 会话 [{sid, title, titles, first_prompt, mtime秒, archived}],
607
806
  mtime 降序、上限 LIST_SESSIONS_LIMIT。
608
807
 
609
- `title` = 首个会话标题事件,`first_prompt` = 主会话第一条真实用户提问原文
610
- (看板 sync 卡取它做「首行=标题、其余=描述」)。
808
+ `title` = **当前**会话标题(末枚 session/title 事件,与 dsh GUI 一致)、
809
+ `titles` = 全部标题事件、`first_prompt` = 主会话第一条真实用户提问原文
810
+ (看板 sync 卡取它做描述、取 title 做卡标题)。
611
811
 
612
812
  供看板「绑定已有会话」下拉与「自动同步」共用;`archived` 读宿主归档集
613
813
  (`<dsh home>/storages/workspace.json` 的 `global.archivedSessionIds`,读不到
package/touchstone.py CHANGED
@@ -6,7 +6,7 @@ Windows 原生运行的首选入口(bash/nohup/kill 不可用),Linux 上
6
6
  .run/server.log)。用法:
7
7
 
8
8
  python3 touchstone.py start|stop|restart|status|build|test|test-full|test-ui
9
- # Windows: python touchstone.py 或 touchstone.cmd
9
+ # Windows: python touchstone.py 或 touchstone.cmd(后者自动解析 python → python3)
10
10
 
11
11
  行为对齐 touchstone.sh:
12
12
  - start / restart 先按需构建前端(npm install 按需:依赖指纹未变化则跳过,
@@ -117,8 +117,11 @@ def precheck_deps():
117
117
  import requests # noqa: F401
118
118
  import zstandard # noqa: F401
119
119
  except ImportError as e:
120
- print_err(f"错误: python3 缺少运行依赖({e}),请先执行:")
121
- print_err(" python3 -m pip install -r requirements.txt")
120
+ # 用当前解释器自己的路径提示(不写死 python3/python): Windows 官方安装器只有
121
+ # `python.exe`、Linux 惯例是 `python3`, 报用户机器上真正在跑的那个最不容易误导。
122
+ py = sys.executable or "python"
123
+ print_err(f"错误: 当前解释器缺少运行依赖({e}),请先执行:")
124
+ print_err(f" {py} -m pip install -r requirements.txt")
122
125
  sys.exit(1)
123
126
 
124
127
 
@@ -448,7 +451,8 @@ def cmd_test_full():
448
451
  ran = 0
449
452
  for name in ("e2e_board_continue.py", "e2e_board_stream.py", "e2e_chat_queue.py",
450
453
  "e2e_unit_state.py", "e2e_stress_case.py", "e2e_board_archive.py",
451
- "e2e_board_worktree.py"):
454
+ "e2e_board_worktree.py", "e2e_ext_stale_recovery.py",
455
+ "e2e_subagent_no_occupancy.py"):
452
456
  script = os.path.join(ROOT, "tests", name)
453
457
  if not os.path.isfile(script):
454
458
  print(f"(跳过: 未随附 tests/{name})")
package/waitq.py CHANGED
@@ -727,6 +727,9 @@ def selfcheck(probe=None):
727
727
  的扫描集内**——③ 只取 `kind=KIND_CARD` 的活跃行,ext 行永不被 ③ 覆盖
728
728
  (v3 终审修复:原文「③ 只对『卡行已删』形态告警」不成立;③ 的实际报告面
729
729
  是两类 c: 行形态——卡不在 doing(列已非开发容器)+ 卡行已删(行无主))。
730
+ 反向形态「占位在、行不在」(doing+queue 卡无活跃 c: 行)**不在本函数面**:
731
+ 它要读 board_cards 的排队占位语义,归 `board._heal_queue_placeholders`
732
+ 的 30s 节拍(报告 + 补建一体,2026-10-10 实障卡 936)。
730
733
  probe:行判活探针(web busy 三态;`selfcheck_units` 透传,直调缺省 None)。
731
734
  """
732
735
  problems = []
@@ -1216,6 +1219,26 @@ def msg_get(msg_id):
1216
1219
  (str(msg_id),)).fetchone()
1217
1220
 
1218
1221
 
1222
+ def msg_meta(msg_id):
1223
+ """消息等待项 meta(取该 target 的**最后一行**,含终态行):飞书回流钩子在
1224
+ 终态收口之后读「提交时定格」的载荷(feishu 命名空间)。无行返回 {},坏 JSON 亦 {}。
1225
+
1226
+ 只读口:与 msg_get 相邻;口径上不看 state(收口已把行落终态,活跃口径读不到),
1227
+ 也不去 join chat_msgs——载荷只在等待项行上(chat_msgs 无 meta 列)。
1228
+ """
1229
+ with db.connect() as conn:
1230
+ row = conn.execute(
1231
+ "SELECT meta FROM wait_items WHERE kind=? AND target_id=?"
1232
+ " ORDER BY id DESC LIMIT 1", (KIND_MSG, str(msg_id))).fetchone()
1233
+ if row is None:
1234
+ return {}
1235
+ try:
1236
+ meta = json.loads(row["meta"] or "{}")
1237
+ except ValueError:
1238
+ return {}
1239
+ return meta if isinstance(meta, dict) else {}
1240
+
1241
+
1219
1242
  def msg_rows(sid=None, card_id=None, states=None):
1220
1243
  """消息行查询(chat 读侧专用):sid/card_id/states 可选过滤,created_at 升序
1221
1244
  (idx_chat_msgs_sid 覆盖 sid 过滤);states 传元组。"""