@twinsearth/dsh-master 3.3.3 → 3.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +17 -0
  2. package/client.js +126 -1
  3. package/cordis.patch.yml +51 -0
  4. package/dist-host/agents/pjm-agent.js +62 -0
  5. package/dist-host/agents/pm-agent.js +57 -0
  6. package/dist-host/agents/prompts.js +71 -0
  7. package/dist-host/agents/toolkit.js +64 -0
  8. package/dist-host/channel/abort-registry.js +93 -0
  9. package/dist-host/channel/protocol.js +114 -0
  10. package/dist-host/channel/server.js +349 -0
  11. package/dist-host/channel/stream-state.js +343 -0
  12. package/dist-host/channel/ws.js +199 -0
  13. package/dist-host/dual/documents.js +143 -0
  14. package/dist-host/dual/orchestrator.js +214 -0
  15. package/dist-host/dual/schema.js +367 -0
  16. package/dist-host/engine/atomic-switch.js +159 -0
  17. package/dist-host/engine/health-probe.js +86 -0
  18. package/dist-host/engine/index.js +18 -0
  19. package/dist-host/engine/llm-stream.js +215 -0
  20. package/dist-host/engine/plugin-updater.js +392 -0
  21. package/dist-host/engine/safe-update.js +150 -0
  22. package/dist-host/index.js +154 -3
  23. package/dist-host/kernel.js +27 -1
  24. package/dist-host/plugins/cost-optimizer.js +3 -3
  25. package/dist-host/plugins/dual-manager.js +374 -77
  26. package/docs/AGENTS-PM-PJM.md +207 -0
  27. package/docs/ARCHITECTURE.md +2 -0
  28. package/docs/CHANGELOG.md +74 -0
  29. package/docs/CHANNEL-WEBSOCKET.md +169 -0
  30. package/docs/DESIGN-command-center.md +147 -0
  31. package/docs/ENGINE-PRIMITIVES.md +215 -0
  32. package/docs/GAP-ANALYSIS-fang-session.md +75 -0
  33. package/docs/REVIEW-PM-PJM-3.3.4.md +179 -0
  34. package/docs/examples/command-center/README.md +167 -0
  35. package/package.json +5 -1
  36. package/src/agents/pjm-agent.ts +62 -0
  37. package/src/agents/pm-agent.ts +58 -0
  38. package/src/agents/prompts.ts +74 -0
  39. package/src/agents/toolkit.ts +64 -0
  40. package/src/channel/abort-registry.ts +85 -0
  41. package/src/channel/protocol.ts +110 -0
  42. package/src/channel/server.ts +289 -0
  43. package/src/channel/stream-state.ts +349 -0
  44. package/src/channel/ws.ts +182 -0
  45. package/src/dual/documents.ts +142 -0
  46. package/src/dual/orchestrator.ts +223 -0
  47. package/src/dual/schema.ts +355 -0
  48. package/src/engine/atomic-switch.ts +146 -0
  49. package/src/engine/health-probe.ts +84 -0
  50. package/src/engine/index.ts +20 -0
  51. package/src/engine/llm-stream.ts +189 -0
  52. package/src/engine/plugin-updater.ts +359 -0
  53. package/src/engine/safe-update.ts +148 -0
  54. package/src/index.ts +143 -4
  55. package/src/kernel.ts +28 -1
  56. package/src/plugins/cost-optimizer.ts +3 -3
  57. package/src/plugins/dual-manager.ts +337 -76
@@ -0,0 +1,215 @@
1
+ # 宿主侧引擎原语:指针切换 / 健康检查 / 安全更新
2
+
3
+ > 3.3.5 新增。源码 `src/engine/`,对外导出 `@twinsearth/dsh-master/engine`。
4
+ > 这三个原语只回答「切换/验证/回滚」的**正确性**,不负责管理 VM,也不 import 瓮城代码。
5
+
6
+ ## 为什么单独拆出来
7
+
8
+ 规格里的 `AtomicSwitcher` / `EssenceUpdater` / `PluginSafeUpdater` 都假设存在一个
9
+ `VmManager` 和 `vm_create`/`vm_exec`/`vm_snapshot` 那套工具。核对结果:**房主没有这套工具面**,
10
+ 当前 Host 也没有。若照着写,只能写出“看起来能编译、实际调不到”的骨架。
11
+
12
+ 所以这里改成三层可验证的实现:
13
+
14
+ | 原语 | 文件 | 依赖 |
15
+ |---|---|---|
16
+ | 指针原子切换 | `atomic-switch.ts` | 只用 `node:fs/promises` + 平台判断 |
17
+ | 健康检查 | `health-probe.ts` | 只用 `fetch`(可注入) |
18
+ | 安全更新 + 回滚 | `safe-update.ts` | **执行能力由调用方注入**(`executor`) |
19
+
20
+ ## 1. 指针切换
21
+
22
+ ```js
23
+ import { switchPointer, readPointer, switchWithRollback } from '@twinsearth/dsh-master/engine'
24
+
25
+ await switchPointer({ linkPath: '/srv/current', targetDir: '/srv/green' })
26
+ // → { ok: true, code: 'switched', linkType: 'dir', atomic: true, resolvedMatchesTarget: true }
27
+ ```
28
+
29
+ - 做法:新链接写临时名 → `rename` 覆盖(同文件系统内原子);
30
+ - **Windows 真实限制**:`rename` 不能覆盖已存在的 junction(`EPERM`),退化为「先移开再放」,
31
+ 结果里 `atomic: false`、`replaced: 'move-aside'`——如实标记,不假装原子;
32
+ - 拒绝:目标不存在、目标是普通文件/目录(不覆盖真实数据)、自引用、父目录缺失、跨卷;
33
+ - `switchWithRollback()`:切换失败时把指针切回原目标,并返回 `rollback` 结果。
34
+
35
+ ## 2. 健康检查
36
+
37
+ ```js
38
+ await probeHealth({ url: 'http://127.0.0.1:60457/api/health', timeoutMs: 30_000, intervalMs: 1_000 })
39
+ // → { ok: true, status: 200, attempts, elapsedMs } 或
40
+ // { ok: false, code: 'unhealthy-status' | 'probe-failed' | 'probe-timeout', status, attempts }
41
+ ```
42
+
43
+ - 有总超时与最大次数,不会无限轮询;
44
+ - 只有拿到期望状态码才算健康;**连不上不等于健康**;
45
+ - `switchAndVerify()` = 切指针 + 健康检查;检查失败时切回**切换前**的旧目标(不是新目标)。
46
+
47
+ ## 3. 安全更新
48
+
49
+ ```js
50
+ await runSafeUpdate({
51
+ executor: { snapshot, restore, run }, // 必须注入;房主不自带 VM
52
+ update: ['dsh', 'plugin', 'update', '--all'],
53
+ smoke: ['dsh', 'restart'],
54
+ restart: ['dsh', 'restart'],
55
+ })
56
+ ```
57
+
58
+ - 顺序固定:**snapshot → update → smoke**,任何一步失败即 `restore`(+ 可选 `restart`);
59
+ - 没有执行器时返回 `executor-required`,不会假装成功;
60
+ - 快照失败直接拒绝更新(没有快照就没有回滚能力)。
61
+
62
+ ## 4. 插件安全更新流水线(`plugin-updater.ts`,最完整的那个)
63
+
64
+ ```js
65
+ import { createPluginUpdater } from '@twinsearth/dsh-master/engine'
66
+
67
+ const updater = createPluginUpdater({
68
+ executor, // 见下方契约
69
+ logger: { info, warn, error },
70
+ onProgress: (progress) => ui.push(progress), // phase + percent,抛异常也不会打断事务
71
+ })
72
+
73
+ const result = await updater.safeUpdate({
74
+ update: ['dsh', 'plugin', 'update', '--all'],
75
+ smoke: ['dsh', 'restart', '&&', 'curl', '-f', 'http://127.0.0.1:PORT/api/health'],
76
+ restart: ['dsh', 'restart'],
77
+ timeoutMs: 600_000,
78
+ key: 'essence-1', // 并发锁维度
79
+ })
80
+ // → { success, code, message, rolledBack, fatal, snapshotId, steps, phase }
81
+ ```
82
+
83
+ **executor 契约**(房主不内置实现,也不假设有 VM):
84
+
85
+ | 方法 | 说明 |
86
+ |---|---|
87
+ | `snapshot(name)` | 建快照;失败即中止更新 |
88
+ | `restore(name)` | 回滚到快照 |
89
+ | `run(argv, {phase})` | 执行命令;返回 `{ok, jobId}`,有 `jobId` 即异步作业 |
90
+ | `jobStatus(jobId)` | `{status:'running'\|'completed'\|'failed', exitCode?, stdout?, stderr?}` |
91
+ | `deleteSnapshot?(name)` | 成功后的清理(可选) |
92
+
93
+ **流水线语义**:
94
+
95
+ ```text
96
+ snapshotting ──失败──▶ failed(环境未改动,rolledBack:false)
97
+ │
98
+ ▼
99
+ updating ──同步失败/作业失败/作业超时/被中止──▶ rolling-back → restore(+restart)
100
+ │ │
101
+ ▼ └─ restore 失败 → code:'rollback-failed', fatal:true
102
+ smoke-testing ──失败/被中止──▶ rolling-back …
103
+ │
104
+ ▼
105
+ success(按策略清理快照;清理失败只告警)
106
+ ```
107
+
108
+ **生命周期与中断(生产级要点)**:
109
+
110
+ | 能力 | 说明 |
111
+ |---|---|
112
+ | `dispose()` | 宿主卸载时调用(包在 `ctx.effect` 里):中止所有在跑的更新;之后 `safeUpdate` 返回 `updater-disposed` |
113
+ | `safeUpdate({ signal })` | 外部取消;与整体超时、dispose 三者合并成一个内部信号 |
114
+ | 卡死适配器也能打断 | `jobStatus` 与 `sleep` 都和中止信号 `Promise.race`,不会出现“信号来了但轮询还挂着” |
115
+ | 中止后回滚 | 只要快照已经建立,中止即回滚(`aborted` + `rolledBack:true`);快照前中止则不动环境 |
116
+ | 冒烟期间中止 | 冒烟结束前会再检查一次信号,不会把“刚好跑完”当成功 |
117
+ | `minProgressIntervalMs` | 进度节流:阶段切换与终态必发,中间轮询可折叠 |
118
+ | `onFatal(failure)` | 回滚失败时必被调用(宿主可据此弹全局告警);钩子抛异常不改变结论 |
119
+
120
+ **命令构造(避免引号地狱 / 注入)**:
121
+
122
+ ```js
123
+ import { shellArgv, buildSmokeScript } from '@twinsearth/dsh-master/engine'
124
+
125
+ const smoke = buildSmokeScript({ port: 18042 }) // set -eu + 有界健康探测,端口来自调用方
126
+ await updater.safeUpdate({ update: ['dsh', 'plugin', 'update', '--all'], smoke: shellArgv(smoke) })
127
+ ```
128
+
129
+ `shellArgv(script)` 返回 `['bash', '-lc', script]`:脚本始终是**单个 argv 元素**,不做字符串拼接转义。
130
+ `buildSmokeScript` 的尝试次数与间隔会 clamp(≤60 次 / ≤30 秒),并且**不硬编码 3000 端口**。
131
+
132
+ **与草案 `PluginUpdater.ts` 的差异(都是有意为之)**:
133
+
134
+ | 草案 | 本实现 | 原因 |
135
+ |---|---|---|
136
+ | `vm.snapshot/submitJob/getJobStatus/restore` 直连 | 注入 `executor` | 房主没有 VM 工具面;直连会让代码永远调不到 |
137
+ | 整体超时只传给 `waitForJob(update)` | 整体预算 + 冒烟独立预算 + 每步 `remaining()` | 否则「更新 10 分钟 + 冒烟 1 分钟」会超总预算 |
138
+ | 回滚失败返回 `success:false` | 追加 `fatal:true` + `logger.error('FATAL …')` | 双重故障必须升级为“需人工介入”,不能和普通失败同形 |
139
+ | 无并发保护 | 同 `key` 并发直接 `update-in-progress` | 两路更新会互相踩快照 |
140
+ | 成功即删快照(异步、不等待) | `keepSnapshotOnSuccess` 可保留;删除失败只告警 | 删快照失败不应把成功翻成失败,但也不能静默 |
141
+ | `onProgress` 直接调用 | 回调异常被吞掉 | UI 出错不能让事务半途而废 |
142
+ | `waitForJob` 见 `failed` 即返回 | 同时看 `exitCode` | `completed` 但退出码非 0 也必须回滚 |
143
+
144
+ **接瓮城 VM 层的方式**:写一个 adapter,把 `vm_snapshot`→`snapshot`、`vm_job_submit`→`run`、
145
+ `vm_job_status`→`jobStatus`、`vm_restore`→`restore` 映射过来即可。**不要**在房主代码里直接写 `vm_*` 工具名。
146
+
147
+ ## 5. 可中断的 LLM 流(`llm-stream.ts`)
148
+
149
+ **先纠正一个常见误解**:DSH 插件侧的真实 API 是 `ctx.llm.stream(options)`,
150
+ 不是 `ctx.llm.client.chat.completions.create`,也不是某个 SDK 的 `create({stream:true})`。
151
+
152
+ ```ts
153
+ ctx.llm.stream({
154
+ provider: 'deepseek', model: 'deepseek-chat',
155
+ messages, system, tools,
156
+ signal, // ← 原生支持取消
157
+ })
158
+ // 产出 StreamChunk:{type:'text-delta', text} / {type:'usage'} / {type:'finish', reason}
159
+ // 取消时 reason 是 {kind:'aborted', failure}
160
+ ```
161
+
162
+ 封装后的用法:
163
+
164
+ ```ts
165
+ const streamer = createLlmStreamer({ streamFn: llmStreamFn(ctx.llm) })
166
+ const result = await streamer.run({
167
+ key: 'session-1',
168
+ options: { provider, model, messages, system },
169
+ onDelta: (delta) => ws.send({ type: 'stream_chunk', delta }),
170
+ })
171
+
172
+ // 用户点“停止”:
173
+ streamer.abort('session-1')
174
+
175
+ // result:
176
+ // { ok:false, code:'aborted', text:'半截内容', savedContent:'半截内容', aborted:true }
177
+ // 或 { ok:true, code:'completed', text, usage, finish:{kind:'stop'} }
178
+ // 或 { ok:false, code:'llm-error', failure:{message,code,status} }
179
+ ```
180
+
181
+ **语义保证**:
182
+
183
+ | 行为 | 说明 |
184
+ |---|---|
185
+ | signal 透传 | 取消信号直接交给 `ctx.llm.stream`,并 `Promise.race` 每一跳,卡住的上游也能返回 |
186
+ | 半截文本保留 | 中止后 `text` / `savedContent` 是已产出内容,可直接写回上下文续写 |
187
+ | 错误分类 | `aborted`(主动取消)/ `llm-error`(模型侧错误,带 failure)/ `stream-failed`(网络/其他抛出)三分,**不把取消记成失败** |
188
+ | 同 key 覆盖 | 同一会话新开一轮会先中止旧的,避免两路流并发扣费 |
189
+ | 生命周期 | `dispose()` 中止全部(宿主卸载),之后 `run` 返回 `streamer-disposed` |
190
+ | 收尾 | `finally` 里**有界**关闭上游:`return()` 与 50ms 定时器 race,避免卡死的生成器把清理挂住 |
191
+
192
+ **一个真实的坑(已在代码里修)**:卡死的异步生成器会把 `return()` 排在未完成的 `next()` 之后,
193
+ `await iterator.return()` 会**永久挂起**——这正是"僵尸句柄"在清理路径上的翻版。
194
+ 所以收尾必须有界:`Promise.race([return(), 50ms])`。
195
+
196
+ **上游卡住兜底**:`createLlmStreamer({ stallTimeoutMs })`。超过该时间没有新 chunk 时,
197
+ 主动中止并返回 `code:'stalled'`(`aborted:true`,保留半截文本),避免半死连接一直挂着烧钱。
198
+
199
+ **必须说清的边界**:
200
+
201
+ - 我能保证的是「**我们停止读取、并把 signal 交给了上游**」;
202
+ - 「服务端立刻停止推理与计费」取决于**模型厂商网关是否会因连接断开而终止生成**——
203
+ 这一点从插件侧无法验证,也没有日志能证明。任何声称"已验证停止扣费"的说法都是假的;
204
+ - 唯一可观测的间接证据是 usage:如果网关在 stream 结束前发过 `usage`,可以对比中止前后的 token 数;
205
+ 但如果它没发,就没有证据。
206
+
207
+ ## 6. 接入方式(诚实说明)
208
+
209
+ 房主本身不带 VM,`executor` 需要由外部提供:
210
+
211
+ - 瓮城的 VM 层(`vm_snapshot` / `vm_restore` / `vm_exec`)可包成 executor;
212
+ - 或宿主自己的进程/文件管理能力;
213
+ - 或测试里的假执行器(本项目 `test/engine.test.mjs` 就是这么验证顺序与回滚的)。
214
+
215
+ 在没有真实 executor 之前,这些原语可以被单测与脚本调用,但**不会自动接管任何宿主进程**。
@@ -0,0 +1,75 @@
1
+ # 瓮城会话通读与房主查漏补缺(第一轮 · 语料盘点与差距矩阵)
2
+
3
+ > 语料来源:`C:\Users\fangw\.dsh\sessions\--E-ToFuture--\e5dd3d5b-47a3-4e92-a75f-9ce31b8076bf\session.v4.jsonl.zstd`
4
+ > (1,218,908 B → 解压 3,908,690 B / 1,336 事件 / 152 轮用户 / 213 条助手消息 / 2,551,127 字符)
5
+ > 工作区 `E:\ToFuture`:590 个可读文本文件(排除 `full-backup`、`data-backup`、`recovery`、`Chat`、`.tmp-*`)
6
+ > 本文件只做**盘点与对比**;本轮不修改任何 dsh-fang 文件。
7
+
8
+ ## 1. 语料结构(213 条助手消息的主题聚类)
9
+
10
+ | 簇 | 消息号 | 主题 |
11
+ |---|---|---|
12
+ | A 插件热更新与安全护栏 | 3–47 | `swapPlugin` 伪代码、RestartGuard 两层状态机、影响面采集(会话/缓冲区)、`_getActiveHandles` 指纹、升红判定、评分子插件(A–E)、动作灯色(红黄蓝绿)、审批/审计、进程级隔离(瓮城 profile、AppContainer、Job Objects) |
13
+ | B Fork/Adapter/IPC | 42–71 | 端口池、junction、退避、`Forker`、协议与版本协商、SSE 解析 + **fuzz/定向模糊测试**、命令缓存、事件环 + cursor、HTTP adapter、tokenRegistry、孤儿恢复、双端口 |
14
+ | C Umbrella 评分与规则 | 72–110 | 插件/动作评分器、YAML 分层规则(硬规则锁定、默认/项目/环境)、AST 静态扫描(bindings 两阶段)、规则表覆盖率、增量重建一致性、健康度/趋势/通知/漂移 |
15
+ | D Mirror/Essence/Derived 与 UI | 111–149 | 三文件夹隔离、配额与 VIP、overlay 合并、per-key 锁、分身不落盘、镜像状态 store、偏好 schema 驱动 UI、引导向导(PC 探测)、版本检查 API、AtomicSwitcher、EssenceUpdater+HealthProbe、PluginUpdater(快照/冒烟/回滚) |
16
+ | E Agent 与前端交互 | 150–171 | PM/PJM Prompt 与工具、WS 协议、流式渲染、块级锁、光标三态、输入框状态机、心跳、AbortController/中断 |
17
+ | F 生态与商业化 | 76, 143, 172–199, 204, 212 | 桌面桥/IM2A、日志位置、L1–L5 演进、决策中心、SuperAgent、HarnessMM(GDR/CCS/RSI)、票证幂等、成本评测框架(4 对照组 × 6 维度) |
18
+
19
+ ## 2. 差距矩阵:房主现状 vs 瓮城设计
20
+
21
+ 判定:**已有**=房主已实现且有测试;**可借鉴**=通用能力,房主缺;**不适用**=绑定瓮城镜像/分身/VM,房主不该复制。
22
+
23
+ | 域 | 瓮城设计 | 房主现状 | 判定 |
24
+ |---|---|---|---|
25
+ | 插件热更新 | `swapPlugin` 原子交换 + 旧 fiber 有限恢复 + 升红 | 房主不热更插件;有 `engine/atomic-switch`(指针)与 `safe-update`(快照/冒烟/回滚) | 部分对应;**升红语义**可借鉴到 safe-update 的失败分级 |
26
+ | 影响面采集 | 会话/缓冲区收集器 + 排空语义 + dirty 状态机 | 无(房主无热更) | 不适用 |
27
+ | 句柄指纹 | `_getActiveHandles` 指纹 + Stable Window | 无 | **可借鉴**:房主自己也会有泄漏排查需求 |
28
+ | 两层状态机/守卫 | RestartGuard + 审批 + 审计 sink | 有 bridge token 审计、成本审计 | 部分对应 |
29
+ | 评分子插件 | AST 静态扫描(child_process/网络/fs/eval/凭据/混淆/外联)+ 等级 A–E | 无 | 不适用(瓮城装载第三方插件) |
30
+ | 动作灯色 | 命令/路径规则 → 红黄蓝绿 + YAML 分层 + 硬规则锁定 | 无(房主不执行用户命令) | 不适用 |
31
+ | 进程隔离 | 独立 DSH_HOME + AppContainer + Job Objects | 无 | 不适用(房主无子进程树) |
32
+ | Fork/端口/junction | 端口池、退避、双端口、junction | `engine/atomic-switch`(junction 切换)、`fangForker.spawn`(交给瓮城) | **部分可借鉴**:端口池/退避在房主无场景 |
33
+ | SSE 解析 | 纯帧解析 + **fuzz 性质测试 + 定向分片** | 客户端 EventSource;WS 帧解析器自实现(掩码/分片/长度) | **可借鉴(高优先)**:给 WS 解析器做 fuzz/性质测试 |
34
+ | 事件环 + cursor | `EventLog` ring buffer + cursor | 无独立实现;stream-state 有 orphan 计数 | **可借鉴**:SSE/WS 事件补 cursor 与有界缓冲 |
35
+ | 命令缓存/幂等 | `CommandCache` + 幂等键 | 有 dispatch 幂等(`superagent/dispatch`)与 relay 幂等 | 已有(部分) |
36
+ | 异步任务句柄 | 事件驱动 `waitForRestart`,**明确去掉轮询** | `PluginUpdater` 仍轮询 `jobStatus` | **可借鉴(高优先)**:事件优先 + 轮询兜底 |
37
+ | per-key 互斥 | `essence/lock.ts` | 无通用锁;dual-manager 用占位标记 | **可借鉴**:给闭环/计划加 keyed 锁 |
38
+ | 三文件夹隔离 | Mirror/Essence/Derived + 配额 | 房主无实体层;`docs/pm|pjm` 有路径包含检查 | 不适用 |
39
+ | 分身不落盘 | 配置随起随清、数据持久化 | 房主派发交给瓮城 | 不适用 |
40
+ | 版本检查 API | 版本比对 + 角标 | 有 `MASTER_VERSION` 与契约最小版本 | 已有(简化版) |
41
+ | 镜像指引 UI | 状态 store + 偏好 schema + 向导 | 只读面板(agents/dual/成本/协作) | **可借鉴**:偏好/状态入面板,但不做镜像向导 |
42
+ | PM/PJM | Prompt、工具、交接、决策中心 | **已实现**:19 工具 + 闭环 + 编排 + 面板 | 已有(更完整) |
43
+ | 流式/中断/心跳 | WS + abort + 心跳 + terminate | **已实现**:WS 通道 + abort-registry + 中断 + 心跳 + 僵尸清理 | 已有(更完整) |
44
+ | 成本评测 | 4 对照组 × 6 维度、三层任务集 | 有 §10.2 门禁 G1–G7 + A/B 实验(control/enforce) | **可借鉴**:评测矩阵维度与诚实边界写法 |
45
+ | 决策中心 | 票证/选项/审计 | 复用 `fangDecisionCenter` | 已有 |
46
+ | 健康/趋势通知 | health + trend + flapping + resolution | 有 `health()` 与成本审计;无趋势/漂移 | **可借鉴(低优先)**:健康快照趋势 |
47
+
48
+ ## 3. 本轮选定的可落地项(按价值/风险排序)
49
+
50
+ | # | 项 | 来源 | 落地位置 | 验收 |
51
+ |---|---|---|---|---|
52
+ | G1 | **WS 帧解析器 fuzz/性质测试**(分片、掩码、长度边界、随机字节不崩) | 簇 B(SSE fuzz) | `test/ws-fuzz.test.mjs` + `src/channel/ws.ts` 如需修 | 不崩、错误具名、合法帧往返一致 |
53
+ | G2 | **异步任务事件驱动**(`executor.onJobDone` 优先,轮询兜底) | 簇 B(去轮询) | `src/engine/plugin-updater.ts` | 事件先到不轮询;无事件时仍超时 |
54
+ | G3 | **per-key 互斥锁**(`createKeyedLock`),用于计划变更与派发 | 簇 D(`essence/lock`) | `src/engine/keyed-lock.ts` + dual-manager | 并发 close/dispatch 不产生中间态 |
55
+ | G4 | **有界事件环 + cursor**(WS 通道事件流) | 簇 B(EventLog) | `src/channel/server.ts` | 越界丢旧、cursor 单调、可重放 |
56
+ | G5 | **句柄泄漏自检**(`process.getActiveHandles` 快照 + 差异报告) | 簇 A(指纹) | `src/engine/handles.ts` | 只报告 refed 新增;测试可控 |
57
+ | G6 | **失败分级升红**(safe-update 结果带 severity) | 簇 A/D | `src/engine/safe-update.ts` | 分级可断言,不改变既有 code |
58
+
59
+ 下一轮起逐项实现(每项:代码 + 测试 + 文档 + 提交),全部只动 dsh-master。
60
+
61
+ ## 4. 明确不做(避免把瓮城搬进房主)
62
+
63
+ - 镜像/原身/分身三文件夹、配额与 VIP、overlay 合并、蓝色绿灯评分、AppContainer/Job Objects、端口池、SSE 服务端适配器、独立 DSH_HOME 的瓮城实例;
64
+ - 理由:这些绑定瓮城的物理形态;房主的定位是「母体控制面 + PM/PJM 双智能体」,复制会形成第二套并行架构。
65
+
66
+ ## 5. 语料可复现
67
+
68
+ ```powershell
69
+ node E:\GPT\.tmp\extract-fang-session.cjs # 解压 + 抽取到 E:\GPT\.tmp\fang-session-digest
70
+ node E:\GPT\.tmp\extract-fang-structure.cjs # 标题/关键词索引
71
+ node E:\GPT\.tmp\scan-tofuture.cjs # 工作区 590 个文本文件清单
72
+ ```
73
+
74
+ 产物:`session.jsonl`、`user-messages.txt`、`assistant-messages.txt`、`headings.txt`、
75
+ `keyword-lines.txt`、`workspace-files.json`。
@@ -0,0 +1,179 @@
1
+ # 房主 PM/PJM 智能体:产品盘点与项目计划(3.3.4)
2
+
3
+ > 产出时间:2026-11-14 · 范围:`E:\GPT\dsh-master`(房主)· 版本:**3.3.4 已三方发布**
4
+ > 本文件由「房主智能体」的角色契约产出(PM 六类盘点 + PJM 七类项目管理),**只写有证据的结论**。
5
+ > 结案记录见文末「第三部分 · 3.3.4 结案」。
6
+
7
+ ---
8
+
9
+ ## 第一部分 · 产品经理(PM)
10
+
11
+ ### WHY · 解决什么问题
12
+
13
+ 用户与开发者的需求是口语的,而代码、测试、发布是严格的。中间缺一层**可核对的翻译**:
14
+
15
+ - 需求含糊时没人先问清楚 → 做完了才发现理解错,返工成本最高;
16
+ - "完成度"靠口头声明 → 设计稿被当成交付,测试没跑也敢说 100%;
17
+ - 冲突与歧义埋在聊天记录里 → 决策反复,前后矛盾没人指出;
18
+ - 项目执行没有 WBS/owner/验收 → 谁做、多久、做成什么样都说不清。
19
+
20
+ ### WHAT · 交付了什么
21
+
22
+ 两个**可被新会话选择的 Agent preset** + 一个共享结构化服务:
23
+
24
+ | 交付物 | 位置 | 证据 |
25
+ |---|---|---|
26
+ | PM preset `master-pm` + 4 个工具 | `cordis.patch.yml`、`src/agents/pm-agent.ts` | `test/dual-agents.test.mjs`、`test/package-contract.test.mjs` |
27
+ | PJM preset `master-pjm` + 6 个工具 | 同上、`src/agents/pjm-agent.ts` | 同上 |
28
+ | 角色 Prompt(WHY/WHAT/六类;HOW/WHO/WHERE/WHEN/七类) | `src/agents/prompts.ts` | preset 注册断言、Prompt 文本断言 |
29
+ | 结构化契约与门禁 | `src/dual/schema.ts` | 10 条测试 |
30
+ | 文档渲染(PRD/盘点/项目/结案) | `src/dual/documents.ts` | 文档写入测试 |
31
+ | 共享服务(PRD/盘点/计划/进度/结案) | `src/plugins/dual-manager.ts` | `test/dual.test.mjs` + `test/dual-agents.test.mjs` |
32
+ | 智囊团只读面板 | `client.js`、`status().dual` | 面板与状态测试 |
33
+
34
+ **关键设计**:preset 决定"这个会话是谁",`ROLE_MATRIX` 决定"子插件在哪个角色装配",两者都不授予额外权限;
35
+ 智能体工具边界写在 preset 里(只读 fs + 提问/待办/交付,**无 shell、无写文件**)。
36
+
37
+ ### 六类盘点(房主范围,逐条带证据)
38
+
39
+ #### 1. 已完成(进度与证据)
40
+
41
+ | 项 | 进度 | 证据 |
42
+ |---|---|---|
43
+ | 成本优化系统层 + A/B 模块 + 报表/门禁/里程碑(3.3.0) | 100% | 449→462 测试;§10.2 门禁 G1–G7 全绿;真实 A/B −37.6% |
44
+ | 瓮城回流 contract 1.1.1 + 房主 relay 适配(3.3.1/3.3.2) | 100% | npm `1.1.1`;dsh-Fang 3.1.35 双进程实测 6 条回执、幂等重投通过 |
45
+ | 角色矩阵放开:母体全开 + 分身挂双主管(3.3.3) | 100% | `7e2c132`;7 条测试断言;`test:clean` 449/449 |
46
+ | PM/PJM 双智能体 + 共享服务 + 面板(3.3.4) | 100% | `666f9bc`、`d1cf443`;`test:clean` 462/462;tag `v3.3.4`=`bcd4a15d`→`6a7ffde6`,Release `444463794`,npm shasum `985677d0` |
47
+ | 三方发布流水线(tag + Release + npm 各四次) | 100% | `v3.3.0`–`v3.3.3`,资产 sha256 与本地一致 |
48
+
49
+ #### 2. 未完成(原因与后续)
50
+
51
+ | 项 | 原因 | 后续 |
52
+ |---|---|---|
53
+ | preset 在真实 Host 可选验证 | 守则 §二 禁止向任何 profile 安装 | 需一次**受限安装授权**,装到指定 profile 点选验证 |
54
+ | 面板卡片渲染验证 | 同上,且当前会话面板读的是已安装版本 | 与上一条合并做 |
55
+ | 智能体工具真实会话跑通 | 只有 fake ctx 级测试 | 同上 |
56
+
57
+ #### 3. 无法实现 / 受阻(卡点)
58
+
59
+ | 项 | 卡点 | 性质 |
60
+ |---|---|---|
61
+ | 宿主级 co-load / preset 点选 | 守则 §二:禁止把插件装进任何 profile | **策略限制**,非技术问题 |
62
+ | `vm_create/vm_exec` 那 27 个工具 | 房主仓库与本 Host 都不存在该工具面 | 依赖不存在,不是没写 |
63
+ | 房主的三层磁盘架构 / 蓝绿 / 快照回滚 | **那是瓮城 dsh-fang 的实现**,房主没有也不该有 | 跨仓库、跨项目 |
64
+
65
+ #### 4. 已计划(后续开发计划)
66
+
67
+ | 项 | 优先级 | 估时 |
68
+ |---|---|---|
69
+ | 3.3.4 三方发布 | P1 | 30–45 分钟 |
70
+ | 受限安装 + preset/面板验证 | P1(依赖授权) | 30 分钟 |
71
+ | PM → PJM 一键交接(PM 结案后生成 PRD 摘要供 PJM 建计划) | P2 | 2–3 小时 |
72
+ | 若确需真 VM:新增 vm-sandbox 适配层 | P3(依赖授权) | 3–5 天 |
73
+
74
+ #### 5. 相互矛盾 / 冲突(需你重新判断)
75
+
76
+ | # | 冲突 | 影响 | 我的处理 |
77
+ |---|---|---|---|
78
+ | A | 规格里"已完成:架构设计 100%、权限矩阵、UI 左厂右车、VM 27 工具映射、v3.0.8–v3.0.12" —— 这些是**瓮城**的成果,不是房主的 | 若照抄成房主完成度,就是伪造证据(PM 门禁本身禁止) | 已按房主真实仓库盘点;瓮城成果不计入房主 |
79
+ | B | "Agent = 后台调度官自动接管项目" vs "用户面板呼叫" | 自动接管会绕过用户决策 | 实现为 **preset 由用户选择**,不自动接管 |
80
+ | C | 旧文档"母体不接纳执行体" vs 3.3.3 已放开 | 文档与实现不一致 | 3.3.3 已改文档与测试,按你的选择执行 |
81
+ | D | 交付物要求"27 工具的 VM 沙箱插件" vs 房主只有 `fangForker` | 交付物清单无法满足 | PJM 派发走 `fangForker.spawn`,无 forker 即 fail-closed |
82
+
83
+ #### 6. 理解歧义(请确认是否理解错)
84
+
85
+ | # | 歧义 | 当前理解 |
86
+ |---|---|---|
87
+ | A | PM 是否应能改代码/跑测试? | **不能**:只读 + 文档 + 提问 |
88
+ | B | PJM 要真 VM 隔离,还是 `fangForker` 分身就够? | 当前按 `fangForker` 实现 |
89
+ | C | PM 结案后是否自动交给 PJM? | 不自动,人工选择(避免越权) |
90
+ | D | 这次是"发 3.3.4"还是"继续加功能"? | 代码已就绪,等你决定发布/验证/扩范围 |
91
+
92
+ ---
93
+
94
+ ## 第二部分 · 项目经理(PJM)
95
+
96
+ ### 一、项目会审
97
+
98
+ - **目标**:3.3.4 可发布、可验证;PM/PJM 在真实 Host 中可选择并可用。
99
+ - **范围**:发布 + 验证(不含 VM/三层架构移植,那属瓮城)。
100
+ - **依赖**:① 发布授权;② 受限安装授权(验证 preset);③ 若要做 VM 适配,需要范围与授权。
101
+ - **风险**:安装验证会改 profile;发布不可回滚;两者都需要你明确点头。
102
+
103
+ ### 二、项目计划(WBS)
104
+
105
+ | # | 步骤 | Owner | 场景 | 估时 | 依赖 | 验收 |
106
+ |---|---|---|---|---|---|---|
107
+ | P1 | 3.3.4 版本对齐 + CHANGELOG 收口 | 房主 Agent | 本仓库 | 20 分钟 | 发布授权 | 版本号/标题一致 |
108
+ | P2 | 门禁:check/build/test:clean + gate + milestones | 房主 Agent | 本仓库 | 15 分钟 | P1 | 462/462、G1–G7 全绿 |
109
+ | P3 | 三方发布:tag + Release + npm + 一致性核验 | 房主 Agent | 本仓库/registry | 30 分钟 | P2 | 资产 sha256 一致、latest 正确 |
110
+ | P4 | 受限安装到指定 profile | 房主 Agent | 指定 profile | 10 分钟 | **安装授权** | 插件行激活 |
111
+ | P5 | 点选 `master-pm`/`master-pjm`,确认工具可见 | 房主 Agent | 该 profile 新会话 | 15 分钟 | P4 | 工具清单与文档一致 |
112
+ | P6 | 面板智囊团卡片渲染验证 | 房主 Agent | 该 profile Web | 10 分钟 | P4 | 卡片与计数可见 |
113
+
114
+ ### 三、项目需求与内容
115
+
116
+ - 发布物:`@twinsearth/dsh-master@3.3.4`,含 `dist-host/agents/*`、`dist-host/dual/*`、`docs/AGENTS-PM-PJM.md`。
117
+ - 验证记录:预设可选择、工具可调用、结案门禁生效、文档写入 workspace。
118
+ - 文档:`docs/AGENTS-PM-PJM.md`(本文件之外的实现说明)。
119
+
120
+ ### 四、项目时间
121
+
122
+ - P1–P3:约 65 分钟(含 npm 传播等待)。
123
+ - P4–P6:约 35 分钟(依赖安装授权)。
124
+ - 合计:**约 1.5–2 小时**,不含 VM 适配(3–5 天,另议)。
125
+
126
+ ### 五、项目人员
127
+
128
+ | 角色 | 人 | 职责 |
129
+ |---|---|---|
130
+ | 实现/测试/发布 | 房主 Agent(本会话) | P1–P6 执行与证据 |
131
+ | 决策/授权 | 你 | 发布授权、安装授权、范围变更 |
132
+ | 需求方 | 你 | 验收标准与优先级 |
133
+
134
+ ### 六、测试、部署与交付验收
135
+
136
+ - **测试**:`npm run check`、`npm run build`、`npm run test:clean`(462/462)、§10.2 门禁、§14 里程碑。
137
+ - **部署**:npm public + GitHub Release;安装需单独授权,且只装指定 profile。
138
+ - **交付**:tarball + `.sha256` + `manifest.json`;`manifest.tagObject` 必须是 annotated tag。
139
+ - **验收**:三方产物 sha256 一致;CI 通过;preset 点选可用(若做 P4–P6)。
140
+
141
+ ### 七、项目结案
142
+
143
+ - **评审报告**:本文件 + `docs/CHANGELOG.md` 3.3.4 段 + `docs/AGENTS-PM-PJM.md`。
144
+ - **资料汇总**:设计文档、Prompt 源码、测试清单、发布 manifest、验证记录。
145
+ - **结案门禁**:P1–P3 全绿即可结案发布;P4–P6 未做则结案报告必须写明"host 级验证未做",不得写成已验证。
146
+
147
+ ---
148
+
149
+ ## 需要你做的决定
150
+
151
+ 1. 是否把 3.3.4 **装到某个 profile** 做 preset 点选验证(会改动该 profile)?
152
+ 2. 规格里的 VM 沙箱 / 三层架构 / 蓝绿升级,是否要**移植进房主**(这属于瓮城能力,需要明确范围)?
153
+ 3. PM → PJM 是否要自动交接(当前是人工选择)?
154
+
155
+ ---
156
+
157
+ ## 第三部分 · 3.3.4 结案(PJM)
158
+
159
+ **结案状态**:已发布(代码 + 三方产物),**宿主级验证未做**。
160
+
161
+ | 项 | 结果 |
162
+ |---|---|
163
+ | 版本 | `3.3.4`(package / kernel / cost-optimizer / 面板一致) |
164
+ | 门禁 | `check` 0 error、`build` 0 error、`test:clean` **462/462**、§10.2 G1–G7 全绿、§14 M1–M5 done |
165
+ | annotated tag | `v3.3.4` → tag object `bcd4a15d675c…` → commit `6a7ffde6cb6e…`(`type=tag`) |
166
+ | GitHub Release | `444463794`,3 个附件(tgz / `.sha256` / `manifest.json`),tgz 520,481 B,sha256 `25b5a410…` |
167
+ | npm | `@twinsearth/dsh-master@3.3.4`,`latest=3.3.4`,shasum `985677d0…`(与 Release 资产 sha1 一致),186 files |
168
+ | CI | run `43463616991` → `completed / success` |
169
+ | manifest | 含 `tagObject`、`companionPackages.@twinsearth/dsh-fang-contract=1.1.1` |
170
+
171
+ **评审报告**:本文件 + `docs/CHANGELOG.md`(3.3.4 段)+ `docs/AGENTS-PM-PJM.md`(角色与工具契约)。
172
+
173
+ **资料汇总**:`src/agents/*`(Prompt 与工具)、`src/dual/*`(契约与文档)、`cordis.patch.yml`(preset 行)、
174
+ `test/dual-agents.test.mjs`(10 条)、发布 manifest 与 tarball。
175
+
176
+ **未完成(如实记录,不包装成已完成)**:
177
+ - 宿主级 preset 点选与工具可见性验证(需安装授权);
178
+ - 面板在真实浏览器中的渲染验证;
179
+ - VM 沙箱 / 三层架构 / 蓝绿升级 **不在本版范围**(属瓮城)。
@@ -0,0 +1,167 @@
1
+ # 智囊团 Command Center(参考实现 · 流式版)
2
+
3
+ > **不随房主插件发布。** 插件里上线的是只读看板(`client.js` 的 `DualBoard`)。
4
+
5
+ ## 输入框状态机:三层门,不只靠 disabled
6
+
7
+ | 状态 | 触发 | 输入框 | 按钮 | Enter |
8
+ |---|---|---|---|---|
9
+ | `Idle` | 空闲且已连接 | 可编辑、自动聚焦 | 蓝色「发送」 | 发送 |
10
+ | `Generating` | `isGenerating(state) === true` | **禁用**,placeholder「AI 正在回复,可点击停止…」 | 红色「停止」 | **拦截**(`preventDefault`) |
11
+ | `Offline` | `connected === false` | 禁用,placeholder「连接已断开,正在重连…」 | 禁用 | 无效 |
12
+
13
+ 前端三道门(缺一不可):
14
+
15
+ 1. **UI 层**:`disabled` + 按钮切换;
16
+ 2. **逻辑层**:`sendChat` 顶部三连判断(空输入 / 未连接或未授权 / 正在生成);
17
+ 3. **键盘层**:Enter 处理里挡掉三件事——生成中、**输入法候选中的回车**(`isComposing`)、`Shift+Enter` 换行。
18
+
19
+ 生成结束后自动把焦点还给输入框,用户不必再点一次。
20
+
21
+ **后端兜底(“打断施法”)**:不能只靠前端。通道在收到新的 `chat` 时,会先中止该连接上
22
+ 所有正在跑的流(`superseded-by-new-chat`),再处理新请求;`llm-stream` 层同样保证
23
+ 「同一个 key 的新流会先中止旧流」。两层都防,才挡得住多端并发与直调 API。
24
+
25
+ ## 打字机光标:三个微状态 + 防换行
26
+
27
+ 光标是「我还活着」的信号,但它有三种形态,混用会误导用户:
28
+
29
+ | 状态 | 何时 | 长什么样 | 来源 |
30
+ |---|---|---|---|
31
+ | `thinking` | 收到 `stream_start`,还没有第一个字 | 三个跳动点 | `cursorState(state)` |
32
+ | `streaming` | 正在吐字 | 竖线紧贴最后一个字符(PM 靛蓝 / PJM 翠绿) | 同上 |
33
+ | `interrupted` | 最近一条消息被中止 | 红色竖线闪两下 + 「已中止生成」 | 同上 |
34
+ | `idle` | 没有生成 | 无 | 同上 |
35
+
36
+ **状态由状态机导出**(`cursorState`),组件不自己推导——否则「有没有第一个字」这类判断到处复制,早晚写歪。
37
+ 测试覆盖四种形态(`test/stream-state.test.mjs`)。
38
+
39
+ **防换行**(经典 bug:光标被挤到下一行开头):不是给光标 `position:absolute`,
40
+ 而是把**最后一个字符 + 光标**包进 `whitespace-nowrap` 行内盒(`StreamingText` 已实现):
41
+
42
+ ```tsx
43
+ const tail = value.slice(-1)
44
+ const head = value.slice(0, -1)
45
+ return <div className="whitespace-pre-wrap break-words">
46
+ {head}<span className="whitespace-nowrap">{tail}<StreamingCursor/></span>
47
+ </div>
48
+ ```
49
+
50
+ **硬闪烁**:Tailwind 的 `animate-pulse` 是呼吸灯,像“加载中”;终端光标要用 `step-end` 阶梯闪烁。
51
+ `styles.css` 提供 `animate-blink` / `animate-ping-once` / `animate-blink-fast`,
52
+ 并且对 `prefers-reduced-motion` 降级为静态光标。
53
+
54
+ **Markdown 场景**:不要往 Markdown 源里拼 `<span>`(解析器会把它当文本或丢到代码块外)。
55
+ 实践是「解析后再插入」:把 `▌` 之类哨兵字符附着在最后一个文本节点上,渲染器命中后替换为光标组件。
56
+
57
+ ## 高频流式下的三层性能保障
58
+
59
+ | 层 | 做法 | 为什么 |
60
+ |---|---|---|
61
+ | 状态层 | `state.history`(引用稳定)+ `state.active`(只有它每 chunk 换对象) | 历史列表订阅 `history`:引用没变就**不重渲染**,也不用遍历 |
62
+ | 渲染层 | `React.memo` + `key=id` | 只有当前气泡 diff;历史组件直接跳过 |
63
+ | 滚动层 | `useAutoScroll`:rAF 合并 + 仅贴底时滚动 + `behavior:'auto'` | 每 chunk 调 `scrollIntoView` 会强制重排;连续 `smooth` 会互相打断 |
64
+
65
+ **只有历史超过约 100 条时才需要虚拟列表**:`VirtualChatFeed.tsx`(依赖 `@tanstack/react-virtual`,
66
+ 需自行安装;插件本体不依赖)。它让 DOM 数量与历史长度解耦,但会牺牲"全部可搜索/可 Ctrl+F"。
67
+
68
+ > 参考测量口径:`history` 引用不变可以直接用 `===` 断言;测试里已经这么做了
69
+ > (`test/stream-state.test.mjs`:30 个 chunk 后 `history === before`)。
70
+
71
+ ## 块级锁(Blocking)在代码里长什么样
72
+
73
+ | 步骤 | 状态机行为 | 测试断言 |
74
+ |---|---|---|
75
+ | 后端流式中发来 `card_choice` | 收尾正在流式的文本(`streaming=false`)、**清空 `activeStreamId`**、插入卡片 | `activeStreamId === null`、前文一字不丢 |
76
+ | 后端要接着说 | 必须重新发 `stream_start`(不会复用旧气泡) | 新消息追加,旧消息引用不变 |
77
+ | 用户点选项 | `answerChoice(id, value, {echo})`:卡片置 `answered`、写 `answer`、追加 `local:true` 用户气泡 | 重复点击返回 `choice-already-answered` |
78
+ | 卡片重复到达 | 同 id 原地更新,不重复插卡 | 消息数不变、内容更新 |
79
+ | 历史消息 | 引用保持不变,`React.memo` 跳过 | `messages[0] === before` |
80
+
81
+ 「AI 说到一半递卡片」不是动画效果,而是状态机里一次原子变更:**光标停在那里,句子不补全、不回退**。
82
+
83
+ ## 极简渲染管线(性能要点)
84
+
85
+ ```text
86
+ WebSocket ──► createChatStream(纯状态机)──► useSyncExternalStore ──► ChatFeed
87
+ ```
88
+
89
+ **关键不是 `map`,而是引用稳定性**:`createChatStream` 每次只替换发生变化的那一条消息对象,
90
+ 历史消息的引用保持不变,所以 `React.memo` 才能真正跳过它们。这一条有测试直接断言:
91
+
92
+ ```js
93
+ chat.apply({ type: 'stream_start', messageId: 'm1', role: 'pjm' })
94
+ for (let i = 0; i < 50; i++) chat.apply({ type: 'stream_chunk', messageId: 'm1', delta: 'x' })
95
+ assert.equal(chat.getState().messages[0], before) // 历史消息 === 原对象
96
+ ```
97
+
98
+ 其他已实现的性能手段:
99
+
100
+ | 手段 | 位置 | 作用 |
101
+ |---|---|---|
102
+ | `applyBatch(messages)` | 状态机 | 多个 chunk 只通知一次;UI 按帧 flush |
103
+ | `subscribe(listener)` | 状态机 | 配合 `useSyncExternalStore`,不把大数组塞进 `useState` |
104
+ | `activeStreamId` / `activeMessage()` | 状态机 | UI 只关心“当前那一条”,不必全量比较 |
105
+ | 内容/条数上限 | 状态机 | 200 条、单条 20 万字符,超长截断并标记 |
106
+ | 光标用纯 CSS | 参考 UI | `animate-pulse`,不额外触发渲染 |
107
+
108
+ `useChatStream.ts` 是这套组合的参考实现(`useSyncExternalStore` + 按帧批量 + outbox 队列)。
109
+
110
+ ## 文件
111
+
112
+ | 文件 | 内容 |
113
+ |---|---|
114
+ | `types.ts` | 客户端/服务端消息类型(与 `src/channel/` 协议对齐) |
115
+ | `useAgentSocket.ts` | WebSocket Hook:指数退避重连、连接期消息队列、调用流式状态机 |
116
+ | `useChatStream.ts` | 极简渲染管线参考:`useSyncExternalStore` + 按帧批量 + 引用稳定 |
117
+ | `useAutoScroll.ts` | 自动贴底:rAF 合并 + 仅贴底滚动(`isNearBottom` 可单测) |
118
+ | `StreamingText.tsx` | 流式文本 + 三态光标(防换行:最后一字与光标同一个 nowrap 盒) |
119
+ | `styles.css` | 硬闪烁 `animate-blink` / 中断 `animate-ping-once` / reduced-motion 降级 |
120
+ | `VirtualChatFeed.tsx` | 可选虚拟列表(`@tanstack/react-virtual`,不随插件发布) |
121
+ | `AgentCommandCenter.tsx` | 主面板:流式打字机、卡片、能力开关(chat=false 时禁用输入) |
122
+ | `MessageComponents.tsx` | 早期独立组件版(保留作参考,主面板已内联渲染) |
123
+ | `demo-server.mjs` | 本地流式演示服务(复用房主已测试的 RFC 6455 编解码) |
124
+
125
+ 流式拼接逻辑**不在**这里重复实现,而是复用房主已发布的纯状态机:
126
+
127
+ ```ts
128
+ import { createChatStream } from '@twinsearth/dsh-master/channel/stream-state'
129
+ ```
130
+
131
+ 它负责:`stream_start` 幂等建气泡、`stream_chunk` 追加(容忍乱序、丢孤儿帧)、
132
+ `stream_end` 收尾光标、`card_*` 打断流并插入卡片、错误变系统消息、消息数与内容长度有界。
133
+
134
+ ## 看效果(不需要真实后端)
135
+
136
+ ```bash
137
+ node docs/examples/command-center/demo-server.mjs
138
+ # [demo] command-center stream at ws://127.0.0.1:51234/dsh-master/ws/command-center?token=demo
139
+ ```
140
+
141
+ 把 `AgentCommandCenter` 的 `channel` 传成 `{ path: '/dsh-master/ws/command-center', token: 'demo' }`、
142
+ `wsBase` 传 `ws://127.0.0.1:51234`,发一句话就能看到:逐字流式回复 → 选择题卡片 → PRD 卡片 → 任务进度卡片。
143
+
144
+ > 演示服务故意把 `capabilities.chat` 设为 `true`(否则 UI 会禁用输入)。
145
+ > 真实宿主目前是 `false`,原因见下。
146
+
147
+ ## 接真实宿主时会发生什么
148
+
149
+ `hello` 会带回:
150
+
151
+ ```json
152
+ { "type": "hello", "protocol": 1,
153
+ "capabilities": { "dualSnapshot": true, "chat": false, "answer": false },
154
+ "limitations": { "chat": "plugin cannot submit a user message into a preset agent session" } }
155
+ ```
156
+
157
+ 所以真实环境下面板显示「只读:chat 能力未开放」,输入框禁用,并给出原因——
158
+ **不会假装在跟 PM 聊天**。要打开它,Host 必须先提供这四样(见 `docs/CHANNEL-WEBSOCKET.md` §4):
159
+
160
+ 1. 按 preset 建/选会话;2. 提交 user message 触发回合;3. 订阅该会话事件流;4. 把问题回答回填到会话。
161
+
162
+ ## 渲染性能说明
163
+
164
+ - 状态机每次返回新对象,React 用 `id` 作 key,只重渲染变化的那条气泡;
165
+ - 长会话有上限(200 条 / 单条 20 万字符),超出截断并显示提示;
166
+ - 自动贴底在 `useEffect` 里做,依赖 `messages` 与连接状态;
167
+ - 流式文本末尾的光标是纯 CSS `animate-pulse`,不额外触发渲染。