@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
@@ -105,20 +105,103 @@ function toggleByKey(e) {
105
105
  /** 宿主页 keydown 监听体(capture 阶段; 函数引用稳定, 便于 dispose 时精确解绑)。 */
106
106
  function onHostKey(e) { toggleByKey(e); }
107
107
 
108
+ // ── 侧栏入口按钮的配色与几何(2026-10-09 修) ──────────────────────────────────
109
+ // 血案: 原先写 color:'var(--dsw-alias-text-l1, #e8e8e8)' —— dsh **没有** text-l1 这族
110
+ // token(真实族是 --dsw-alias-label-primary/secondary/tertiary/caption/dimmed), var() 取不到
111
+ // 就一直吃 fallback: 浅色档在 #f9fafb 的侧栏底上画 #e8e8e8 的字(实测对比度 1.17, 同排的
112
+ // 「插件」「设置」是 18.08), 只有深色档碰巧能看。教训: 引用宿主 token 一律先核实名字。
113
+ // 现行口径(明暗两档都跟宿主走):
114
+ // · 颜色**只取 dsh 语义 token 且不写死 fallback** —— token 缺席时该声明失效并回落到继承
115
+ // (侧栏根 .hHd-Xa_root 已设 label-primary), 而不是钉死一个只对某一档成立的灰;
116
+ // · 尺寸/圆角/gap/行高逐条对齐 dsh 自己的 .SidebarRoot_panelRow(min-height 36 / 内边距
117
+ // 7px 8px / margin 0 2px / gap 8 / line-height 22 / --dsw-radius-md), 收起态对齐
118
+ // .hHd-Xa_collapsed .panelRow(36x36 居中);
119
+ // · 图标盒 16x16 —— 与 dsh 侧栏 SVG 图标同尺寸; 文字继承侧栏根的 14px。
120
+ const ENTRY_TITLE = 'Touchstone 测试平台(Alt+T 开关)';
121
+ const ENTRY_GLYPH_STYLE = {
122
+ flex: '0 0 16px', width: 16, height: 16, display: 'inline-flex',
123
+ alignItems: 'center', justifyContent: 'center', fontSize: 16, lineHeight: '16px',
124
+ };
125
+ const ENTRY_LABEL_STYLE = {
126
+ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
127
+ };
128
+
129
+ // 交互态样式(悬停/开合/键盘聚焦环): 伪类没法写在 inline style 里, 落一张最小样式表。
130
+ // 取值逐条对齐 dsh 侧栏行(.hHd-Xa_panelRow:hover 与 :focus-visible)。
131
+ const ENTRY_CSS = [
132
+ '.ts-sb-entry{background:transparent}',
133
+ '.ts-sb-entry:hover,.ts-sb-entry.ts-sb-on{background:'
134
+ + 'var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent))}',
135
+ '.ts-sb-entry:focus-visible{outline-width:var(--dsw-focus-ring-width,2px);outline-style:solid;'
136
+ + 'outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));'
137
+ + 'outline-offset:-2px}',
138
+ ].join('');
139
+ const ENTRY_CSS_ID = 'touchstone/sidebar-entry.css';
140
+
141
+ /** 幂等挂上入口行交互态样式表(形态与 dsh 插件自身的注入方式一致)。
142
+ * **失败一律吞掉**: 挂不上只是少了悬停/聚焦环的观感, 绝不能让侧栏入口或面板受影响
143
+ * (桩 DOM 自检环境里 document 面本就不全, 真机异常同理)。
144
+ * @returns 摘除函数(插件卸载时调用; 已存在/挂不上时返回空操作)。 */
145
+ function mountEntryStyle() {
146
+ try {
147
+ if (typeof document === 'undefined' || !document.head
148
+ || typeof document.createElement !== 'function'
149
+ || typeof document.querySelector !== 'function') {
150
+ return () => {};
151
+ }
152
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(ENTRY_CSS_ID) + ']')) {
153
+ return () => {};
154
+ }
155
+ const tag = document.createElement('style');
156
+ tag.dataset.plugin = 'touchstone';
157
+ tag.dataset.pluginCss = ENTRY_CSS_ID;
158
+ tag.textContent = ENTRY_CSS;
159
+ document.head.appendChild(tag);
160
+ return () => tag.remove();
161
+ } catch (error) {
162
+ console.warn('[touchstone] 侧栏入口样式注入失败(只影响悬停/聚焦观感):', error);
163
+ return () => {};
164
+ }
165
+ }
166
+
108
167
  // 侧栏入口按钮(footer.action 槽; wide=侧栏展开态显示文字)
109
168
  function Toggle(props) {
110
- const wide = props && props.wide;
169
+ const wide = !!(props && props.wide);
111
170
  const isOpen = useOpen();
171
+ const style = {
172
+ boxSizing: 'border-box', display: 'flex', alignItems: 'center',
173
+ border: 'none', cursor: 'pointer', textAlign: 'left',
174
+ color: 'var(--dsw-alias-label-primary)', // 无 fallback: 取不到即回落继承(见上注)
175
+ borderRadius: 'var(--dsw-radius-md, 8px)',
176
+ fontSize: 'inherit', // 侧栏根是 14px, 不再自带 13px
177
+ };
178
+ if (wide) {
179
+ style.gap = 8;
180
+ style.margin = '0 2px';
181
+ style.padding = '7px 8px';
182
+ style.minHeight = 36;
183
+ style.lineHeight = '22px';
184
+ style.flex = '1 1 auto';
185
+ style.minWidth = 0;
186
+ } else {
187
+ style.width = 36;
188
+ style.height = 36;
189
+ style.margin = 0;
190
+ style.padding = 0;
191
+ style.flex = 'none';
192
+ style.justifyContent = 'center';
193
+ }
112
194
  return h('button', {
113
- style: {
114
- display: 'flex', alignItems: 'center', gap: 6, width: '100%', boxSizing: 'border-box',
115
- padding: '6px 12px', margin: '2px 0', borderRadius: 8, cursor: 'pointer', border: 'none',
116
- color: 'var(--dsw-alias-text-l1, #e8e8e8)', fontSize: 13, textAlign: 'left',
117
- background: 'transparent',
118
- },
119
- title: 'Touchstone 测试平台(Alt+T 开关)',
195
+ type: 'button',
196
+ className: 'ts-sb-entry' + (isOpen ? ' ts-sb-on' : ''),
197
+ style,
198
+ title: ENTRY_TITLE,
199
+ 'aria-label': ENTRY_TITLE,
200
+ 'aria-expanded': isOpen,
120
201
  onClick: () => setOpen(!isOpen),
121
- }, wide ? (isOpen ? '🧪 关闭 Touchstone' : '🧪 Touchstone') : '🧪');
202
+ },
203
+ h('span', { style: ENTRY_GLYPH_STYLE, 'aria-hidden': 'true' }, '🧪'),
204
+ wide ? h('span', { style: ENTRY_LABEL_STYLE }, isOpen ? '关闭 Touchstone' : 'Touchstone') : null);
122
205
  }
123
206
 
124
207
  // 关闭按钮自动隐藏(2026-10-03): 常显会盖住被嵌 SPA 侧栏底部的用户名, 所以平时完全隐藏,
@@ -330,6 +413,8 @@ function Panel() {
330
413
 
331
414
  // cordis 客户端插件入口: 等 slots 服务就位后注册两个 UI 槽, 再挂快捷键
332
415
  function apply(ctx) {
416
+ // 侧栏入口的交互态样式表(悬停/开合/聚焦环), 与本插件同生命周期
417
+ ctx.effect(() => mountEntryStyle(), 'touchstone: 侧栏入口样式');
333
418
  const slots = ctx.get('slots');
334
419
  if (!slots) return;
335
420
  slots.inject('sidebar.footer.action', () => slots.register(
package/dshdriver.py CHANGED
@@ -143,9 +143,30 @@ def available(timeout=HEALTH_TIMEOUT):
143
143
  return False
144
144
 
145
145
 
146
+ def live_snapshot(timeout=10):
147
+ """`/live` 的**原始响应**:`{sessions: [...], complete?: bool}`。
148
+
149
+ `complete`(A 批 2026-10-08 起真插件会上报)=插件是否已枚举宿主已有会话:
150
+ 只有它为 true,**空表**才具备「宿主里确实没有会话」的语义(见 dshevents._align)。
151
+ 旧装机副本没有该字段 ⇒ `complete` 缺省按未声明处理(空表=未知)。
152
+
153
+ 为什么单列一个读口(2026-10-08 修正):`live()` 返回的是**行表**(历史调用点
154
+ server.py 直接迭代),而中枢对齐需要「行表 + 完整性声明」两件东西。旧写法拿
155
+ `live()` 的返回值当 dict 取 `.get("sessions")` ⇒ `/live` 非空时必抛
156
+ AttributeError,被 `except` 吞掉后中枢**永久「未对齐」**(`aligned()` 恒 False ⇒
157
+ 看板 recover 不搬列、调和器缺席归位不触发)。契约由
158
+ `tests/test_session_visibility.py::test_live_readers_contract` 钉死。
159
+ """
160
+ return _request("GET", "/live", timeout=timeout) or {}
161
+
162
+
146
163
  def live():
147
- """当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。"""
148
- return _request("GET", "/live", timeout=10).get("sessions", [])
164
+ """当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。
165
+
166
+ 返回**行表 list**(历史契约:server.py 的外部会话汇总直接迭代);需要「完整性
167
+ 声明」的调用方(dshevents._align)用 `live_snapshot()`。
168
+ """
169
+ return list((live_snapshot() or {}).get("sessions") or [])
149
170
 
150
171
 
151
172
  def status(session_id):
@@ -245,9 +266,17 @@ def fork(session_id, at_seq=None):
245
266
  return _request("POST", "/fork", payload, timeout=60)
246
267
 
247
268
 
248
- def rename(session_id, title):
249
- """改会话标题(与 dsh 侧栏同一份标题)。返回 `{title}`。"""
250
- return _request("POST", "/rename", {"session_id": session_id, "title": title})
269
+ def rename(session_id, title, timeout=REQUEST_TIMEOUT):
270
+ """改会话标题(与 dsh 侧栏同一份标题)。返回 `{title, [external]}`。
271
+
272
+ `title` 是**宿主接受值**(可能被 `normalizeSessionTitle` 规范化/按 UTF-8 字节预算
273
+ 截断),调用方据此回写卡面可保证两侧逐字一致(见 board.rename_card_session)。
274
+
275
+ `timeout` 可缩短:卡面改名是 best-effort 的同步链路(`PATCH /board/cards/<cid>`),
276
+ 不能让驱动侧僵死把 HTTP worker 拖满默认 120s。
277
+ """
278
+ return _request("POST", "/rename", {"session_id": session_id, "title": title},
279
+ timeout=timeout)
251
280
 
252
281
 
253
282
  def set_model(session_id, model, provider="", reasoning_effort=""):
@@ -355,6 +384,46 @@ def unarchive(session_id, timeout=30):
355
384
  return archive(session_id, archived=False, timeout=timeout)
356
385
 
357
386
 
387
+ # ---------- 看管声明(外部会话投递通道,C 批 T5 2026-10-10) ----------
388
+
389
+ # 看管声明超时(秒):`/watch` 是驱动侧**纯内存写**,正常毫秒级返回;而投递前置闸
390
+ # (`chat._external_preflight`)是在 HTTP 请求线程上同步调用它的——不能沿用普通请求
391
+ # 的 120s,否则驱动僵死会把用户的一次「发送」卡住两分钟。2s 与「声明不成功即拒投」
392
+ # 的语义相称(设计 §5.1)。
393
+ WATCH_TIMEOUT = 2
394
+
395
+ # 旧插件没有 `/watch` 端点时的状态码(未知驱动端点,见插件 _serve 的兜底 404)。
396
+ # 平台据此把「外部会话投递」降级为拒投并给「插件过旧」文案,池内一切功能不受影响。
397
+ WATCH_UNSUPPORTED = 404
398
+
399
+
400
+ def watch_session(session_id, on=True):
401
+ """声明 / 撤销「平台看管该会话」(驱动 `POST /watch`,幂等)。返回看管是否生效。
402
+
403
+ 为什么需要(C 批设计 §3.1):外部会话(用户在 dsh GUI 里直跑/接管、`/live` 里
404
+ `owned:false`)既不在驱动池、也没有平台持有者,驱动侧因此不可能靠「在不在池内」
405
+ 推断平台是否有意投递——**范围必须由平台显式划定**(宿主里与 Touchstone 无关的
406
+ 会话多得是,无差别放开等于允许平台向任意会话注入消息与答案)。平台只对**平台确实
407
+ 要投递的**会话声明看管:有看板卡的会话由 `board._ensure_watch` 常态声明;任务侧
408
+ 会话在宿主上报为外部态(`owned:false`)时,由消息前置闸 `chat._external_preflight`
409
+ 与作答/审批送达前的 `board._watch_before_delivery` 补声明。驱动据此放行 `/prompt`·
410
+ `/steer`·`/answer`·`/approval` 的池外分支。
411
+
412
+ 幂等:重复声明同一 sid 不报错,驱动回执给的是 `watched` 的**当前实际状态**
413
+ (而非「这次有没有改动」),故本函数返回 `bool(resp["watched"])`——调用方按
414
+ 「True 才算声明成功」处置。`on=False` 撤销(卡删除/归档后的回收,见
415
+ `board._watch_prune`)。
416
+
417
+ 失败语义:**异常照抛**(`DshDriverError`:旧插件 404 / 驱动不可达 / 令牌错),
418
+ 「是否吞异常降级」由调用方决定——`board._ensure_watch` 选择留痕 + 返回 False
419
+ 且不重试(看管失败 ⇒ 投递前置闸拒投,见 `chat._external_preflight`)。
420
+ """
421
+ resp = _request("POST", "/watch",
422
+ {"session_id": session_id, "on": bool(on)},
423
+ timeout=WATCH_TIMEOUT)
424
+ return bool((resp or {}).get("watched"))
425
+
426
+
358
427
  def apply_session_defaults(session_id, model="", provider="",
359
428
  reasoning_effort="", permission_mode="", log=None):
360
429
  """把项目级默认(思考等级 / 权限档)应用到刚建或刚续的会话上(best-effort)。
package/dshevents.py CHANGED
@@ -61,6 +61,11 @@ class EventHub:
61
61
  # 与 `_sessions` 同样遵守「断连=未知」不变量——None 时调用方一律不动作。
62
62
  self._archived = None
63
63
  self._connected = False
64
+ # 快照对齐是否**可信**(2026-10-08 批次,B):只有「(重)连时拿到过权威快照」
65
+ # 才置 True——空快照且插件未声明完整时保留旧表但置 False(见 `_align`)。
66
+ # 读口 `aligned()` = connected ∧ _aligned:调用方据此决定「能否按缺席推断
67
+ # 会话已结束」(`board._iw_once`)与「能否按实况搬列」(`board.recover`)。
68
+ self._aligned = False
64
69
  self._last_seq = 0
65
70
  self._frames = 0
66
71
  self._reconnects = 0
@@ -101,11 +106,29 @@ class EventHub:
101
106
  with self._cond:
102
107
  return self._connected
103
108
 
109
+ def aligned(self):
110
+ """注册表是否**可信**(= 链路在线 ∧ 曾用插件权威快照对齐过)。
111
+
112
+ 为什么需要它(2026-10-08 批次 B,bug_report/20261008_1935):
113
+ 插件重新 apply(热重载)时驱动实例重建、`/live` 会短暂返回空表——旧实现
114
+ `self._sessions = fresh` 直接清空注册表,调用方随即把「未知」当成
115
+ 「会话已结束/空闲」:`board.recover()` 把**正在运行的卡**搬去待审核,
116
+ 且调和器此后再也读不到该会话 ⇒ 不自愈。现在空快照保留旧表并置
117
+ `_aligned=False`,本读口给出「是否可信」的单点判据:
118
+ - False ⇒ 一律不据实况写列(未知 ≠ 空闲,同「断连=未知」不变量);
119
+ - True ⇒ 会话不在注册表即可判「已结束」(`board._iw_once` 的补口)。
120
+ 插件半(A 批次)在 `/live` 里声明 `complete` 后,真·零会话的宿主也能可信。
121
+ """
122
+ with self._cond:
123
+ return bool(self._connected and self._aligned)
124
+
104
125
  def get(self, sid):
105
126
  """单会话实时态;未连接或未知会话返回 None(=未知,不是空闲)。
106
127
 
107
- 字段:`session_id/status/cwd/task/owned/interaction/last_turn_reason/
108
- updated_at`——与 `dshdriver.status()` 同形,供 board 直接替换读口。
128
+ 字段:`session_id/status/cwd/task/owned/origin/interaction/
129
+ last_turn_reason/updated_at`——与 `dshdriver.status()` 同形,供 board 直接
130
+ 替换读口。`origin`(2026-10-07 增):`"subagent"` = dsh 子代理会话,
131
+ `""` = 未知(旧插件未上报,board 侧另有磁盘兜底判据)。
109
132
  """
110
133
  if not sid:
111
134
  return None
@@ -144,6 +167,7 @@ class EventHub:
144
167
  """诊断信息(/api 健康与测试断言用)。"""
145
168
  with self._cond:
146
169
  return {"connected": self._connected, "sessions": len(self._sessions),
170
+ "aligned": bool(self._connected and self._aligned),
147
171
  "last_seq": self._last_seq, "frames": self._frames,
148
172
  "reconnects": self._reconnects, "changed": self._changed,
149
173
  "archived": None if self._archived is None else len(self._archived),
@@ -174,6 +198,24 @@ class EventHub:
174
198
  self._cond.wait(timeout)
175
199
  return self._changed != before
176
200
 
201
+ def wait_connected(self, timeout):
202
+ """等(首)次连接就绪:就绪返回 True,超时返回 False。
203
+
204
+ 启动补跑用(server 在 `dshevents.start()` 之后等它就绪,再对账 ext 行
205
+ ——2026-10-07 僵尸占用修复):`board.recover()` 执行时中枢尚未 start,
206
+ 那次 ext 对账必然「探测不可用」而保行,需要一次连接就绪后的补跑。
207
+ 未配置驱动/宿主不可达时至多等满 timeout;绝不把「未连接」当成就绪
208
+ (不变量 1「断连=未知」)。
209
+ """
210
+ deadline = time.monotonic() + max(0.0, float(timeout))
211
+ with self._cond:
212
+ while not self._connected:
213
+ remain = deadline - time.monotonic()
214
+ if remain <= 0:
215
+ return False
216
+ self._cond.wait(remain)
217
+ return True
218
+
177
219
  # ---------- 内部:连接与折叠 ----------
178
220
 
179
221
  def _run(self):
@@ -206,8 +248,20 @@ class EventHub:
206
248
  新的状态轮询」。归档集取不到时保持「未知」(None),调用方一律不动作。
207
249
  """
208
250
  try:
209
- rows = (dshdriver.live() or {}).get("sessions") or []
210
- except Exception: # noqa: BLE001 — 对齐失败不阻断重连
251
+ # 用 live_snapshot()(原始 dict)而不是 live()(行表 list)——2026-10-08 修正:
252
+ # 旧写法 `dshdriver.live() or {}` 把行表当 dict 取 `.get("sessions")`,
253
+ # `/live` 非空时必抛 AttributeError 被下面 except 吞掉 ⇒ 中枢**永久未对齐**
254
+ # (aligned() 恒 False,C/D 两处补口在真机等于失效)。契约见
255
+ # tests/test_session_visibility.py::test_live_readers_contract。
256
+ resp = dshdriver.live_snapshot() or {}
257
+ rows = resp.get("sessions") or []
258
+ # 插件完整声明(A 批次后新增,旧插件缺省 None=未声明):只有它才让
259
+ # 「空表」具备「宿主里确实没有会话」的语义,否则空表一律按未知处理。
260
+ complete = resp.get("complete") is True
261
+ except Exception as exc: # noqa: BLE001 — 对齐失败不阻断重连
262
+ self._set_aligned(False) # 取不到快照=不能确认任何事
263
+ print(f"[dshevents] /live 对齐失败({exc}):保留 {len(self._sessions)} 条"
264
+ f"既有会话、标记未对齐", flush=True)
211
265
  return
212
266
  try:
213
267
  archived = {str(s) for s in (dshdriver.archived() or []) if s}
@@ -227,6 +281,9 @@ class EventHub:
227
281
  "cwd": row.get("cwd") or old.get("cwd") or "",
228
282
  "task": row.get("task") or old.get("task") or "",
229
283
  "owned": bool(row.get("owned", old.get("owned", False))),
284
+ # origin 是会话固有属性(subagent / 空=未知):快照没带就保留
285
+ # 事件里学到的值,别把已认出的子代理会话在重连对齐时又变成未知
286
+ "origin": str(row.get("origin") or old.get("origin") or ""),
230
287
  # /live 的 interaction 可能为 None(此刻无等待);已挂起的
231
288
  # interaction 若快照里没带,保留事件里学到的值,避免抖动
232
289
  "interaction": row.get("interaction", old.get("interaction")),
@@ -242,11 +299,34 @@ class EventHub:
242
299
  "inbox": old.get("inbox") or [],
243
300
  "updated_at": now,
244
301
  }
245
- self._sessions = fresh
302
+ # —— 快照可信判定(2026-10-08 批次 B)——
303
+ # 非空快照 = 权威整表;空快照只有插件声明完整(complete)才算权威空。
304
+ # 其余情况(热重载后驱动实例重建、不重枚举已有会话 ⇒ 空表)=**未知**:
305
+ # 旧表原样保留(宁可多等不可误放行),只把可信标志置 False——
306
+ # 调用方一律不据此推断忙/闲,也绝不据「缺席」判会话已结束。
307
+ trusted = bool(fresh) or complete
308
+ if trusted:
309
+ self._sessions = fresh
310
+ kept = len(self._sessions)
311
+ self._aligned = trusted
246
312
  if archived is not None:
247
313
  self._archived = archived
248
314
  self._changed += 1
249
315
  self._cond.notify_all()
316
+ if not trusted and kept:
317
+ # 留痕(插件形态落 <库目录>/plugin-backend.log):这是可诊断的降级态,
318
+ # 不是静默——旧表保留期间看板列不做忙/闲写入,直到下一次权威快照。
319
+ print(f"[dshevents] /live 空快照且插件未声明完整:保留 {kept} 条既有会话、"
320
+ f"标记未对齐(热重载后驱动实例重建的已知形态,见 "
321
+ f"bug_report/20261008_1935)", flush=True)
322
+
323
+ def _set_aligned(self, value):
324
+ """置「快照可信」标志(变更才唤醒订阅者;调用方持锁外语义同 `_set_connected`)。"""
325
+ with self._cond:
326
+ if self._aligned != bool(value):
327
+ self._aligned = bool(value)
328
+ self._changed += 1
329
+ self._cond.notify_all()
250
330
 
251
331
  def _set_connected(self, value):
252
332
  with self._cond:
@@ -293,7 +373,8 @@ class EventHub:
293
373
  item = self._sessions.get(sid)
294
374
  if item is None:
295
375
  item = {"session_id": sid, "status": "", "cwd": "", "task": "",
296
- "owned": False, "interaction": None,
376
+ "owned": False, "origin": "",
377
+ "interaction": None,
297
378
  "last_turn_reason": None, "last_seq": 0,
298
379
  "usage": None, "permission": None, "inbox": [],
299
380
  "updated_at": time.time()}
@@ -345,6 +426,9 @@ class EventHub:
345
426
  "cache_read": int(data.get("cache_read") or 0)}
346
427
  elif typ == "session/created":
347
428
  item["cwd"] = data.get("cwd") or item["cwd"]
429
+ # 子代理会话标记(2026-10-07):插件在 `session/created` 里上报
430
+ # 会话头行的 origin。缺字段保持旧值/空串=未知,绝不推断。
431
+ item["origin"] = str(data.get("origin") or item.get("origin") or "")
348
432
  elif typ == "session/disposed":
349
433
  self._sessions.pop(sid, None)
350
434
  self._changed += 1
@@ -374,6 +458,11 @@ def connected():
374
458
  return HUB.connected()
375
459
 
376
460
 
461
+ def aligned():
462
+ """注册表可信读口(在线 ∧ 曾用权威快照对齐);未对齐 ⇒ 调用方不写列、不推断。"""
463
+ return HUB.aligned()
464
+
465
+
377
466
  def get(sid):
378
467
  return HUB.get(sid)
379
468
 
@@ -430,6 +519,11 @@ def wait(timeout):
430
519
  return HUB.wait(timeout)
431
520
 
432
521
 
522
+ def wait_connected(timeout):
523
+ """等中枢(首)次连接就绪(单例薄壳):就绪 True / 超时 False。"""
524
+ return HUB.wait_connected(timeout)
525
+
526
+
433
527
  def subscribe(callback):
434
528
  return HUB.subscribe(callback)
435
529
 
package/export_cases.py CHANGED
File without changes
File without changes
File without changes
File without changes