@gavinc-cn/touchstone-dsh 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -255,24 +255,107 @@ export function killTree(pid, { sig = 'SIGTERM', graceMs = KILL_GRACE_MS } = {})
255
255
  return result;
256
256
  }
257
257
 
258
+ /** Windows 命令行查询缓存:同一次判定里同一 pid 可能被问两遍(liveForeignHolder + writePidRecord) */
259
+ const WIN_CMDLINE_CACHE = new Map();
260
+ const WIN_CMDLINE_CACHE_MAX = 32;
261
+
262
+ /** 往缓存里记一条(超限丢最早的;数字键的插入序即写入序,够用) */
263
+ function cacheWinCmdline(pid, value) {
264
+ if (WIN_CMDLINE_CACHE.size >= WIN_CMDLINE_CACHE_MAX) {
265
+ WIN_CMDLINE_CACHE.delete(WIN_CMDLINE_CACHE.keys().next().value);
266
+ }
267
+ WIN_CMDLINE_CACHE.set(pid, value);
268
+ return value;
269
+ }
270
+
271
+ /**
272
+ * Windows:用系统自带工具读 pid 的命令行。读不到返回 null。
273
+ *
274
+ * 为什么不是「读不到就算它是后端」:**判据会被用在两个方向**——
275
+ * ① `liveForeignHolder` 用它决定「要不要拒绝启动」(exit 3);
276
+ * ② `reapStale` 用它决定「要不要 SIGTERM 掉这个 pid」。
277
+ * 2026-10-17 桌面端真机事故(npm 0.3.0 / Windows)就是①:Node 后端被 TerminateProcess 硬杀后
278
+ * `.pid` 残留、其 pid 被 OpenConsole.exe 复用,旧实现 `非 linux ⇒ return true` 让「没有活实例」
279
+ * 永久误判成「已有运行中的实例」⇒ 面板再也打不开。两个方向误判的代价都远大于「确认不了就放行」。
280
+ *
281
+ * 工具选择(零 npm 依赖红线):PowerShell 的 CIM(Win7+ 自带,`-NoProfile -NonInteractive` 尽量快)
282
+ * 优先;`wmic` 兜底(Win10/11 仍可用,但已被微软标记弃用)。权限不足时输出为空,用 wmic 再试。
283
+ */
284
+ function windowsProcessCmdline(pid) {
285
+ if (WIN_CMDLINE_CACHE.has(pid)) return WIN_CMDLINE_CACHE.get(pid);
286
+ const opts = { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true };
287
+ let text = '';
288
+ try {
289
+ const script = '$p = Get-CimInstance Win32_Process -Filter "ProcessId=' + pid + '"; '
290
+ + 'if ($p -and $p.CommandLine) { [Console]::Out.Write($p.CommandLine) }';
291
+ text = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], opts);
292
+ } catch {
293
+ /* 没有 PowerShell / 超时 / 权限不足:退 wmic */
294
+ }
295
+ if (text && text.trim()) return cacheWinCmdline(pid, text.trim());
296
+ try {
297
+ const out = execFileSync('wmic',
298
+ ['process', 'where', `processid=${pid}`, 'get', 'commandline', '/value'], opts);
299
+ const line = String(out || '').split(/\r?\n/).find((l) => l.startsWith('CommandLine='));
300
+ if (line && line.length > 'CommandLine='.length) {
301
+ return cacheWinCmdline(pid, line.slice('CommandLine='.length).trim());
302
+ }
303
+ } catch {
304
+ /* 无 wmic(新系统可能已移除)/ 超时:确认不了 */
305
+ }
306
+ return cacheWinCmdline(pid, null);
307
+ }
308
+
309
+ /**
310
+ * 读 pid 的真实命令行(字符串;读不到 null,绝不抛)。
311
+ *
312
+ * `platform` 是显式形参而不是直接读 `process.platform`,为了单测能**在 Linux 上验 Windows
313
+ * 分支**(真机事故形态没法在 CI 复现,见 `backend/test/proc.test.mjs` 的注入用例)。
314
+ * macOS 等 POSIX:`ps -p <pid> -o command=`。
315
+ */
316
+ function readProcessCmdline(pid, platform) {
317
+ if (platform === 'linux') {
318
+ try {
319
+ return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
320
+ } catch {
321
+ return null; // 进程刚消失/无权限:确认不了
322
+ }
323
+ }
324
+ if (platform === 'win32') return windowsProcessCmdline(pid);
325
+ try {
326
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
327
+ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
328
+ });
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
258
334
  /**
259
335
  * 命令行粗校验:这个 pid 是否**像**本平台后端(防 PID 复用误杀,对齐
260
336
  * `lifecycle.SingleInstance._looks_like_server`)。
261
337
  *
262
338
  * 与 Python 的差异:Python 只认 `server.py`;Node 侧**同时接受 `backend/server.js`**
263
339
  * ——两个版本互为孤儿时都要能收口(Python 杀 Node 孤儿、Node 杀 Python 孤儿)。
264
- * 读不到命令行(非 Linux 无 /proc、进程刚消失、权限不足)一律返回 **true**:
265
- * 调用点只在「pid 记录声明 parent_watch 且其父已死」时才走到这里,宁可不杀不误杀。
340
+ *
341
+ * **读不到命令行(含非 Linux 平台、进程刚消失、权限不足)一律返回 false**(2026-10-17 订正):
342
+ * 旧实现返回 true,等于把「不知道」当成「像后端」,在 Windows 上造成两个方向的真机误判
343
+ * (见 `windowsProcessCmdline` 的注释)。宁可不拦/不杀,也不拿不确定当判据。
344
+ *
345
+ * @param {number} pid
346
+ * @param {{platform?: string, readCmdline?: (pid: number) => string|string[]|null}} [options]
347
+ * 测试缝:注入平台与命令行读取器(生产路径不传)。
266
348
  */
267
- export function looksLikeBackend(pid) {
349
+ export function looksLikeBackend(pid, { platform = process.platform, readCmdline } = {}) {
268
350
  const n = Number(pid);
269
- let raw = '';
270
- if (process.platform !== 'linux') return true; // 无 /proc:读不到命令行 ⇒ 宁可不杀
351
+ if (!Number.isInteger(n) || n <= 0) return false;
352
+ let raw;
271
353
  try {
272
- raw = fs.readFileSync(`/proc/${n}/cmdline`, 'utf8');
354
+ raw = typeof readCmdline === 'function' ? readCmdline(n) : readProcessCmdline(n, platform);
273
355
  } catch {
274
- return true; // 读不到 ⇒ 宁可不杀(对齐 Python)
356
+ return false; // 读取器抛错同样算「确认不了」
275
357
  }
276
- const cmdline = raw.replace(/\\/g, '/'); // Windows 风格分隔符归一,便于同一判据
358
+ if (raw === null || raw === undefined || raw === '') return false;
359
+ const cmdline = (Array.isArray(raw) ? raw.join(' ') : String(raw)).replace(/\\/g, '/');
277
360
  return cmdline.includes('server.py') || cmdline.includes('backend/server.js');
278
361
  }
@@ -107,8 +107,19 @@
107
107
  * 执行体」段各自 `console.log` 一行后跳过本轮(「响亮 + 继续」),绝不让一行坏数据或
108
108
  * 一次收口写失败(`finally` 里的 DB 故障)带走服务。**单元分派/except/finally 本体照
109
109
  * Python 逐条保留,不吞任何异常**——保底只在它们之外兜底。
110
- * ⑫ **`runPickedUnit` 的 a: except 分支多一行日志**:Python 静默放回重投;Node 侧
111
- * 「answer 口未接线」时该路径会**热旋**(放回→立即重拾→再抛),日志是唯一可见性。
110
+ * ⑫ **`runPickedUnit` 的 a: except 分支两处 Node 侧加固**(本分支在 Python 侧实际不可达:
111
+ * `runner.py:1087` 直接 `import board` 调执行体,不存在「口没接线」这个态;Node 侧是
112
+ * 缝,未注册即抛 ⇒ 本分支的可达场景就是**接线缺口**,或有异常从执行体逃逸):
113
+ * ① **多一行日志**(Python 静默放回重投)——放回→立即重拾→再抛是热旋,日志是唯一可见性;
114
+ * ② **放回带退避**(`not_before = now + ANSWER_EXC_BACKOFF_S`;Python `runner.py:1110`
115
+ * 传 `waitq.return_to_waiting` 的缺省 `0.0`)——2026-10-16 真机事故:接线缺口 + 零延迟
116
+ * 重投 ⇒ **微任务级热旋**(await 全落已决 promise)把事件循环的宏任务队列饿死:后端
117
+ * 50% CPU、日志 5.5KB/s 涨到 17MB、连 SIGTERM 都收不到(只能 SIGKILL),TS 会话详情
118
+ * 的读端点随事件循环一起卡死;毒行在 `wait_items` 里 ⇒ 重启后端立刻续旋
119
+ * (用户观感「重启 DSH 也没用」)。**行态语义一字不改**:仍落 `waiting`、`retries`
120
+ * 不涨(`retries` 只记业务重试,判据见 `backend/test/runner-core.test.mjs` ⑮),只把
121
+ * 「立即重拾」改成「30s 后重拾」;退避档与 `board.ANSWER_BACKOFF_S` 同值
122
+ * (不在本文件 import board:会成环,见差异㉘)。
112
123
  * ⑬ **两处 board/db 口缺失,就地等价实现**(本文件私有、不导出成公开 API):
113
124
  * `board._card_unit_active`(`_answer_exempt_keys_locked` 用;board.js 的 M3 只读子集
114
125
  * 没有这个口,判据本身只是一次 waitq 行读)与 `db.update_board_card(last_error=…)`
@@ -758,7 +769,9 @@ function stageIndex(stage) {
758
769
  export const UNIT_HANDLER_MISSING = Object.freeze({
759
770
  task: '任务单元轮次执行体未移植(M6)',
760
771
  card: '卡片单元起跑体未移植(M5)',
761
- answer: '答案送达体未移植(M5)',
772
+ // 2026-10-16:实现体 M5 就在 `board.deliverAnswerUnit`,缺的只是 server.js 的注册
773
+ // ⇒ 文案改成「未接线」(原「未移植」在真机上把人指向了错的排查方向)。
774
+ answer: '答案送达体未接线(setUnitHandlers 缺 deliverAnswerUnit)',
762
775
  });
763
776
 
764
777
  /** 单元执行体未接线(`setUnitHandlers` 的缺口):具名错误,调用方可 `instanceof` 判定 */
@@ -770,6 +783,15 @@ export class UnitHandlerMissing extends Error {
770
783
  }
771
784
  }
772
785
 
786
+ /**
787
+ * `a:` 执行体**异常兜底**放回时的退避秒数(见文件头差异⑫②)。
788
+ *
789
+ * 为什么是 30.0:与业务退避 `board.ANSWER_BACKOFF_S`(`_answer_retry_or_fail`)同档——
790
+ * 「送达失败等一会儿再投」在两侧是同一个语义;**不 import board**(模块级 import 会成环,
791
+ * 见文件头差异㉘),故此处独立定义并在单测里钉住「放回后当轮不可再拾取」。
792
+ */
793
+ export const ANSWER_EXC_BACKOFF_S = 30.0;
794
+
773
795
  /** 单元执行体注册表(`setUnitHandlers` 唯一写点;全 null = 未接线) */
774
796
  let UNIT_HANDLERS = Object.freeze({
775
797
  processTask: null, processCard: null, deliverAnswerUnit: null,
@@ -790,6 +812,8 @@ let UNIT_HANDLERS = Object.freeze({
790
812
  *
791
813
  * ⚠ `a:` 口未接线时该单元会「放回 waiting → 立即被重拾 → 再抛」热旋(Python 同形:其
792
814
  * `board._deliver_answer_unit` 正常不抛)——运行日志里能直接看到,接线后消失。
815
+ * **2026-10-16(M10)`server.js` 已把三路口全部接真**(`a:` 那次是真机热旋被发现后补的:
816
+ * 50% CPU + 日志 5.5KB/s);本文件不校验接线态,接线判据在 `server-wiring.test.mjs`「接线④」。
793
817
  *
794
818
  * 只传一个口即把其余口置空;传 `{}` / `null` 全注销(回到未接线态)。
795
819
  *
@@ -2280,11 +2304,13 @@ export class Runner {
2280
2304
  // 消息单元异常:状态落盘由 chat.runUnit 内部负责,此处不记任务行
2281
2305
  } else if (key.startsWith('a:')) {
2282
2306
  // 答案执行体异常兜底(业务失败已在执行体内部分流,此处只防未预期异常把行永久挂在
2283
- // starting):放回 waiting 重入队,不计业务重试额度;放回失败(行已被取消)则不再重入队
2284
- console.log(`[runner] 答案单元异常(放回等待重投): ${key}: ${err}`); // 见文件头差异⑫
2307
+ // starting):放回 waiting 重入队(**带退避**,见文件头差异⑫②),不计业务重试额度;
2308
+ // 放回失败(行已被取消)则不再重入队
2309
+ console.log(`[runner] 答案单元异常(放回等待重投): ${key}: ${err}`); // 见文件头差异⑫①
2285
2310
  try {
2286
2311
  const arow = waitq.getActive(waitq.KIND_ANSWER, key.slice(2), opts);
2287
- if (arow !== null && waitq.returnToWaiting(arow.id, 0.0, false, opts)) {
2312
+ const notBefore = Date.now() / 1000 + ANSWER_EXC_BACKOFF_S;
2313
+ if (arow !== null && waitq.returnToWaiting(arow.id, notBefore, false, opts)) {
2288
2314
  this.submitAnswer(pyIntOr(key.slice(2), null), opts);
2289
2315
  }
2290
2316
  } catch {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 启动横幅的默认账号提示(`server.py:5457-5472 _seed_hint` 的对应物,2026-10-16 补)。
3
+ *
4
+ * ── 三档与 `db.js:seedAdmin()` 的口令来源一一对应(逐条对齐 Python) ──────────────
5
+ * ① 随机一次性口令(`seedPassword` 非空)→ 打印口令本身 + 首登须改密(读后即清)
6
+ * ② `TS_ADMIN_PASSWORD` 非空 → 只指向变量名,**绝不回显口令值**
7
+ * ③ 都没有(`TS_ADMIN_PASSWORDLESS=1`,插件形态)→ 空口令,提示可在设置页设密码
8
+ *
9
+ * ── 为什么单列第 ③ 档(2026-10-16 修) ────────────────────────────────────────
10
+ * 第 ③ 档此前**落进第 ② 档的文案**:插件形态(薄壳默认下发 `TS_ADMIN_PASSWORDLESS=1`)
11
+ * 首启横幅写的是「初始口令取自 TS_ADMIN_PASSWORD」,可那个变量根本没设、账号实际是空口令
12
+ * ——首装排障按这句话去找变量会白找。Python 侧同一分支的旧文案是「其一次性口令已在上一次
13
+ * 启动时打印,本次不再显示」,同样对不上:该分支**结构上不可达**于「口令已被取走」——
14
+ * `seeded=false` 时 `_seed_hint` 直接返回空串,而 `seeded=true` 且 `initial_pw` 为空只可能
15
+ * 是空口令种子。两版现在给同一句、且逐字节相同(`tests/test_seed_admin.py` 与
16
+ * `test/seed-hint.test.mjs` 各钉一半)。
17
+ *
18
+ * 纯函数:不读全局、无副作用(`env` 由调用方传入)⇒ 与 Python 一样可函数级单测,
19
+ * 不必真起进程。
20
+ */
21
+
22
+ /**
23
+ * 组装本次种子后的横幅片段。
24
+ * @param {{seeded?: boolean, seedPassword?: string, env?: NodeJS.ProcessEnv}} [boot]
25
+ * `seeded`/`seedPassword` 取自 `initDb()` 的返回值;`env` 缺省 `process.env`
26
+ * ——种子用的就是它,横幅判断与种子判断必须同源
27
+ * @returns {string} 本次实际种子时返回横幅片段(含前导两空格);日常启动返回空串
28
+ */
29
+ export function seedHint({ seeded = false, seedPassword = '', env = process.env } = {}) {
30
+ if (!seeded) return '';
31
+ if (seedPassword) {
32
+ return ` (首次启动已创建默认账号 admin,一次性初始口令:${seedPassword}`
33
+ + ',首次登录须修改;仅本次显示,请立即记录)';
34
+ }
35
+ if (env.TS_ADMIN_PASSWORD) {
36
+ return ' (首次启动已创建默认账号 admin,初始口令取自 TS_ADMIN_PASSWORD,请尽快登录修改)';
37
+ }
38
+ return ' (首次启动已创建默认账号 admin,空口令(插件形态免登)'
39
+ + ';如需口令登录请在设置页设置密码)';
40
+ }
@@ -9,17 +9,18 @@
9
9
  * - `boardOwnsSid` (`_board_owns_sid`:2765)
10
10
  * - `boardOwned` (`_board_owned`:2748 的只读版)
11
11
  * - `taskSince` (`_task_since`:4747)
12
- * - `interactionOfSid` (`board.interaction_of_sid`:4688 的 M3 空实例口径)
13
12
  *
14
13
  * 命名口径:**函数 camelCase、返回字段沿用 Python 的 snake_case**(字段直接进 JSON
15
14
  * 响应,改名就是改契约)。`{state,pos,total}` / `{pending,kind,…}` 这类形状逐字同 Python。
16
15
  *
17
- * ## M3 的两条边界(方案 §四,逐条在这里落地并注明)
16
+ * ## M3 的边界(方案 §四,逐条在这里落地并注明)
18
17
  * ① `board.runningMap()` / `chat.state` 的进程内登记表在 M3 恒空 ⇒ `boardSessionRunning`
19
18
  * 的「平台在管」分支恒 false(与「刚起的 Python 实例」等价),实况 busy 分支照常走
20
19
  * `dshevents`;真驱动下的非空分支由 M4/M5 接线后自然生效。
21
- * ② `board.interaction_of_sid` 读调和器缓存 `_IW_CACHE`(属 M5)⇒ 本文件按**空缓存**
22
- * 返回 `null`(`interaction: null` 仍在 payload 里,键集合与 Python 一致)。
20
+ * ② 原先这里还有一个 `interactionOfSid` 占位(「调和器缓存属 M5 ⇒ 恒 null」)——M5 已把
21
+ * 调和器搬进 `board.js`,该占位成了**同名的错误实现**:路由误 import 它 ⇒ Node 后端下
22
+ * 会话窗永远拿不到待答提问/审批(用户报障卡 980)。2026-10-16 删除,读口只用
23
+ * `board.interactionOfSid`(缓存为空/无等待时自然为 null,键仍在 payload 里)。
23
24
  *
24
25
  * 数据层调用口径:M3 约定「自由函数 + 末位可选 `{db, dbPath, env}`」,本文件一律把
25
26
  * `options` 原样透传给 `runner`/`board`/`db` 的读口(路由层传 `{db: ctx.db}`)。
@@ -196,17 +197,6 @@ export function boardOwned(projectId, cardId = null, options = {}) {
196
197
  return { project, card };
197
198
  }
198
199
 
199
- /**
200
- * `board.interaction_of_sid(sid)` 的 M3 口径:调和器缓存 `_IW_CACHE` 属 M5 ⇒ **恒 null**
201
- * (=「刚起的 Python 实例」)。
202
- *
203
- * 为什么仍然发这个键:Python payload 里 `interaction` 一定在场(值可能是 null),
204
- * 少发键会被对拍器按「键集合差异」抓到,也会让前端多一处 `undefined` 分支。
205
- */
206
- export function interactionOfSid(sid) {
207
- return sid ? null : null; // 保留 sid 形参只为与 Python 同名同签名
208
- }
209
-
210
200
  /**
211
201
  * `_task_since(task)`:任务起始时间(epoch **秒**,本地时区,运行中会话探测的时间下界)。
212
202
  * `started_at` 优先,回退 `created_at`;格式异常返回 `0`(探测据此直接放弃)。
@@ -64,7 +64,7 @@ import { unquote } from '../lib/query.js';
64
64
  import { DSH_EFFORT_LEVELS, DSH_PERMISSION_PRESETS, familyCapabilities, normalizeEffort,
65
65
  sessFamily } from '../lib/servercaps.js';
66
66
  import * as sessparse from '../lib/sessparse.js';
67
- import { injectMsg } from './sessions.js';
67
+ import { cancelQueueAction, injectMsg } from './sessions.js';
68
68
 
69
69
  // --------------------------------------------------------------------------
70
70
  // 常量(照抄 server.py:2820-2830 / 5029-5154 的字面量)
@@ -598,6 +598,14 @@ async function apiBoardCardAction(ctx, projectId, cardId, action) {
598
598
  const [row, card] = boardOwned(ctx, projectId, cardId);
599
599
  if (row === null) return;
600
600
  if (action === 'stop') {
601
+ // body 在 stop 分支按需读(`ctx.readBody` 记忆化,时机对齐 Python `do_POST:1048` 的
602
+ // 「分派前统一读体」):缺省(旧前端不带体)⇒ 原样停止,响应体一字不变。
603
+ // `mode=cancel_queue` ⇒ 会话窗「取消排队」:纯平台侧取消排队消息、不碰轮次(2026-10-17)。
604
+ const body = await ctx.readBody();
605
+ if (pyStrScalar(pyGet(body, 'mode') || '') === 'cancel_queue') {
606
+ cancelQueueAction(ctx, body, { cardId });
607
+ return;
608
+ }
601
609
  const hit = await board.stopCard(cardId, { db: ctx.db });
602
610
  ctx.json(200, { ok: true, stopped: hit });
603
611
  return;
@@ -1,15 +1,17 @@
1
1
  /**
2
- * 路由族:飞书应用面(斜杠指令 + 扫码供给 + 配置自检)——M7-R4 尾/R5 车道,5 个端点。
2
+ * 路由族:飞书应用面(斜杠指令 + 扫码供给 + 配置自检 + 入站重连)——M7-R4 尾/R5 车道 + M11.1,6 个端点。
3
3
  *
4
- * 归属条目(`server.py` 行号 = 归属条目;顺序照 `_route_get`(1318/1320) 与 `do_POST`(1062-1068)):
4
+ * 归属条目(`server.py` 行号 = 归属条目;顺序照 `_route_get`(1318/1320) 与 `do_POST`(1062-1085)):
5
5
  * - `GET /api/me/feishu/slash-commands` → `_api_me_feishu_slash_get`(1818) → `feishu.slash_status`
6
6
  * - `POST /api/me/feishu/slash-commands` → `_api_me_feishu_slash_set`(1826) → sync/clear/notify
7
7
  * - `GET /api/me/feishu/provision` → `_api_me_feishu_provision_get`(1767) → `provision_status`
8
8
  * - `POST /api/me/feishu/provision` → `_api_me_feishu_provision_set`(1774) → start/cancel/apply/publish
9
9
  * - `POST /api/me/feishu/doctor` → `_api_me_feishu_doctor`(1801) → `doctor`
10
+ * - `POST /api/me/feishu/reconnect` → `_api_me_feishu_reconnect`(1826) → `restart_inbound_for`(M11.1)
10
11
  *
11
- * 本文件只做**接线**:业务在 `../lib/feishu_slash.js`(更早车道已落地)与
12
- * `../lib/feishu_provision.js`(R5 追加段)。路由层负责的判据只有四类,全部逐字照抄 oracle:
12
+ * 本文件只做**接线**:业务在 `../lib/feishu_slash.js`(更早车道已落地)、
13
+ * `../lib/feishu_provision.js`(R5 追加段)与 `../lib/feishu_inbound.js`(M11 可停长连接)。
14
+ * 路由层负责的判据只有四类,全部逐字照抄 oracle:
13
15
  *
14
16
  * ══ ① 缺 action 两族**相反**(本族最容易被写歪的一处)═══════════════════════════════
15
17
  * - `/slash-commands`:`action = str(body.get("action") or "sync")` ⇒ **缺省 sync**(200);
@@ -18,6 +20,8 @@
18
20
  * - `/provision`:`action = str(body.get("action") or "")` ⇒ 缺省是**空串 ⇒ 400**;
19
21
  * 返回体是 `json.dumps({"error": ...})` ⇒ **冒号后带空格**(`ctx.errorSpaced`),
20
22
  * 与全站硬编码紧凑错误体(`ctx.error`)**不是一个字节形态**。
23
+ * - `/reconnect`:**不读任何业务字段**(无 action/无参数);响应固定三键
24
+ * `{ok, started, inbound}`,`ok` 恒 true,连接成败走 `started`/`inbound`(见 handler 注释)。
21
25
  *
22
26
  * ══ ② 三个动作的返回体**键序**(Python dict 插入序,`json.dumps` 保持)════════════════
23
27
  * - `notify`:`{ok, card_sent, card_error}`(字面量)。
@@ -29,8 +33,9 @@
29
33
  * 设置页的报错展示 ⇒ 失败**不**改 `error`)。
30
34
  *
31
35
  * ══ ③ 读体时机 ═══════════════════════════════════════════════════════════════════════
32
- * `do_POST:1048` 在**分派之前**统一读体 ⇒ 三条 POST 一律先 `await ctx.readBody()`
33
- * (400 档也要读掉),而两条 GET 全程**不读**(Python `do_GET` 没有 `_read_json`)。
36
+ * `do_POST:1048` 在**分派之前**统一读体 ⇒ 四条 POST 一律先 `await ctx.readBody()`
37
+ * (400 档也要读掉;`/reconnect` 不读业务字段也照样读掉),而两条 GET 全程**不读**
38
+ * (Python `do_GET` 没有 `_read_json`)。
34
39
  * 这不是洁癖:没读掉的 body 会留在 keep-alive 连接上污染下一个请求。
35
40
  *
36
41
  * ══ ④ 真值口径 ═══════════════════════════════════════════════════════════════════════
@@ -42,6 +47,8 @@
42
47
  * 协议客户端在 `../lib/feishu_device.js`,`supported` 改为真能力探测;剩 `inbound_ws` 线程态);
43
48
  * **本文件不重复实现任何业务逻辑**。
44
49
  */
50
+ import { inboundStatus } from '../lib/feishu_cfg.js';
51
+ import { restartInboundFor } from '../lib/feishu_inbound.js';
45
52
  import { doctor, provisionApply, provisionCancel, provisionPublish, provisionStart,
46
53
  provisionStatus } from '../lib/feishu_provision.js';
47
54
  import { clearSlashCommands, sendSlashPermCard, slashStatus,
@@ -177,8 +184,29 @@ async function apiDoctor(ctx) {
177
184
  ctx.json(200, await doctor(ctx.user.id, pyTruthy(body.send_probe), { db: ctx.db }));
178
185
  }
179
186
 
187
+ /**
188
+ * `_api_me_feishu_reconnect`(1826):设置页面板「重连入站」按钮的写口(M11.1)。
189
+ *
190
+ * 语义 = `feishu.restart_inbound_for`:停旧长连接 → 用**当前库里**的凭据起新。响应三键定序
191
+ * `{ok, started, inbound}`:`ok` 恒 true(动作已执行,不用 HTTP 错误码表达连接成败),
192
+ * `started` = 是否起了**新**连接,`inbound` = 与 `GET /api/me/feishu-cfg` 的 `inbound`
193
+ * **同形两键**(`configured`/`thread`)。
194
+ *
195
+ * 读体时机照文件头 ③:本端点**不读任何业务字段**,但 POST 必须把体读掉(Python
196
+ * `do_POST:1048` 在分派前统一读);读在哪一步不重要,重要的是别把它留在 keep-alive 上。
197
+ */
198
+ async function apiReconnect(ctx) {
199
+ await ctx.readBody();
200
+ const started = await restartInboundFor(ctx.user.id, { db: ctx.db });
201
+ ctx.json(200, {
202
+ ok: true,
203
+ started: Boolean(started),
204
+ inbound: inboundStatus(ctx.user.id, { db: ctx.db }),
205
+ });
206
+ }
207
+
180
208
  // --------------------------------------------------------------------------
181
- // 路由表(5 条;前缀 `/api/me/feishu*` 与其它族互不重叠,挂表位置不影响分派,见 `./index.js`)
209
+ // 路由表(6 条;前缀 `/api/me/feishu*` 与其它族互不重叠,挂表位置不影响分派,见 `./index.js`)
182
210
  // --------------------------------------------------------------------------
183
211
 
184
212
  export const routes = [
@@ -212,4 +240,10 @@ export const routes = [
212
240
  /** 配置自检八项(`send_probe=true` 时额外发一条测试消息,60s 频控)。 */
213
241
  handler: (ctx) => apiDoctor(ctx),
214
242
  },
243
+ {
244
+ method: 'POST',
245
+ path: '/api/me/feishu/reconnect',
246
+ /** M11.1 面板「重连入站」:停旧 → 用当前库凭据起新;三键 `{ok, started, inbound}`。 */
247
+ handler: (ctx) => apiReconnect(ctx),
248
+ },
215
249
  ];
@@ -78,10 +78,11 @@ export const ROUTES = [
78
78
  // 七族任何条目都不重叠(`^/api/projects/(\d+)$`、`/board/*`、`/bugs/*` 都吞不掉它),
79
79
  // 故挂表位置对分派结果无影响;放末尾是为了让既有条目的相对顺序一个字节都不动。
80
80
  ...feishuHookRoutes,
81
- // M7 续追加(`feishu.js` 个人配置/绑定族 7 条 + `feishu_app.js` 应用面 5 条)——
81
+ // M7 续追加(`feishu.js` 个人配置/绑定族 7 条 + `feishu_app.js` 应用面 6 条)——
82
82
  // 同样**只追加、不重排**,两条新族与此前八族前缀互不重叠(`/api/me/feishu*` 三个前缀
83
83
  // 互不吞并:`-cfg`/`-bindcode` 是字面路径,`/api/me/feishu`、`/api/me/feishu/outbox`、
84
- // `/api/me/feishu/slash-commands`、`/api/me/feishu/provision`、`/api/me/feishu/doctor`
84
+ // `/api/me/feishu/slash-commands`、`/api/me/feishu/provision`、`/api/me/feishu/doctor`、
85
+ // `/api/me/feishu/reconnect`(M11.1)
85
86
  // 各由自己的匹配条目收口),故挂表位置对分派结果无影响。
86
87
  // 注:车道施工期间这两条暂时是空表(占位文件),全部落地后由本表原样生效。
87
88
  ...feishuRoutes,
@@ -288,6 +288,22 @@ function resolveProject(ctx) {
288
288
  return rows.length ? rows[0] : null;
289
289
  }
290
290
 
291
+ /**
292
+ * `file?inline=1` 的内联白名单(2026-10-16,`server.py:PROJECT_INLINE_IMAGE_TYPES` 同表):
293
+ * **只放行浏览器可直接渲染的位图**。与 `raw=1`「一律 text/plain 或附件下载」的红线配套——
294
+ * 按扩展名把 agent 在工作区写的内容内联下发,等于让同源页面按该类型解释它;`html`/`svg`/`js`
295
+ * 这类可执行/可带脚本的类型一律不给,只给位图(`<img>` 里不会执行任何东西)。
296
+ * 扩展名口径同 `fileExt()`(`os.path.splitext(...)[1].lstrip('.').lower()`,大小写不敏感)。
297
+ */
298
+ const PROJECT_FILE_INLINE_TYPES = {
299
+ png: 'image/png',
300
+ jpg: 'image/jpeg',
301
+ jpeg: 'image/jpeg',
302
+ gif: 'image/gif',
303
+ webp: 'image/webp',
304
+ bmp: 'image/bmp',
305
+ };
306
+
291
307
  /**
292
308
  * `_send_project_file_raw`:原文(新标签打开/二进制下载):文本按 text/plain,二进制按附件下载。
293
309
  *
@@ -357,6 +373,62 @@ function sendProjectFileRaw(ctx, filePath) {
357
373
  }
358
374
  }
359
375
 
376
+ /**
377
+ * `_send_project_file_inline`:`inline=1` 的项目内位图原图内联下发。
378
+ *
379
+ * 与 `sendProjectFileRaw` 只差两处:Content-Type 按扩展名给真图片类型、不带
380
+ * `Content-Disposition: attachment`(浏览器才肯在 `<img>` 里渲染)。仍带 nosniff 与
381
+ * no-store。扩展名不在白名单 ⇒ 400(不是「不支持就按 raw 下发」——那正是 raw 红线要防的);
382
+ * 超 `FILE_RAW_MAX` ⇒ 413。三条错误体的 JSON 口径与 Python `json.dumps(..., ensure_ascii=False)`
383
+ * 逐字节相同,故一律走 `ctx.errorSpaced`(`{"error": "…"}` 带空格)。
384
+ * @param {object} ctx
385
+ * @param {string} filePath
386
+ */
387
+ function sendProjectFileInline(ctx, filePath) {
388
+ const ctype = PROJECT_FILE_INLINE_TYPES[fileExt(filePath)];
389
+ if (ctype === undefined) {
390
+ ctx.errorSpaced(400, '该类型不支持内联显示');
391
+ return;
392
+ }
393
+ let size;
394
+ try {
395
+ size = fs.statSync(filePath).size;
396
+ } catch {
397
+ ctx.errorSpaced(404, '文件不存在或不可读');
398
+ return;
399
+ }
400
+ if (size > FILE_RAW_MAX) {
401
+ ctx.errorSpaced(413, '文件过大,无法内联显示');
402
+ return;
403
+ }
404
+ ctx.res.writeHead(200, {
405
+ 'Content-Type': ctype,
406
+ 'Content-Length': String(size),
407
+ 'Cache-Control': 'no-store',
408
+ 'X-Content-Type-Options': 'nosniff',
409
+ });
410
+ try {
411
+ const fd = fs.openSync(filePath, 'r');
412
+ try {
413
+ const buf = Buffer.allocUnsafe(64 * 1024);
414
+ for (;;) {
415
+ const n = fs.readSync(fd, buf, 0, buf.length, null);
416
+ if (!n) break;
417
+ ctx.res.write(buf.subarray(0, n));
418
+ }
419
+ } finally {
420
+ fs.closeSync(fd);
421
+ }
422
+ } catch {
423
+ // 客户端提前断开属常态(对齐 Python 的 `except (BrokenPipeError, ConnectionResetError, OSError)`)
424
+ }
425
+ try {
426
+ ctx.res.end();
427
+ } catch {
428
+ // 已断开:忽略
429
+ }
430
+ }
431
+
360
432
  /**
361
433
  * `_api_project_file` 的 err → (状态码, 消息) 映射(照抄 Python 的字面量,含中文标点)。
362
434
  * @param {string} err
@@ -917,10 +989,13 @@ export const routes = [
917
989
  /**
918
990
  * 读取项目范围内的文件内容供会话页预览(只读,多用户隔离)。
919
991
  *
920
- * query: `path=`相对/绝对路径(相对按 项目目录 → 工作目录 解析);`raw=1` 直接返回原文。
992
+ * query: `path=`相对/绝对路径(相对按 项目目录 → 工作目录 解析);`raw=1` 直接返回原文;
993
+ * `inline=1` 把项目范围内的**位图**按原图内联下发(会话页渲染 agent 写的截图用,
994
+ * 见 `sendProjectFileInline`;两参数都给时 inline 优先)。
921
995
  * 安全:路径须落在本项目「项目目录/工作目录」之内(realpath 包含判定,防目录穿越与越权
922
996
  * 读他人文件);raw 一律 text/plain + nosniff —— 这些文件是 agent 在工作区写的内容,
923
- * 按扩展名猜类型内联下发会让同源页面执行其中脚本。
997
+ * 按扩展名猜类型内联下发会让同源页面执行其中脚本;`inline=1` 是这条红线上的**收窄例外**:
998
+ * 只放行 `PROJECT_FILE_INLINE_TYPES` 里的位图扩展名。
924
999
  */
925
1000
  handler: (ctx, m) => {
926
1001
  const row = ctx.ownedProject(Number(m[1]));
@@ -931,6 +1006,10 @@ export const routes = [
931
1006
  ctx.errorSpaced(mapped[0], mapped[1]);
932
1007
  return;
933
1008
  }
1009
+ if (['1', 'true', 'yes'].includes(ctx.q('inline', '0'))) {
1010
+ sendProjectFileInline(ctx, resolved.path);
1011
+ return;
1012
+ }
934
1013
  if (['1', 'true', 'yes'].includes(ctx.q('raw', '0'))) {
935
1014
  sendProjectFileRaw(ctx, resolved.path);
936
1015
  return;
@@ -49,7 +49,7 @@ import {
49
49
  sessFamily, sessionOwned, sessionPermissionMode,
50
50
  } from '../lib/servercaps.js';
51
51
  import {
52
- boardOwnsSid, boardSessionRunning, interactionOfSid, projectBusy, sessionQueueState,
52
+ boardOwnsSid, boardSessionRunning, projectBusy, sessionQueueState,
53
53
  taskSince, unitState,
54
54
  } from '../lib/serverstate.js';
55
55
 
@@ -244,7 +244,11 @@ export async function boardSessionMessages(ctx, projectId) {
244
244
  data.card_column = owner.column_key;
245
245
  data.card_worktree = String(dbmod.rowOpt(owner, 'worktree', '') || '').trim();
246
246
  data.answer_pending = board.isAnswerPending(ownerCid, options);
247
- data.interaction = interactionOfSid(sid); // M3:调和器缓存未搬 ⇒ null(见 serverstate)
247
+ // 待答交互(会话窗问答对话框的数据源):读**调和器缓存** `board._IW_CACHE`
248
+ // —— 由 `board.iwOnce` 每轮从 dsh 状态帧写;缓存为空/无等待时为 null。
249
+ // 这里曾经误用 `serverstate.interactionOfSid`(M3 的「空实例」占位,恒 null),
250
+ // 导致 Node 后端下会话窗永远不出现提问/审批卡片(用户报障:卡 980)。
251
+ data.interaction = board.interactionOfSid(sid);
248
252
  data.owned = sessionOwned(sid); // 三态:true 池内 / false 外部 / null 未知
249
253
  data.family = family;
250
254
  if (family === 'dsh_plugin') {
@@ -700,18 +704,71 @@ export async function injectMsg(ctx, project, sid, body, taskId = null) {
700
704
  *
701
705
  * `chat.stop` 自 M4 起是 **async**(要 await 驱动的 /cancel):漏 await 会让「停止」在
702
706
  * 响应之后才生效——`cancelQueued` 的落库态与驱动的中断调用都会晚一拍。
707
+ *
708
+ * **2026-10-17 加 `mode`**(设计《双通道继续与停止同效》§4.2(d)、本批只落平台侧):
709
+ * body `{"mode":"cancel_queue"}` ⇒ 只取消排队消息、**不碰轮次**(走 `cancelQueueAction`,
710
+ * 响应回带被取消消息的全文供前端放回输入框);缺省/其它值 ⇒ 原样「停止」(旧前端不带体
711
+ * 照常走这条,行为与响应体一字不变)。为什么必须拆开:没有在跑的轮次时会话窗那个
712
+ * 「取消排队」按钮走停止链路必被驱动 404 判成 stopped=false,用户看到的是
713
+ * 「该会话未在运行或无法停止」而排队消息其实已被取消——动作与结果对不上。
703
714
  */
704
715
  export async function taskSessionChatStop(ctx, taskId) {
705
- await ctx.readBody(); // 见上:时机照 Python,值不消费
716
+ const body = await ctx.readBody(); // 见上:时机照 Python;本批起要消费 mode
706
717
  const { task } = ctx.sessionContext(taskId);
707
718
  if (task === null) {
708
719
  ctx.error(404, 'task not found'); // 紧凑
709
720
  return;
710
721
  }
711
- await chat.stop(task.session_id || '', { db: ctx.db });
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 });
712
728
  ctx.text(200, '{"ok":true}', 'application/json; charset=utf-8'); // 紧凑(Python 是字节串)
713
729
  }
714
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
+
715
772
  // ==========================================================================
716
773
  // 任务会话三条(M6-6):server.py 1209/1213/1217 分派 → 3646 / 3849 / 3919
717
774
  // POST /api/tasks/<id>/session/profile → `_api_task_session_profile`(3646)