@gavinc-cn/touchstone-dsh 0.2.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.
Files changed (116) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +426 -0
  33. package/backend/lib/proc.js +361 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * 路由族:会话内容读(M3 读路径)—— 看板卡片会话与任务会话共用一套 payload 组装
3
+ *
4
+ * 归属条目(顺序 = 匹配顺序,见 `./index.js` 文件头):
5
+ * - `GET /api/projects/<id>/board/session/messages` → `_api_board_session_messages`
6
+ * - `GET /api/projects/<id>/board/session/media/<mid>` → `_api_board_session_media`(字节)
7
+ * - `GET /api/tasks/<id>/session/messages` → `_api_session_messages`
8
+ * - `GET /api/tasks/<id>/session/stream` → `_api_session_stream`(SSE)
9
+ * - `GET /api/tasks/<id>/session/media/<mid>` → `_api_session_media`(字节)
10
+ *
11
+ * 两个 `/board/session/*` 条目在 Python 里属看板族(正则相邻),但实现共用
12
+ * `_session_payload` / `sessparse.resolve_media`,故整族收在本文件。
13
+ *
14
+ * M4.5 追加同一族的**三条任务写端点**(Python 顺序:chat → chat/inject → chat/stop):
15
+ * - `POST /api/tasks/<id>/session/chat` → `_api_session_chat`(4930)
16
+ * - `POST /api/tasks/<id>/session/chat/inject` → `_api_session_chat_inject`(3781)
17
+ * - `POST /api/tasks/<id>/session/chat/stop` → `_api_session_chat_stop`(4994)
18
+ * 看板侧的两条写端点(`/board/session/chat`、`/board/session/inject`,共用 `_inject_msg`)
19
+ * 属 M5。会话的写路径(rewind/profile/answer)同样留待后续里程碑。
20
+ *
21
+ * ## 逐点照抄清单(Python 基准 `server.py`,行号见上)
22
+ * - **两套 `after` 语义不同,别顺手统一**:
23
+ * `/board/session/messages` 是 `int(after or 0)` + `bad after` 400(非法就给 400);
24
+ * `/api/tasks/<id>/session/messages` 是 `int(after) if after.isdigit() else 0`
25
+ * (非法**静默回落 0**);SSE 则是 `?after=` 非数字时回落 `Last-Event-ID`,再回落 0。
26
+ * - **`found:false` 的 payload 没有 `total` 键**(三个提前返回分支):SSE 的 total 回退比较
27
+ * 必须按「无 total」安全处理(Python 原代码在此 `payload["total"]` 直接下标会 KeyError)。
28
+ * - **sid 切换 / total 回退** ⇒ 增量基点归零、用 `after=0` 重取全量再发(同一 tick 内)。
29
+ * - 三帧各自「变了才发」:`meta`(`sort_keys=True` + `ensure_ascii=False`)、`entries`
30
+ * (带 `id: after-1`)、`chat`(`sort_keys=True` + **`ensure_ascii=True`**——两条帧的
31
+ * 转义口径不同,照抄)。空闲累计 20 拍(≈15s)发一次 `: hb`。
32
+ * - `/board/session/*` 的 sid 归属走 `serverstate.boardOwnsSid`(跨项目读的隔离红线)。
33
+ * - 媒体一律 `await sessparse.resolve_media(...)`(M2 评审 P1:Node 的 `dshdriver.media`
34
+ * 是 async,漏 await 会「静默 404 + 未捕获拒绝崩进程」)。
35
+ */
36
+ import { agentFamily } from '../lib/agents.js';
37
+ import * as board from '../lib/board.js';
38
+ import * as chat from '../lib/chat.js';
39
+ import * as dbmod from '../lib/db.js';
40
+ import * as dshdriver from '../lib/dshdriver.js';
41
+ import { DshDriverError } from '../lib/dshdriver.js';
42
+ import * as dshevents from '../lib/dshevents.js';
43
+ import { jsonBody } from '../lib/http.js';
44
+ import { pyStrip } from '../lib/lib.js';
45
+ import { unquote } from '../lib/query.js';
46
+ import * as sessparse from '../lib/sessparse.js';
47
+ import {
48
+ DSH_EFFORT_LEVELS, dshLiveSid, effectiveModel, familyCapabilities, normalizeEffort,
49
+ sessFamily, sessionOwned, sessionPermissionMode,
50
+ } from '../lib/servercaps.js';
51
+ import {
52
+ boardOwnsSid, boardSessionRunning, projectBusy, sessionQueueState,
53
+ taskSince, unitState,
54
+ } from '../lib/serverstate.js';
55
+
56
+ /** `time.sleep(0.75)` 的 Node 形态;`tickMs<=0` 立即返回(单测用) */
57
+ function sleep(ms) {
58
+ if (!(ms > 0)) return Promise.resolve();
59
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
60
+ }
61
+
62
+ /** Python `int(s)`(`s` 来自 parse_qs 的字符串):空白/正负号/下划线可接受,其余抛错 */
63
+ function pyIntStrict(value) {
64
+ if (typeof value === 'number') {
65
+ if (!Number.isFinite(value)) throw new Error('bad after');
66
+ return Math.trunc(value);
67
+ }
68
+ const text = String(value ?? '').trim();
69
+ if (!/^[+-]?\d(?:_?\d)*$/.test(text)) throw new Error('bad after');
70
+ return Number(text.replaceAll('_', ''));
71
+ }
72
+
73
+ /** Python `str.isdigit()`(平台参数只可能是 ASCII 数字;Unicode 数字见报告「未决」) */
74
+ function pyIsDigit(text) {
75
+ return /^\d+$/.test(String(text ?? ''));
76
+ }
77
+
78
+ /** Python `json.dumps(..., sort_keys=True)` 的键序(递归;仅键序,不改值) */
79
+ function sortKeysDeep(value) {
80
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
81
+ if (value !== null && typeof value === 'object') {
82
+ const out = {};
83
+ for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
84
+ return out;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ /**
90
+ * `json.dumps(..., ensure_ascii=True)` 的转义口径:非 ASCII 码点一律 `\uXXXX`
91
+ * (非 BMP 出**两个**转义,与 Python 同)。输入必须是已按 Python 口径序列化的 JSON 文本
92
+ * (`jsonBody`),非 ASCII 只会出现在字符串字面量里,故整段替换是安全的。
93
+ */
94
+ function asciiEscape(text) {
95
+ let out = '';
96
+ for (const ch of text) {
97
+ const cp = ch.codePointAt(0);
98
+ if (cp < 0x80) { out += ch; continue; }
99
+ if (cp > 0xffff) {
100
+ const v = cp - 0x10000;
101
+ out += `\\u${(0xd800 + (v >> 10)).toString(16).padStart(4, '0')}`
102
+ + `\\u${(0xdc00 + (v & 0x3ff)).toString(16).padStart(4, '0')}`;
103
+ } else {
104
+ out += `\\u${cp.toString(16).padStart(4, '0')}`;
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * `_session_context(task_id)`:session 接口公共上下文(任务行 / 项目行 / agent 归族)。
112
+ *
113
+ * 与 Python 的差异只在**响应时机**:Python 先 `self._owned_task`(内含 `_owned_project`,
114
+ * 越权时已回一次 404)再由调用方回 `task not found`;Node 的 `ctx.ownedTask` 已经把
115
+ * 「不存在 → 404 task not found / 不属于本人 → 404 project not found」收口为**一次**响应
116
+ * (见 `./index.js` 文件头契约),故这里返回 null 时调用方**不再**回 404。
117
+ *
118
+ * @returns {Promise<{task: object|null, project: object|null, family: string|null}>}
119
+ */
120
+ async function sessionContext(ctx, taskId) {
121
+ const task = ctx.ownedTask(taskId);
122
+ if (task === null) return { task: null, project: null, family: null };
123
+ const project = dbmod.getProject(task.project_id, { db: ctx.db });
124
+ const family = project ? agentFamily(project.agent_path) : 'dsh_plugin';
125
+ return { task, project, family };
126
+ }
127
+
128
+ /** `_owned_task(task_id)` 的**无副作用**版(SSE 循环每拍重读任务行用,不回 404) */
129
+ function readTaskQuiet(ctx, taskId) {
130
+ const task = dbmod.getTask(taskId, { db: ctx.db });
131
+ if (task === null || task === undefined) return null;
132
+ const project = dbmod.getProject(task.project_id, { db: ctx.db });
133
+ if (project === null || project === undefined || project.user_id !== ctx.user.id) return null;
134
+ return task;
135
+ }
136
+
137
+ /**
138
+ * `_session_payload(task, project, family, agent, after)`:组装 session messages 主体。
139
+ *
140
+ * 任务行 `session_id` 为空(首轮 agent 未退出、`resume_hint` 尚未解析)时按工作区 +
141
+ * 任务起始时间探测正在写的会话(仅展示,见 `sessparse.live_session_id`);dsh_plugin
142
+ * 另有精确通道 `dshLiveSid`(驱动池自持会话,不靠 mtime 猜)。
143
+ *
144
+ * 四个提前返回:family 不在 sessparse 白名单 / 无 sid / `load` 未 found —— 这几种
145
+ * payload **没有 `total` 键**(SSE 侧据此安全处理,见文件头)。
146
+ *
147
+ * **async**:`dshLiveSid` 在 Node 是 async(Python 侧同步),调用方一律 await。
148
+ */
149
+ export async function sessionPayload(ctx, task, project, family, agent, after) {
150
+ let sid = task.session_id || '';
151
+ // dsh_plugin 的会话即 dsh 宿主会话,sessparse 调用统一经 sessFamily 归一
152
+ const sfamily = sessFamily(family);
153
+ if (!sid && family === 'dsh_plugin') {
154
+ sid = await dshLiveSid(project ? project.project_dir : '');
155
+ }
156
+ if (!sid) {
157
+ sid = sessparse.live_session_id(sfamily, project ? project.project_dir : '', taskSince(task));
158
+ }
159
+ const base = {
160
+ task_id: task.id,
161
+ session_id: sid,
162
+ family,
163
+ model: effectiveModel(task.id, task.model, { db: ctx.db }),
164
+ capabilities: familyCapabilities(family),
165
+ project_busy: project ? projectBusy(project.id, { db: ctx.db }) : false,
166
+ // M4:`chat.state` 接线 `_CHATS` 登记表后**整体 async**(`dshBusy` 是远端读,Node 无同步
167
+ // HTTP)——`await` 一处不能漏,否则 Promise 会被静默序列化成 `{}`(M3 文件头边界①的记账)。
168
+ chat: await chat.state(sid, { db: ctx.db }),
169
+ };
170
+ // 会话级思考等级(会话窗「思考等级」控件):事件流里学到的宿主 reasoningEffort;
171
+ // 没有就不下发——前端按「该模型的默认档」渲染,不猜。
172
+ if (sid && family === 'dsh_plugin') {
173
+ const effort = dshevents.sessionEffort(sid);
174
+ if (effort) base.sessionEffort = effort;
175
+ }
176
+ // 统一队列态(会话窗标题「队列」徽标):本任务单元此刻排队/运行/空闲。
177
+ // 探到正在写的会话(sid 未入库)时 task_id 仍是准确的队列键,照常下发。
178
+ base.unit_state = unitState(project ? project.id : 0, `t:${task.id}`, { db: ctx.db });
179
+ // 任务会话无卡:answer_pending/server_queued 不在场,退化为四态
180
+ base.queue_state = sessionQueueState(project ? project.id : 0, base.unit_state, {}, { db: ctx.db });
181
+ if (!sessparse.FAMILIES.includes(sfamily)) {
182
+ return { ...base, found: false, reason: 'family' };
183
+ }
184
+ if (!sid) {
185
+ return { ...base, found: false, reason: 'no_session' };
186
+ }
187
+ const data = sessparse.load(sfamily, sid, agent, after);
188
+ if (!data.found) {
189
+ return {
190
+ ...base,
191
+ found: false,
192
+ reason: data.reason === undefined ? 'missing' : data.reason,
193
+ agents: data.agents === undefined ? [] : data.agents,
194
+ agent: data.agent === undefined ? agent : data.agent,
195
+ };
196
+ }
197
+ return { ...base, ...data };
198
+ }
199
+
200
+ /**
201
+ * `_api_board_session_messages(project_id, parsed)`:看板卡片会话只读查看。
202
+ *
203
+ * 405 之外的错误分支(顺序与 Python 同):项目不归本人 404 → `bad after` 400 →
204
+ * `sid required` 400 → sid 不归属本项目卡片 404 `session not found`。
205
+ */
206
+ export async function boardSessionMessages(ctx, projectId) {
207
+ const row = ctx.ownedProject(projectId);
208
+ if (row === null) return;
209
+ const sid = ctx.q('sid', '');
210
+ let after;
211
+ try {
212
+ after = pyIntStrict(ctx.q('after', '0') || 0);
213
+ } catch {
214
+ ctx.error(400, 'bad after');
215
+ return;
216
+ }
217
+ if (!sid) {
218
+ ctx.error(400, 'sid required');
219
+ return;
220
+ }
221
+ const options = { db: ctx.db };
222
+ // sid 必须落在本项目全部卡片的会话并集内(防跨项目读任意会话)
223
+ const owner = boardOwnsSid(row, sid, options);
224
+ if (owner === null) {
225
+ ctx.error(404, 'session not found');
226
+ return;
227
+ }
228
+ const ownerCid = owner.id;
229
+ const family = agentFamily(row.agent_path);
230
+ const agent = ctx.q('agent', 'main') || 'main';
231
+ const data = sessparse.load(sessFamily(family), sid, agent, after);
232
+ if (data && typeof data === 'object') {
233
+ data.session_id = sid; // 前端头部徽标展示用(load 结果不含 sid)
234
+ const running = boardSessionRunning(ownerCid, row, sid, options);
235
+ data.capabilities = familyCapabilities(family);
236
+ data.running = running;
237
+ // chat 态 = 卡片会话 busy(含外部直跑)+ 排队/失败消息(统一队列消息单元)
238
+ const chatSt = await chat.state(sid, options);
239
+ chatSt.running = running || Boolean(chatSt.running);
240
+ data.chat = chatSt;
241
+ data.project_busy = projectBusy(projectId, options);
242
+ // 统一队列态(会话窗标题「队列」徽标):本卡片单元此刻排队/运行/空闲
243
+ data.unit_state = unitState(row.id, `c:${ownerCid}`, options);
244
+ data.card_column = owner.column_key;
245
+ data.card_worktree = String(dbmod.rowOpt(owner, 'worktree', '') || '').trim();
246
+ data.answer_pending = board.isAnswerPending(ownerCid, options);
247
+ // 待答交互(会话窗问答对话框的数据源):读**调和器缓存** `board._IW_CACHE`
248
+ // —— 由 `board.iwOnce` 每轮从 dsh 状态帧写;缓存为空/无等待时为 null。
249
+ // 这里曾经误用 `serverstate.interactionOfSid`(M3 的「空实例」占位,恒 null),
250
+ // 导致 Node 后端下会话窗永远不出现提问/审批卡片(用户报障:卡 980)。
251
+ data.interaction = board.interactionOfSid(sid);
252
+ data.owned = sessionOwned(sid); // 三态:true 池内 / false 外部 / null 未知
253
+ data.family = family;
254
+ if (family === 'dsh_plugin') {
255
+ const st = dshevents.get(sid);
256
+ // 会话级模型:事件流里学到就用它,否则留空由前端回落「项目默认模型」
257
+ const sel = (st || {}).model || {};
258
+ if (sel.model) {
259
+ data.sessionModel = sel.provider ? `${sel.provider}/${sel.model}` : sel.model;
260
+ }
261
+ const pmode = sessionPermissionMode(st);
262
+ if (pmode) data.permission = pmode;
263
+ const effort = dshevents.sessionEffort(sid);
264
+ if (effort) data.sessionEffort = effort;
265
+ // 宿主 inbox 排队行:只展示、不提供「立即注入」(dsh 行没有平台 msg_id)
266
+ data.queue = ((st || {}).inbox || []).map((msg) => ({
267
+ id: String(msg.id || ''),
268
+ text: String(msg.text || ''),
269
+ }));
270
+ }
271
+ data.queue_state = sessionQueueState(projectId, data.unit_state, {
272
+ running,
273
+ answerPending: Boolean(data.answer_pending),
274
+ serverQueued: Boolean(data.queue && data.queue.length),
275
+ starting: board.cardStarting(ownerCid, options),
276
+ interactionPending: owner.block_kind === 'interaction',
277
+ }, options);
278
+ }
279
+ ctx.json(200, data);
280
+ }
281
+
282
+ /**
283
+ * `_api_board_session_media(project_id, media_id, parsed)`:看板卡片会话图片字节
284
+ * (board 会话窗无 taskId,图片经「项目 + sid」寻址;归属口径同上)。
285
+ */
286
+ export async function boardSessionMedia(ctx, projectId, mediaId) {
287
+ const row = ctx.ownedProject(projectId);
288
+ if (row === null) return;
289
+ const sid = ctx.q('sid', '');
290
+ if (!sid) {
291
+ ctx.error(400, 'sid required');
292
+ return;
293
+ }
294
+ if (boardOwnsSid(row, sid, { db: ctx.db }) === null) {
295
+ ctx.error(404, 'session not found');
296
+ return;
297
+ }
298
+ const agent = ctx.q('agent', 'main') || 'main';
299
+ const resolved = await sessparse.resolve_media(
300
+ sessFamily(agentFamily(row.agent_path)), sid, agent, unquote(mediaId));
301
+ if (resolved === null) {
302
+ ctx.error(404, 'media not found');
303
+ return;
304
+ }
305
+ ctx.text(200, resolved[0], resolved[1]);
306
+ }
307
+
308
+ /**
309
+ * `_api_session_messages(task_id, parsed)`:session 原始对话内容(一次性拉取;
310
+ * `?agent=&after=` 增量)。
311
+ *
312
+ * 注意 `after` 语义:**非数字静默回落 0**(与 `/board/session/messages` 的 400 不同)。
313
+ */
314
+ export async function taskSessionMessages(ctx, taskId) {
315
+ const { task, project, family } = await sessionContext(ctx, taskId);
316
+ if (task === null) return;
317
+ const agent = ctx.q('agent', 'main');
318
+ const afterS = ctx.q('after', '0');
319
+ const after = pyIsDigit(afterS) ? Number(afterS) : 0;
320
+ const payload = await sessionPayload(ctx, task, project, family, agent, after);
321
+ ctx.json(200, payload);
322
+ }
323
+
324
+ /**
325
+ * SSE **首拍游标**解析(`_api_session_stream` 开头的 after 计算):
326
+ * `?after=<数字>` 优先;否则 `Last-Event-ID + 1`;都不是则 0。
327
+ *
328
+ * ⚠️ 注意 Python 的一个**反直觉但真实**的行为:循环里 `cur_sid` 初值是 `""`,
329
+ * 而首拍 `sid_now` 非空 ⇒ 首拍必定走「sid 切换」分支把 `after` 归一化为 0 并重取全量。
330
+ * 也就是说 `?after=` / `Last-Event-ID` 只影响首拍那次**被丢弃**的请求,帧序列里
331
+ * **看不到**增量(客户端凭 `id:`/`meta.total` 自行去重——Python 注释原文「已乐观截断的
332
+ * 客户端按 seq 过滤不会重复追加」)。本函数照抄该口径,别「顺手修好」而破坏对拍。
333
+ */
334
+ export function streamStartAfter(ctx) {
335
+ const afterS = ctx.q('after', '');
336
+ if (pyIsDigit(afterS)) return Number(afterS);
337
+ // EventSource 自动重连时带 Last-Event-ID(上次末条 seq),从其后继续
338
+ const lid = String((ctx.req && ctx.req.headers && ctx.req.headers['last-event-id']) || '');
339
+ return pyIsDigit(lid) ? Number(lid) + 1 : 0;
340
+ }
341
+
342
+ /**
343
+ * `_api_session_stream(task_id, parsed)`:session SSE。
344
+ *
345
+ * 帧与续传逐点照抄(见文件头),循环体本身见 `runSessionStream`(可注入 tick/停止条件,
346
+ * 便于单测;缺省 750ms 一拍)。
347
+ */
348
+ export async function taskSessionStream(ctx, taskId, loopOpts = {}) {
349
+ const { task, project, family } = await sessionContext(ctx, taskId);
350
+ if (task === null) return;
351
+ const agent = ctx.q('agent', 'main');
352
+ const after = streamStartAfter(ctx);
353
+ // 会话流**不带** X-Accel-Buffering(只有看板两条流带,见 lib/http.js)。
354
+ // 头部在 after 解析之后再写,与 Python 的先后一致(404 分支已在上面的 context 里返回)。
355
+ const sse = ctx.sse({});
356
+ await runSessionStream(ctx, taskId, { sse, agent, after, task, project, family, ...loopOpts });
357
+ }
358
+
359
+ /**
360
+ * SSE 主循环(`_api_session_stream` 的循环体)。
361
+ *
362
+ * 每拍:重读任务行(轮后写入的 `session_id` 对已打开窗口立即生效)→ 组装 payload →
363
+ * sid 切换或 total 回退时归零重发 → meta/entries/chat 三帧各自「变了才发」→ 空闲 20 拍心跳。
364
+ *
365
+ * @param {object} ctx 请求上下文(需要 `db`/`user`/`req`/`res`)
366
+ * @param {number} taskId
367
+ * @param {{sse:object, agent:string, after:number, task:object, project:object|null,
368
+ * family:string, tickMs?:number, maxIdleTicks?:number, maxTicks?:number,
369
+ * shouldStop?:() => boolean, sessionPayload?:Function}} opts
370
+ * 单测用 `maxTicks`/`tickMs` 驱动固定拍数;`sessionPayload` 是**注入缝**——用于
371
+ * 构造「total 回退」「sid 切换」这类真文件里难以稳定复现的时序。
372
+ */
373
+ export async function runSessionStream(ctx, taskId, opts) {
374
+ const { sse, agent, project, family } = opts;
375
+ const tickMs = opts.tickMs === undefined ? 750 : opts.tickMs;
376
+ const maxIdleTicks = opts.maxIdleTicks === undefined ? 20 : opts.maxIdleTicks;
377
+ const maxTicks = opts.maxTicks === undefined ? Number.POSITIVE_INFINITY : opts.maxTicks;
378
+ let task = opts.task;
379
+ let after = opts.after || 0;
380
+ let curSid = '';
381
+ let lastMeta = null;
382
+ let lastChat = null;
383
+ let idleTicks = 0;
384
+ let closed = false;
385
+ if (ctx.req && typeof ctx.req.on === 'function') ctx.req.on('close', () => { closed = true; });
386
+ if (ctx.res && typeof ctx.res.on === 'function') ctx.res.on('close', () => { closed = true; });
387
+
388
+ const getPayload = typeof opts.sessionPayload === 'function' ? opts.sessionPayload : sessionPayload;
389
+ for (let tick = 0; tick < maxTicks; tick += 1) {
390
+ if (closed) break;
391
+ if (typeof opts.shouldStop === 'function' && opts.shouldStop()) break;
392
+ // 重读任务行:轮次结束把 session_id 写库后,本连接立刻感知
393
+ const fresh = readTaskQuiet(ctx, taskId);
394
+ if (fresh !== null) task = fresh;
395
+ let payload = await getPayload(ctx, task, project, family, agent, after);
396
+ let entries = Array.isArray(payload.entries) ? payload.entries : [];
397
+ const sidNow = payload.session_id || '';
398
+ // found=false 路径 payload **没有 total 键** ⇒ 不参与回退比较(Python 曾在此 KeyError)
399
+ const totalNow = payload.total;
400
+ if (sidNow !== curSid) {
401
+ // 会话对象切换(探测 sid 出现/被真实 sid 纠正):增量基点归零、重取全量
402
+ curSid = sidNow;
403
+ after = 0;
404
+ payload = await getPayload(ctx, task, project, family, agent, 0);
405
+ entries = Array.isArray(payload.entries) ? payload.entries : [];
406
+ } else if (totalNow !== undefined && totalNow !== null && totalNow < after) {
407
+ // 会话被回退/截断(entries 变短):增量基点归零重发全量,否则游标卡在旧长度
408
+ after = 0;
409
+ payload = await getPayload(ctx, task, project, family, agent, 0);
410
+ entries = Array.isArray(payload.entries) ? payload.entries : [];
411
+ }
412
+ if (entries.length) after = payload.total;
413
+
414
+ // meta:entries/chat 之外的展示信息(found/agents/totals/model/session_id 等)
415
+ const meta = {};
416
+ for (const [key, value] of Object.entries(payload)) {
417
+ if (key !== 'entries' && key !== 'chat') meta[key] = value;
418
+ }
419
+ const metaS = jsonBody(sortKeysDeep(meta)); // Python: ensure_ascii=False, sort_keys=True
420
+ let pushed = false;
421
+ if (metaS !== lastMeta) {
422
+ lastMeta = metaS;
423
+ if (!sse.raw(`event: meta\ndata: ${metaS}\n\n`)) break;
424
+ pushed = true;
425
+ }
426
+ if (entries.length) {
427
+ const body = jsonBody({ entries, total: payload.total }); // Python: ensure_ascii=False
428
+ if (!sse.raw(`id: ${after - 1}\nevent: entries\ndata: ${body}\n\n`)) break;
429
+ pushed = true;
430
+ }
431
+ // chat:Python 这一帧**不带** ensure_ascii=False ⇒ 非 ASCII 走 \uXXXX 转义
432
+ const chatS = asciiEscape(jsonBody(sortKeysDeep(payload.chat || {})));
433
+ if (chatS !== lastChat) {
434
+ lastChat = chatS;
435
+ if (!sse.raw(`event: chat\ndata: ${chatS}\n\n`)) break;
436
+ pushed = true;
437
+ }
438
+ idleTicks = pushed ? 0 : idleTicks + 1;
439
+ if (idleTicks >= maxIdleTicks) { // ~15s 一次心跳,防中间层断连
440
+ idleTicks = 0;
441
+ if (!sse.raw(': hb\n\n')) break;
442
+ }
443
+ await sleep(tickMs);
444
+ }
445
+ sse.end();
446
+ }
447
+
448
+ /** `_api_session_media(task_id, media_id, parsed)`:session 图片字节(驱动 /media 取字节) */
449
+ export async function taskSessionMedia(ctx, taskId, mediaId) {
450
+ const { task, family } = await sessionContext(ctx, taskId);
451
+ if (task === null) return;
452
+ const agent = ctx.q('agent', 'main');
453
+ const resolved = await sessparse.resolve_media(
454
+ sessFamily(family), task.session_id || '', agent, unquote(mediaId));
455
+ if (resolved === null) {
456
+ ctx.error(404, 'media not found');
457
+ return;
458
+ }
459
+ ctx.text(200, resolved[0], resolved[1]);
460
+ }
461
+
462
+ // --------------------------------------------------------------------------
463
+ // M4.5 会话写路径:chat / chat/inject / chat/stop(`sessions.js` 的写侧)
464
+ //
465
+ // 三条端点的读体时机、分支顺序与错误体形态**逐处照抄 `server.py`**(行号见文件头);
466
+ // 三个易被「顺手优化」掉的坑,改前必读:
467
+ // ① `_api_session_chat` 的 except 顺序是 DshDriverError → OSError → **裸 RuntimeError**。
468
+ // 最后一档就是投递前置闸 `chat.DeliveryRefused` 的落点——它必须排在 DshDriverError
469
+ // **之后**(Python 里两者同为 RuntimeError 子类,顺序反了 409/500 会被 400 抢走);
470
+ // Node 侧两者不同类(判据一律 `instanceof`),顺序仍照抄,免得两版读起来不一样。
471
+ // ② 校验顺序:message 空 → message 超长 → 族白名单 → sid 空(四道闸的先后即文案优先级)。
472
+ // ③ `_inject_msg` 的归属三重判定(msg_id 不存在 / sid 不符 / project_id 不符 /
473
+ // 任务路径还要 task_id 相符)都回同一个 404 文案——**不区分**是为了不泄露
474
+ // 「这条消息存在但不属于你」。
475
+ // ④ 写口传 `{db: ctx.db}`(与读口一致):代价是 `withWriteDb({db})` **不开事务**,
476
+ // 多语句写口(`waitq.msg_enqueue` 的消息行+等待项行)在崩溃窗口可能半提交,而 Python
477
+ // 是「一个事务内」写两行(waitq.py:1181/1195-1209)。终态在正常路径下相同。
478
+ // **为什么不改成不传**:① `{db}` 是本库「加入调用方事务」的惯用法(`cancelAllActive`
479
+ // 等循环依赖它,改成无条件 BEGIN 会撞嵌套);② 不传时写口会按 `defaultDbPath()` 自开
480
+ // 连接 —— 单测夹具靠 `{db}` 钉临时库,不传就会写**生产库**(M4.5 实测踩中,见实施记录 §3.7)。
481
+ // 正解是给 withWriteDb 加独立选项 `{db, tx: true}` 并逐点判定,属 M5/M6 重构项。
482
+ // --------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Python `bool(x)`:空串/0/None/False/**空容器**为假。
486
+ *
487
+ * 为什么不能直接用 `Boolean`:JS 的 `[]`/`{}` 是 truthy,而 Python 的 `bool([])`/`bool({})`
488
+ * 是 False——本文件两处判据(`body.get("inject")`、`str(x or "")`)都吃这个口径。
489
+ */
490
+ function pyTruthy(value) {
491
+ if (Array.isArray(value)) return value.length > 0;
492
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
493
+ return Boolean(value);
494
+ }
495
+
496
+ /**
497
+ * Python `str(x or "")`(`_api_session_chat` 的 message 与 `_inject_msg` 的 msg_id 共用)。
498
+ *
499
+ * 假值先折成空串再转文本;容器走 JSON 文本兜底——Python 的 `str(dict)` 是 repr 形态,
500
+ * 这里不逐字复刻(与 `sessparse` 的私有 `pyStr` 同一条已知差异:真请求里这两个字段
501
+ * 都是字符串)。
502
+ */
503
+ function pyStrOrEmpty(value) {
504
+ if (!pyTruthy(value)) return '';
505
+ if (typeof value === 'string') return value;
506
+ if (typeof value === 'number') return String(value);
507
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
508
+ return JSON.stringify(value);
509
+ }
510
+
511
+ /**
512
+ * Python `len(str)`:**按码点**计数。
513
+ *
514
+ * JS 的 `.length` 是 UTF-16 码元(emoji 等代理对算 2)——直接用它会让含 emoji 的长消息
515
+ * 在 `len(message) > MESSAGE_MAX` 这条判据上提前 400,而 Python 侧放行。
516
+ */
517
+ function pyLen(text) {
518
+ return [...text].length;
519
+ }
520
+
521
+ /**
522
+ * Python `str(e)`:异常 → 可进响应正文的文本(与 `chat.js` 私有的 `errText` 同口径,
523
+ * 那份没导出,故此处复刻一份)。
524
+ *
525
+ * `DshDriverError` 覆写了 `toString()`(纯消息);普通 `Error` 取 `.message`——直接
526
+ * `${err}` 会拼出 `Error: xxx`,而 Python 的 `str(RuntimeError('xxx'))` 是 `xxx`。
527
+ */
528
+ function pyStrErr(err) {
529
+ if (err === null || err === undefined) return String(err);
530
+ if (err instanceof DshDriverError) return String(err);
531
+ if (err instanceof Error) return err.message;
532
+ return String(err);
533
+ }
534
+
535
+ /**
536
+ * 是不是 Python `OSError` 那一档(`_api_session_chat` 的第二个 except ⇒ 500)。
537
+ *
538
+ * Node 没有 OSError 类:文件/管道层失败一律带 `code`(ENOENT/EACCES/EPIPE…)+ `syscall`
539
+ * (open/write/kill)。判据取 **`syscall` 在场**——比裸 `code` 窄(`DshDriverError.code`
540
+ * 是数字、业务错误不带 syscall),把 Node 的 fs 失败映射到 Python 的 `except OSError`。
541
+ * 可达面:无 runner 单例时 `sendNow → dshSendNow` 的 `fs.appendFileSync` 写日志失败。
542
+ */
543
+ function isOsError(err) {
544
+ return err instanceof Error && typeof err.syscall === 'string' && err.syscall !== '';
545
+ }
546
+
547
+ /**
548
+ * `_api_session_chat(task_id, body)`:向 session 续发一条消息(登记统一队列消息单元,
549
+ * 由 runner 按项目串行执行)。
550
+ *
551
+ * 项目忙(同项目有任务/卡片会话在跑)时**不再 409 拒绝**,改为排队:消息作为统一队列
552
+ * 第三类单元(`m:<msg_id>`),项目空闲才真正发送;响应 `queued=true` 表示本次未立即执行
553
+ * (前端据此显示「排队中」)。
554
+ *
555
+ * **读体**:Python 在 `do_POST:1048` 统一读体(三条写端点同一时机),故第一行就 `readBody`。
556
+ * 校验与异常顺序见本段文件头 ①②。
557
+ *
558
+ * `bodyOverride`(M6-6 新增,缺省 null):`_api_load_diagnose`(4616) 在 Python 里调的是
559
+ * `self._api_session_chat(task_id, {"message": text})` —— 传的是**合成 body**,不是请求体。
560
+ * 只有这一处调用点需要它,故做成可选形参而不是第二份实现(免得两处校验逻辑漂移)。
561
+ */
562
+ export async function taskSessionChat(ctx, taskId, bodyOverride = null) {
563
+ const body = bodyOverride === null ? await ctx.readBody() : bodyOverride;
564
+ // 无响应副作用(= Python `_session_context`):三空时由本函数回 404 `task not found`
565
+ const { task, project, family } = ctx.sessionContext(taskId);
566
+ if (task === null) {
567
+ ctx.error(404, 'task not found');
568
+ return;
569
+ }
570
+ // Python `str(body.get("message") or "").strip()`(假值先折空串,再按 Python 空白集剥两端)
571
+ const message = pyStrip(pyStrOrEmpty(body.message));
572
+ if (!message) {
573
+ ctx.error(400, 'message 不能为空'); // 硬编码字节串 ⇒ 紧凑
574
+ return;
575
+ }
576
+ if (pyLen(message) > chat.MESSAGE_MAX) {
577
+ ctx.error(400, 'message 过长'); // 紧凑
578
+ return;
579
+ }
580
+ if (!chat.CHAT_FAMILIES.includes(family)) {
581
+ ctx.error(409, '该 agent 暂不支持对话'); // 紧凑
582
+ return;
583
+ }
584
+ const sid = task.session_id || '';
585
+ if (!sid) {
586
+ ctx.error(409, '会话尚未生成(首轮执行后才有)'); // 紧凑
587
+ return;
588
+ }
589
+ let rec;
590
+ try {
591
+ rec = await chat.start(task, project, message, pyTruthy(body.inject), { db: ctx.db });
592
+ } catch (err) {
593
+ // ① DshDriverError:409 = 会话运行中(chat 侧的忙拒绝,竞态兜底);其余按 500。
594
+ // **必须排在最前**——见本段文件头①。
595
+ if (err instanceof DshDriverError) {
596
+ if (err.code === 409) {
597
+ // Python `e.args[1] if len(e.args) > 1 else "会话运行中,稍后重试"`:Node 的
598
+ // `new Error()` 把 message 定为 `''`,「只传 code」与「传了空消息」无法区分;
599
+ // dshdriver 的构造点都带非空消息 ⇒ 空串按「无第二参」回兜底文案。
600
+ ctx.errorSpaced(409, err.message ? String(err.message) : '会话运行中,稍后重试');
601
+ } else {
602
+ ctx.errorSpaced(500, `agent 调用失败: ${pyStrErr(err)}`);
603
+ }
604
+ return;
605
+ }
606
+ // ② OSError(Node 的系统错误)⇒ 500,同一文案
607
+ if (isOsError(err)) {
608
+ ctx.errorSpaced(500, `agent 调用失败: ${pyStrErr(err)}`);
609
+ return;
610
+ }
611
+ // ③ 裸 RuntimeError = 投递前置闸 `chat.DeliveryRefused`(外部会话前提不成立):
612
+ // 消息未入队、未落行 ⇒ 400 + 闸的中文文案,让前端能如实 toast。
613
+ if (err instanceof chat.DeliveryRefused) {
614
+ ctx.errorSpaced(400, pyStrErr(err));
615
+ return;
616
+ }
617
+ // 其余(Python 里不是 RuntimeError/OSError 的异常)逃出 do_POST ⇒ 只断这条连接;
618
+ // Node 侧同理:上抛给 dispatchSafely(它 destroy 这条连接、不补 500)。
619
+ throw err;
620
+ }
621
+ // 成功体是 `json.dumps(..., ensure_ascii=False)`:带空格、键序即下文字面序
622
+ ctx.json(200, {
623
+ ok: true,
624
+ queued: Boolean(rec.queued),
625
+ msg_id: rec.id || '',
626
+ state: rec.state || '',
627
+ // `chat.submit` 的返回记录本就不含 prompt_id(Python 同)⇒ 恒空串,照抄这一键
628
+ prompt_id: rec.prompt_id || '',
629
+ });
630
+ }
631
+
632
+ /**
633
+ * `_api_session_chat_inject(task_id, body)`:把**任务会话**排队中的平台消息立即注入
634
+ * (不等统一队列;dsh_plugin 族)。
635
+ */
636
+ export async function taskSessionChatInject(ctx, taskId) {
637
+ const body = await ctx.readBody();
638
+ const { task, project } = ctx.sessionContext(taskId);
639
+ if (task === null) {
640
+ ctx.error(404, 'task not found'); // 紧凑
641
+ return;
642
+ }
643
+ const sid = task.session_id || '';
644
+ if (!sid) {
645
+ ctx.error(409, '会话尚未生成(首轮执行后才有)'); // 紧凑
646
+ return;
647
+ }
648
+ await injectMsg(ctx, project, sid, body, taskId);
649
+ }
650
+
651
+ /**
652
+ * `_inject_msg(project, sid, body, task_id=None)`:平台排队消息「立即注入」的共用实现
653
+ * (看板/任务两路;归属与族校验)。
654
+ *
655
+ * 本端点处理**平台统一队列**里的消息单元(项目忙时排队的消息)——撤销排队并立即投递到
656
+ * 会话(有运行中的轮次则注入当前轮,空闲则立即起轮),不再等项目空闲。投递失败消息退回
657
+ * 队列(见 `chat.injectNow`),响应 502 供前端 toast。
658
+ *
659
+ * 看板侧的 `_api_board_session_inject`(3770)走同一实现,M5 接线时把 project 行换成
660
+ * 卡片所属项目行、`taskId` 留 null 即可(归属三重判定里的 task 一项随之跳过)
661
+ * ——M5-4 已按此接线(`routes/board.js:apiBoardSessionInject` 直接 `await injectMsg(...)`)。
662
+ *
663
+ * @param {object} ctx 请求上下文
664
+ * @param {object} project 项目行(消费 `agent_path`/`id`)
665
+ * @param {string} sid 会话 id(调用方已保证非空)
666
+ * @param {object} body 请求体(消费 `msg_id`)
667
+ * @param {number|null} [taskId] 任务路径传入:`msg_id` 的 task_id 必须相符
668
+ */
669
+ export async function injectMsg(ctx, project, sid, body, taskId = null) {
670
+ // Python `runner.agent_family`(runner.py:45 是 re-export)——单族世界:只有 dsh_plugin 可注入
671
+ const fam = agentFamily(project.agent_path);
672
+ if (fam !== 'dsh_plugin') {
673
+ ctx.errorSpaced(400, '该 agent 族不支持立即注入');
674
+ return;
675
+ }
676
+ const info = chat.msgInfo(pyStrOrEmpty(body.msg_id), { db: ctx.db });
677
+ // 归属三重判定(+任务第四重):不命中就同一个 404 文案,别细分(见本段文件头③)
678
+ if (info === null || info.sid !== sid || info.project_id !== project.id
679
+ || (taskId !== null && info.task_id !== taskId)) {
680
+ ctx.errorSpaced(404, '消息不存在或不在该会话下');
681
+ return;
682
+ }
683
+ try {
684
+ await chat.injectNow(info.id, { db: ctx.db });
685
+ } catch (err) {
686
+ // 被拒(已被 worker 拾起/不可注入):消息仍在队列,不改状态 ⇒ 400
687
+ if (err instanceof chat.InjectRefused) {
688
+ ctx.errorSpaced(400, pyStrErr(err));
689
+ return;
690
+ }
691
+ // 投递失败:消息已按原位次退回排队(chat.injectNow 内),此处只报错 ⇒ 502
692
+ ctx.errorSpaced(502, `注入失败:${pyStrErr(err)}`);
693
+ return;
694
+ }
695
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8'); // 硬编码字节串 ⇒ 紧凑
696
+ }
697
+
698
+ /**
699
+ * `_api_session_chat_stop(task_id)`:停止进行中的对话进程(同时取消该会话排队中的消息)。
700
+ *
701
+ * Python 的方法签名**不吃 body**(`do_POST` 分派成 `_api_session_chat_stop(int(...))`),
702
+ * 读体仍发生在 `do_POST:1048` 那一次;这里照同一时机把体读掉——不读的话,keep-alive
703
+ * 连接上的下一个请求会撞上未消费的正文(Python 侧必然已读)。
704
+ *
705
+ * `chat.stop` 自 M4 起是 **async**(要 await 驱动的 /cancel):漏 await 会让「停止」在
706
+ * 响应之后才生效——`cancelQueued` 的落库态与驱动的中断调用都会晚一拍。
707
+ *
708
+ * **2026-10-17 加 `mode`**(设计《双通道继续与停止同效》§4.2(d)、本批只落平台侧):
709
+ * body `{"mode":"cancel_queue"}` ⇒ 只取消排队消息、**不碰轮次**(走 `cancelQueueAction`,
710
+ * 响应回带被取消消息的全文供前端放回输入框);缺省/其它值 ⇒ 原样「停止」(旧前端不带体
711
+ * 照常走这条,行为与响应体一字不变)。为什么必须拆开:没有在跑的轮次时会话窗那个
712
+ * 「取消排队」按钮走停止链路必被驱动 404 判成 stopped=false,用户看到的是
713
+ * 「该会话未在运行或无法停止」而排队消息其实已被取消——动作与结果对不上。
714
+ */
715
+ export async function taskSessionChatStop(ctx, taskId) {
716
+ const body = await ctx.readBody(); // 见上:时机照 Python;本批起要消费 mode
717
+ const { task } = ctx.sessionContext(taskId);
718
+ if (task === null) {
719
+ ctx.error(404, 'task not found'); // 紧凑
720
+ return;
721
+ }
722
+ const sid = task.session_id || '';
723
+ if (pyStrOrEmpty(body.mode) === STOP_MODE_CANCEL_QUEUE) {
724
+ cancelQueueAction(ctx, body, { sid, taskId });
725
+ return;
726
+ }
727
+ await chat.stop(sid, { db: ctx.db });
728
+ ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8'); // 紧凑(Python 是字节串)
729
+ }
730
+
731
+ /** 端点 body 的 `mode` 取值:只有这一个是「取消排队」,其余(含缺省/旧前端不带体)都是「停止」 */
732
+ export const STOP_MODE_CANCEL_QUEUE = 'cancel_queue';
733
+
734
+ /**
735
+ * `mode=cancel_queue` 的共用实现(任务/看板两路):**只取消平台排队消息,不碰轮次**。
736
+ *
737
+ * 这是会话窗「取消排队」按钮的服务端落点,与「停止」是两件事(用户 2026-10-16 裁定 U4:
738
+ * 停止只打断当前轮;取消排队是另一个显式动作)。**纯平台侧**:删的是 `chat_msgs` 排队行
739
+ * 与 `m:` 等待项行,不经过驱动 ⇒ 池外会话(dsh GUI 直跑/看管未接回)同样有效——这正是
740
+ * 原实现(借用 stop 链路,被驱动 404 判失败)修不好的那一档。
741
+ *
742
+ * 响应回带被取消消息的 `{id,text}`(**全文**,见 `chat.cancelQueuedDetail`):前端要把
743
+ * 原文放回输入框,而下发给前端的排队行文本被截到 120 字。`cancelled=0` 不是错误
744
+ * (消息可能已被 worker 抢走执行,或已被别的路径取消)——前端据此提示「已不在排队中」。
745
+ *
746
+ * `body.msg_id` 非空 ⇒ 只取消该条(排队行的行内「取消」按钮);归属判定照 `injectMsg`
747
+ * 的双/三重口径,不命中同一个 404 文案(不细分,避免暴露他人消息是否存在)。
748
+ *
749
+ * @param {object} ctx 请求上下文
750
+ * @param {object} body 请求体(消费 `mode`/`msg_id`)
751
+ * @param {{sid?: string, cardId?: number|null, taskId?: number|null, projectId?: number|null}} [scope]
752
+ */
753
+ export function cancelQueueAction(ctx, body, scope = {}) {
754
+ const { sid = '', cardId = null, taskId = null, projectId = null } = scope;
755
+ const msgId = pyStrOrEmpty(body.msg_id);
756
+ if (msgId) {
757
+ const info = chat.msgInfo(msgId, { db: ctx.db });
758
+ // Python `sid and info.get("sid") != sid`:只有调用方给了 sid 才比 sid(看板按卡过滤)
759
+ if (info === null || (sid && info.sid !== sid)
760
+ || (projectId !== null && info.project_id !== projectId)
761
+ || (cardId !== null && info.card_id !== cardId)
762
+ || (taskId !== null && info.task_id !== taskId)) {
763
+ ctx.errorSpaced(404, '消息不存在或不在该会话下');
764
+ return;
765
+ }
766
+ }
767
+ const out = chat.cancelQueuedDetail(
768
+ { sid: sid || null, cardId, msgId: msgId || null }, { db: ctx.db });
769
+ ctx.json(200, { ok: true, cancelled: out.cancelled, msgs: out.msgs });
770
+ }
771
+
772
+ // ==========================================================================
773
+ // 任务会话三条(M6-6):server.py 1209/1213/1217 分派 → 3646 / 3849 / 3919
774
+ // POST /api/tasks/<id>/session/profile → `_api_task_session_profile`(3646)
775
+ // POST /api/tasks/<id>/session/rewind → `_api_session_rewind`(3849)
776
+ // POST /api/tasks/<id>/session/interaction/answer → `_api_task_answer_interaction`(3919)
777
+ //
778
+ // 三条与看板同族端点的**关键差异**(逐条照 oracle,别顺手复用看板实现):
779
+ // ① `profile` 只开思考等级:`model`/`permission_mode` 非空 ⇒ 400(文案见下);成功体
780
+ // 是 `json.dumps` ⇒ `ctx.json`(看板那条多回 model/permission_mode 两个键)。
781
+ // ② `rewind` 走同一条 `_rewind_session_dsh`,但**任务侧多两步**:`db.update_task(session_id=new_sid)`
782
+ // (任务只有一个会话位,否则后续轮次仍打在旧会话上)与响应里的 `rebound: true`
783
+ // (看板路径恒 false,见 `routes/board.js` 的同名实现)。
784
+ // ③ `answer` 任务侧**直送**:成功体是硬编码 `b'{"ok":true,"queued":false}'`(紧凑,
785
+ // 且 queued 恒 false),**不是**看板那条按 `board.is_answer_pending` 现读派生的形态。
786
+ // ==========================================================================
787
+
788
+ /** Python `dict.get(key, dflt)`:键存在但值为 null 时**不**回落 dflt(与 Python 同) */
789
+ function pyGet(obj, key, dflt = null) {
790
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return dflt;
791
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : dflt;
792
+ }
793
+
794
+ /** Python `str(x)` 的**标量面**(容器 repr 是已登记偏差,见 board.js 同名私有助手) */
795
+ function pyStrScalar(value) {
796
+ if (value === null || value === undefined) return 'None';
797
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
798
+ return String(value);
799
+ }
800
+
801
+ /**
802
+ * `_api_task_session_profile`(3646):任务会话级配置(只开**思考等级**)。
803
+ *
804
+ * `model`/`permission_mode` 显式拒绝的理由照 oracle:任务会话没有交互面,permission_mode
805
+ * 会让任务永久挂起;model 每轮起会话时按任务/项目口径下发,会话级切换下一轮即被覆盖。
806
+ * 校验顺序:404 → model/permission_mode 400 → 族 400 → 档位非法 400 → 无会话 409 → 502。
807
+ */
808
+ export async function taskSessionProfile(ctx, taskId) {
809
+ const body = await ctx.readBody();
810
+ const { task, project, family } = ctx.sessionContext(taskId);
811
+ if (task === null) {
812
+ ctx.error(404, 'task not found'); // 紧凑
813
+ return;
814
+ }
815
+ for (const unsupported of ['model', 'permission_mode']) {
816
+ if (String(pyTruthy(pyGet(body, unsupported)) ? pyGet(body, unsupported) : '')) {
817
+ ctx.errorSpaced(400, `任务会话不支持 ${unsupported}(仅看板卡会话支持)`);
818
+ return;
819
+ }
820
+ }
821
+ if (family !== 'dsh_plugin') {
822
+ ctx.errorSpaced(400, '该 agent 族不支持会话级配置');
823
+ return;
824
+ }
825
+ const effort = normalizeEffort(pyGet(body, 'reasoning_effort'));
826
+ if (effort === null) {
827
+ ctx.errorSpaced(400, `思考等级非法(${DSH_EFFORT_LEVELS.join('/')} 或留空)`);
828
+ return;
829
+ }
830
+ const sid = task.session_id || '';
831
+ if (!sid) {
832
+ ctx.error(409, '会话尚未生成(首轮执行后才有)'); // 紧凑
833
+ return;
834
+ }
835
+ // 模型取任务/项目口径(与 runner 起轮一致):只改思考等级时插件也能自己回读会话当前模型
836
+ const model = String(task.model || '').trim()
837
+ || ((project ? String(project.model || '') : '').trim());
838
+ const [provider, mid] = dshdriver.splitModel(model);
839
+ try {
840
+ await dshdriver.setModel(sid, mid, provider, effort);
841
+ } catch (e) {
842
+ if (e instanceof DshDriverError) {
843
+ ctx.errorSpaced(502, pyStrErr(e));
844
+ return;
845
+ }
846
+ throw e;
847
+ }
848
+ ctx.json(200, { ok: true, reasoning_effort: effort });
849
+ }
850
+
851
+ /**
852
+ * `_api_session_rewind`(3849):把任务会话回退到某条用户提问之前(=按边界 fork 新会话)。
853
+ *
854
+ * 与看板路径共用 `_rewind_session`(族检查 → `_rewind_session_dsh`),差别只有
855
+ * `task is not None` 触发的 `update_task` 与 `rebound`。**顺序照 oracle**:
856
+ * fork → ensure_session → update_task(前两步失败即 502,不落库)。
857
+ */
858
+ export async function taskSessionRewind(ctx, taskId) {
859
+ const body = await ctx.readBody();
860
+ const { task, project, family } = ctx.sessionContext(taskId);
861
+ if (task === null) {
862
+ ctx.error(404, 'task not found'); // 紧凑
863
+ return;
864
+ }
865
+ const sid = task.session_id || '';
866
+ if (!sid) {
867
+ ctx.error(409, '会话尚未生成(首轮执行后才有)'); // 紧凑
868
+ return;
869
+ }
870
+ const fam = agentFamily(project.agent_path);
871
+ if (fam !== 'dsh_plugin') {
872
+ ctx.errorSpaced(400, '该 agent 族不支持会话回退');
873
+ return;
874
+ }
875
+ const mid = String(pyTruthy(pyGet(body, 'mid')) ? pyGet(body, 'mid') : '');
876
+ const boundary = sessparse.dsh_fork_boundary(sid, mid);
877
+ if (boundary === null) {
878
+ ctx.errorSpaced(409, '该提问已被回退或会话已变化,请刷新后重试');
879
+ return;
880
+ }
881
+ let newSid;
882
+ try {
883
+ newSid = String(((await dshdriver.fork(sid, boundary)) || {}).new_session_id || '');
884
+ if (!newSid) throw new DshDriverError(-2, 'fork 未返回新会话 id');
885
+ // 接进驱动池:UI 切过去后可直接续聊(不接池则 /rename、/compact 会 404)
886
+ await dshdriver.ensureSession(newSid, project.project_dir, `rewind-${sid.slice(0, 8)}`);
887
+ // 任务侧:任务只有一个会话位 —— 回退后必须把新会话设为任务会话(看板路径无此步)
888
+ dbmod.updateTask(task.id, { session_id: newSid }, { db: ctx.db });
889
+ } catch (e) {
890
+ if (e instanceof DshDriverError) {
891
+ ctx.errorSpaced(502, `回退失败:${pyStrErr(e)}`);
892
+ return;
893
+ }
894
+ throw e;
895
+ }
896
+ ctx.json(200, { ok: true, new_session_id: newSid, boundary, rebound: true });
897
+ }
898
+
899
+ /**
900
+ * `_api_task_answer_interaction`(3919):作答任务会话的待答交互(提问 / 审批两路)。
901
+ *
902
+ * 任务会话没有看板 watcher ⇒ 先 `board.interaction_probe` 探测刷新缓存再作答;
903
+ * 成功即 `{"ok":true,"queued":false}`(**硬编码紧凑**,任务侧直送,不看 is_answer_pending)。
904
+ */
905
+ export async function taskAnswerInteraction(ctx, taskId) {
906
+ const body = await ctx.readBody();
907
+ const { task, project, family } = ctx.sessionContext(taskId);
908
+ if (task === null) {
909
+ ctx.error(404, 'task not found'); // 紧凑
910
+ return;
911
+ }
912
+ if (family !== 'dsh_plugin') {
913
+ ctx.errorSpaced(400, '该 agent 族不支持回答问题');
914
+ return;
915
+ }
916
+ const sid = task.session_id || '';
917
+ if (!sid) {
918
+ ctx.error(409, '会话尚未生成(首轮执行后才有)'); // 紧凑
919
+ return;
920
+ }
921
+ board.interactionProbe(family, project, sid);
922
+ const card = { session_id: sid };
923
+ let err;
924
+ if (pyTruthy(pyGet(body, 'approval_id'))) {
925
+ const orEmpty = (k) => pyStrScalar(pyTruthy(pyGet(body, k)) ? pyGet(body, k) : '');
926
+ err = await board.answerApproval(project, card, orEmpty('approval_id'),
927
+ orEmpty('decision'), orEmpty('scope'), { db: ctx.db });
928
+ } else {
929
+ const qid = pyStrScalar(pyTruthy(pyGet(body, 'qid')) ? pyGet(body, 'qid') : '');
930
+ if (!qid) {
931
+ ctx.error(400, 'qid/approval_id required'); // 紧凑
932
+ return;
933
+ }
934
+ err = await board.answerInteraction(project, card, qid, pyGet(body, 'answers') || [],
935
+ { db: ctx.db });
936
+ }
937
+ if (err) {
938
+ ctx.errorSpaced(400, err);
939
+ return;
940
+ }
941
+ ctx.text(200, '{"ok":true,"queued":false}', 'application/json; charset=utf-8');
942
+ }
943
+
944
+ /** 路由表(顺序即匹配顺序;本族前缀与看板/任务族互不重叠) */
945
+ export const routes = [
946
+ {
947
+ method: 'GET',
948
+ re: /^\/api\/projects\/(\d+)\/board\/session\/messages$/,
949
+ handler: (ctx, m) => boardSessionMessages(ctx, Number(m[1])),
950
+ },
951
+ {
952
+ method: 'GET',
953
+ re: /^\/api\/projects\/(\d+)\/board\/session\/media\/(.+)$/,
954
+ handler: (ctx, m) => boardSessionMedia(ctx, Number(m[1]), m[2]),
955
+ },
956
+ {
957
+ method: 'GET',
958
+ re: /^\/api\/tasks\/(\d+)\/session\/messages$/,
959
+ handler: (ctx, m) => taskSessionMessages(ctx, Number(m[1])),
960
+ },
961
+ {
962
+ method: 'GET',
963
+ re: /^\/api\/tasks\/(\d+)\/session\/stream$/,
964
+ handler: (ctx, m) => taskSessionStream(ctx, Number(m[1])),
965
+ },
966
+ {
967
+ method: 'GET',
968
+ re: /^\/api\/tasks\/(\d+)\/session\/media\/(.+)$/,
969
+ handler: (ctx, m) => taskSessionMedia(ctx, Number(m[1]), m[2]),
970
+ },
971
+ // --- 写路径(M4.5/M6-6):顺序 = Python do_POST 的分派顺序 ---
972
+ // (chat(1201) → inject(1205) → rewind(1209) → interaction/answer(1213) → profile(1217) → stop(1221))
973
+ {
974
+ method: 'POST',
975
+ re: /^\/api\/tasks\/(\d+)\/session\/chat$/,
976
+ handler: (ctx, m) => taskSessionChat(ctx, Number(m[1])),
977
+ },
978
+ {
979
+ method: 'POST',
980
+ re: /^\/api\/tasks\/(\d+)\/session\/chat\/inject$/,
981
+ handler: (ctx, m) => taskSessionChatInject(ctx, Number(m[1])),
982
+ },
983
+ {
984
+ method: 'POST',
985
+ re: /^\/api\/tasks\/(\d+)\/session\/rewind$/,
986
+ handler: (ctx, m) => taskSessionRewind(ctx, Number(m[1])),
987
+ },
988
+ {
989
+ method: 'POST',
990
+ re: /^\/api\/tasks\/(\d+)\/session\/interaction\/answer$/,
991
+ handler: (ctx, m) => taskAnswerInteraction(ctx, Number(m[1])),
992
+ },
993
+ {
994
+ method: 'POST',
995
+ re: /^\/api\/tasks\/(\d+)\/session\/profile$/,
996
+ handler: (ctx, m) => taskSessionProfile(ctx, Number(m[1])),
997
+ },
998
+ {
999
+ method: 'POST',
1000
+ re: /^\/api\/tasks\/(\d+)\/session\/chat\/stop$/,
1001
+ handler: (ctx, m) => taskSessionChatStop(ctx, Number(m[1])),
1002
+ },
1003
+ ];