@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.
@@ -2630,7 +2630,7 @@ export function buildTaskPrompt(project, card, extra = '') {
2630
2630
  const desc = String(card.description || '').trim();
2631
2631
  const body = [title, desc].filter((seg) => seg).join('\n\n') || '(无)';
2632
2632
  const workspace = cardWorkspace(project, card);
2633
- let p = `【任务描述】${body}\n【工作目录】${workspace}`;
2633
+ let p = `${TASK_PROMPT_TAG}${body}\n【工作目录】${workspace}`;
2634
2634
  const wt = String(cardOpt(card, 'worktree') || '').trim();
2635
2635
  if (wt) {
2636
2636
  p += `\n(本任务运行在独立 git worktree 中:分支 ${branchFor(card.id)},`
@@ -3125,6 +3125,15 @@ function requireCardSession(card, sid) {
3125
3125
  * 的卡会话在驱动里只剩观察 ⇒ 首跳 404 时经 `ensureWatch` 钩子(`force=true`)补看管声明后
3126
3126
  * 重试一次。未接线(M5-3)⇒ 不补声明、直接按失败抛(与 Python「声明失败」同一分支)。
3127
3127
  *
3128
+ * **会话存活前置(2026-10-17 修卡 907「TS 侧 /compact 对已结束会话必失败」)**:上面那条
3129
+ * 「池外回落」只对宿主里**仍活着**的会话成立;对宿主里根本没有活 agent 的会话(dsh web
3130
+ * 重启、插件重载、久未接续的旧卡),驱动 `/compact` 必 404「会话已结束(宿主无活动 agent)」
3131
+ * ——补看管声明也救不回来。而 dsh GUI 自己的 `/compact` 对冷会话是按需 resume 再执行命令
3132
+ * (`dsh-api-session-controller.resolve` → `agents.resume`),故这里与投递腿 `deliverNow`
3133
+ * 同款:先 `ensureCardSession` 接回(判据/边界同它——中枢未对齐按「未知」不动、sync 卡不
3134
+ * 收养、回滚阀 `TS_HOST_SESSION_REVIVE=0` 时不动,均为零请求),再走下面的两跳。接不回
3135
+ * **不在此收口**:照旧走两跳,由驱动 404 文案兜底(与修复前完全一致)。
3136
+ *
3128
3137
  * `sid` 必须属于该卡片;不支持的族抛 `Error`(server 转 400)。**async**。
3129
3138
  * @returns {Promise<void>}
3130
3139
  */
@@ -3132,6 +3141,8 @@ export async function compactSession(project, card, sid, options = {}) {
3132
3141
  requireCardSession(card, sid);
3133
3142
  const family = agentFamily(project.agent_path || '');
3134
3143
  if (family !== 'dsh_plugin') throw new Error(RETIRED_MSG);
3144
+ // 会话存活前置:宿主已失去该会话 ⇒ 先按 resume 语义接回,再压缩(与投递同款)
3145
+ await ensureCardSession(project, card, sid, options);
3135
3146
  let last = '';
3136
3147
  for (const attempt of [1, 2]) {
3137
3148
  try {
@@ -3910,6 +3921,12 @@ export const SYNC_BUSY_MTIME_S = 120;
3910
3921
  export const SYNC_TITLE_MAX = 200;
3911
3922
  /** sync 卡描述上限(Python `SYNC_DESC_MAX`) */
3912
3923
  export const SYNC_DESC_MAX = 2000;
3924
+ /**
3925
+ * 平台任务提示词的段标(Python `TASK_PROMPT_TAG`):`buildTaskPrompt` 的首段。
3926
+ * 除拼提示词外还用作**判据**——会话首问以它开头 ⇒ 这个会话是平台按某张卡片的内容拼
3927
+ * 提示词启动的(2026-10-16 起这类卡的标题/描述也跟随 DSH,见 `isPlatformTaskSession`)。
3928
+ */
3929
+ export const TASK_PROMPT_TAG = '【任务描述】';
3913
3930
 
3914
3931
  /** 已跑过「存量子代理卡软删收口」的项目 id(Python `_SYNC_SUBAGENT_SWEPT`,每项目一轮) */
3915
3932
  const SYNC_SUBAGENT_SWEPT = new Set();
@@ -4092,24 +4109,67 @@ export function autoTitleForms(item, sid) {
4092
4109
  export const _auto_title_forms = autoTitleForms;
4093
4110
 
4094
4111
  /**
4095
- * 存量 sync 卡按「DSH 当前标题 + 首问全文」补齐的字段 dict(Python `_sync_card_follow`;
4112
+ * 卡面字段是否仍是「平台按卡片内容拼进首问」的**原文形态**(Python `_in_first_prompt`;
4113
+ * 判自动写入用)。
4114
+ *
4115
+ * 平台起会话时把卡片标题/描述原样拼进首问(`buildTaskPrompt`:标题并入【任务描述】段、
4116
+ * 与描述空行分隔)⇒ 卡面字段只要没被用户改过,就必然能在首问原文里原样找到;用户后来
4117
+ * 改写过的文本通常不在(⇒ 不覆盖,尊重用户编辑)。空/纯空白一律 false(空标题由
4118
+ * `autoTitleForms` 那一档负责)。
4119
+ * @param {string} text
4120
+ * @param {object} item
4121
+ * @returns {boolean}
4122
+ */
4123
+ export function inFirstPrompt(text, item) {
4124
+ const t = String(text === null || text === undefined ? '' : text).trim();
4125
+ return Boolean(t) && String(item.first_prompt || '').includes(t);
4126
+ }
4127
+
4128
+ /** `_in_first_prompt` 的 Python 名别名。 */
4129
+ export const _in_first_prompt = inFirstPrompt;
4130
+
4131
+ /**
4132
+ * 该会话是否「平台按某张卡片的内容拼提示词启动的」(Python `_is_platform_task_session`;
4133
+ * 判据 = 首问以段标开头)。
4134
+ *
4135
+ * 卡片启动腿(`enterDoing` → `buildTaskPrompt`)拼的首问以 `TASK_PROMPT_TAG` 开头;
4136
+ * 用户在 dsh GUI 里直跑的会话首问是用户原文、续跑腿是「继续」,都不会命中。用于把正向
4137
+ * 同步(DSH 标题/首问 → 卡面)从 sync 卡扩到**平台从看板启动过会话的卡**(2026-10-16
4138
+ * 用户诉求:卡面标题/正文与 DSH 保持一致;见 spec/board §48)。
4139
+ * @param {object} item
4140
+ * @returns {boolean}
4141
+ */
4142
+ export function isPlatformTaskSession(item) {
4143
+ return String(item.first_prompt || '').replace(/^\s+/, '').startsWith(TASK_PROMPT_TAG);
4144
+ }
4145
+
4146
+ /** `_is_platform_task_session` 的 Python 名别名。 */
4147
+ export const _is_platform_task_session = isPlatformTaskSession;
4148
+
4149
+ /**
4150
+ * 卡面按「DSH 当前标题 + 首问全文」补齐的字段 dict(Python `_sync_card_follow`;
4096
4151
  * 无变化返回 `{}`)。
4097
4152
  *
4098
4153
  * 只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
4099
- * 只在卡仍由平台自动写入时动——用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动);
4100
- * 描述仅在为空、或仍是**旧口径自动写入**(首问除首行)时改成首问全文。
4154
+ * 只在卡仍由平台自动写入时动——卡标题 ∈ `autoTitleForms`(空 / sid 短码 / 任一枚会话
4155
+ * 标题事件 / 旧口径的首问首行)**或**仍是平台拼进首问的原文(`inFirstPrompt`,
4156
+ * 2026-10-16 增:服务「平台从看板启动过会话的卡」——它的标题是用户在卡里敲的原文,
4157
+ * 不在任何标题事件里);用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动)。
4158
+ * 描述仅在为空、仍是**旧口径自动写入**(首问除首行)、或仍是平台拼进首问的原文时改成
4159
+ * 首问全文;用户写过的描述不覆盖。
4101
4160
  * @returns {object}
4102
4161
  */
4103
4162
  export function syncCardFollow(card, item, sid) {
4104
4163
  if (String(card.session_id || sid) !== String(sid)) return {};
4105
4164
  const curTitle = String(card.title || '').trim();
4106
- if (!autoTitleForms(item, sid).has(curTitle)) return {};
4165
+ if (!autoTitleForms(item, sid).has(curTitle) && !inFirstPrompt(curTitle, item)) return {};
4107
4166
  const [title, desc] = syncCardFields(item, sid);
4108
4167
  const out = {};
4109
4168
  if (curTitle !== title) out.title = title;
4110
4169
  const curDesc = String(card.description || '').trim();
4111
4170
  const legacyDesc = splitFirstPrompt(item.first_prompt || '')[1];
4112
- if (desc && curDesc !== desc && (curDesc === '' || curDesc === legacyDesc)) {
4171
+ if (desc && curDesc !== desc
4172
+ && (curDesc === '' || curDesc === legacyDesc || inFirstPrompt(curDesc, item))) {
4113
4173
  out.description = desc;
4114
4174
  }
4115
4175
  return out;
@@ -4126,6 +4186,10 @@ export const _sync_card_follow = syncCardFollow;
4126
4186
  * busy→doing / 空闲→review;存量 sync 卡按同口径补齐 + 归档→done;会话存储被删的 sync 卡
4127
4187
  * 自动进 done;绑子代理会话的存量 sync 卡收口进回收站(每项目一轮);两类误建卡的存量也按
4128
4188
  * 同款口径收口(每项目一轮)。sync 卡不占 runner 项目占用(外部会话平台控制不了)。
4189
+ * **平台从看板启动过会话的卡**(用户建卡 → 开始/回车启动,首问 = 平台拼的任务提示词,
4190
+ * 见 `isPlatformTaskSession`)同样按上面同一口径补齐卡面——但**列语义不动**:归档/取消
4191
+ * 归档的列映射与各类存量收口仍只服务 `origin='sync'` 卡(用户自己的卡,平台只同步
4192
+ * 标题/描述,不替用户搬列、不软删)。用户改过卡名 ⇒ 整卡不动(含描述)。
4129
4193
  *
4130
4194
  * **async**:本函数自身不打驱动,但 `setCardPlaceholder`→无;`webBusy` 走 `dshevents` 内存读。
4131
4195
  * 除 `subagentSession`(注入缝)外全同步——保持 `async` 只为「调用方统一 await」的形态一致。
@@ -4164,11 +4228,14 @@ export async function syncSessions(project, options = {}) {
4164
4228
  { session_id: sid, sessions: pyJsonDumps([sid]), column_key: col, origin: 'sync' },
4165
4229
  options);
4166
4230
  created.push(cid);
4167
- } else if (c.origin === 'sync') {
4168
- // 首问补齐(2026-10-07):标题/描述仍是平台自动写入形态时按首问覆盖
4231
+ } else if (c.origin === 'sync' || isPlatformTaskSession(it)) {
4232
+ // 卡面补齐(2026-10-07 起服务 sync 卡;2026-10-16 起扩到「平台从看板启动过会话的卡」,
4233
+ // 见 `syncCardFollow`):标题/描述仍是平台自动写入形态时按 DSH 当前标题 + 首问全文
4234
+ // 覆盖(用户改过名整卡不动)
4169
4235
  const fields = syncCardFollow(c, it, sid);
4170
4236
  if (Object.keys(fields).length) dbmod.updateBoardCard(c.id, true, fields, options);
4171
- if ((c.column_key === 'doing' || c.column_key === 'review')
4237
+ if (c.origin === 'sync'
4238
+ && (c.column_key === 'doing' || c.column_key === 'review')
4172
4239
  && !hasActiveRun(c.id)) {
4173
4240
  // dsh 的 busy/空闲 列映射已移交调和器;此处保留归档→done(archived 来源=
4174
4241
  // list_sessions 的宿主归档集文件读,与调和器读驱动帧是两条独立通道)
@@ -5070,8 +5137,14 @@ export function startInteractionWatcher() {
5070
5137
  }, delay).unref?.();
5071
5138
  };
5072
5139
  try {
5073
- dshMod.subscribe(() => kick());
5074
- } catch { /* 订阅面不可用(未连接):兜底拍照常 */ }
5140
+ eventSubscribe(() => kick());
5141
+ } catch (e) {
5142
+ // 留痕(2026-10-16 实障):这里原本写的是 `dshMod.subscribe(...)`——`dshMod` 是
5143
+ // `./dshdriver.js`(无该导出),TypeError 被空 catch 吞掉 ⇒ 事件面从没接上,调和器
5144
+ // 退化成只有 `IW_SAFETY_SECONDS` 兜底拍(生产实测:待审核卡追问后 ~60s 才进开发列)。
5145
+ // 事件面若真的不可用,必须能定位(不再是静默降级)。
5146
+ console.log(`[board] 调和器事件订阅失败(退化为 ${IW_SAFETY_SECONDS}s 兜底轮询): ${errText(e)}`);
5147
+ }
5075
5148
  IW_TIMER = setInterval(() => {
5076
5149
  IW_LAST = nowSec();
5077
5150
  void iwTick(); // 兜底对账(60s);事件面未接时的唯一驱动
@@ -1171,6 +1171,43 @@ export function cancelQueued({ sid = null, cardId = null } = {}, options = {}) {
1171
1171
  return count;
1172
1172
  }
1173
1173
 
1174
+ /**
1175
+ * 取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
1176
+ *
1177
+ * 与 `cancelQueued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
1178
+ * 前端要把消息原文放回输入框,而 `msgsOfSid` 下发给前端的展示文本被截到 120 字——
1179
+ * 回填必须由服务端给**全量原文**,否则长消息被静默截断。
1180
+ *
1181
+ * 逐行只在 `cancel()` 真命中(`chat_msgs` 的 queued→cancelled 原子守卫是仲裁点,R12)
1182
+ * 时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
1183
+ * 前端不会把仍在执行的消息错放回输入框。返回按入队顺序(`msgRows` 的 created_at,id)。
1184
+ *
1185
+ * `filter.msgId` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
1186
+ *
1187
+ * @param {{sid?: string|null, cardId?: number|string|null, msgId?: string|null}} [filter]
1188
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1189
+ * @returns {{cancelled: number, msgs: Array<{id: string, text: string}>}}
1190
+ */
1191
+ export function cancelQueuedDetail({ sid = null, cardId = null, msgId = null } = {}, options = {}) {
1192
+ if (!sid && !cardId && !msgId) return { cancelled: 0, msgs: [] };
1193
+ let rows;
1194
+ if (msgId) {
1195
+ const one = waitq.msgGet(msgId, options);
1196
+ if (one === null || one.state !== STATE_QUEUED) return { cancelled: 0, msgs: [] };
1197
+ rows = [one];
1198
+ } else {
1199
+ rows = waitq.msgRows({ sid, cardId, states: [STATE_QUEUED] }, options);
1200
+ }
1201
+ const msgs = [];
1202
+ for (const row of rows) {
1203
+ // 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
1204
+ const text = row.message === null || row.message === undefined ? '' : String(row.message);
1205
+ if (!cancel(row.id, options)) continue;
1206
+ msgs.push({ id: row.id, text });
1207
+ }
1208
+ return { cancelled: msgs.length, msgs };
1209
+ }
1210
+
1174
1211
  // --------------------------------------------------------------------------
1175
1212
  // 立即注入(chat.py:452-493)
1176
1213
  // --------------------------------------------------------------------------
@@ -1684,6 +1684,8 @@ function requireCfgDict(cfg) {
1684
1684
  */
1685
1685
  export async function saveUserConfig(userId, body, options = {}) {
1686
1686
  const cfg = requireCfgDict(userConfig(userId, options));
1687
+ // M11:换应用前后的凭据快照(自动「确认切换」卡片用旧应用凭据发,见函数尾)
1688
+ const oldCfg = { app_id: cfg.app_id || '', app_secret: cfg.app_secret || '' };
1687
1689
  // 推送总控(2026-10-10):与旧 `enabled` 是两个独立开关,缺省=不改(不在 body 里就不动)
1688
1690
  if (Object.hasOwn(body, 'push_enabled')) cfg.push_enabled = pyTruthy(body.push_enabled) ? 1 : 0;
1689
1691
  if (Object.hasOwn(body, 'enabled')) cfg.enabled = pyTruthy(body.enabled) ? 1 : 0;
@@ -1693,6 +1695,12 @@ export async function saveUserConfig(userId, body, options = {}) {
1693
1695
  setFeishuUserCfg(userId, cfg, options);
1694
1696
  const started = startInboundFor(userId, options);
1695
1697
  const verify = appConfig(userId, options) ? await verifyCredentials(userId, options) : null;
1698
+ // M11:手工换应用(非扫码路径)同样发「确认切换」卡片——旧连接还活着时用旧应用通道
1699
+ // 通知用户点确认。后台发(不阻塞保存响应);条件在 maybeSendSwitchCard 里复核,
1700
+ // 凭据没变/线程没跑时是零请求空转;自动卡片故障绝不反噬保存主流程。
1701
+ if (Object.hasOwn(body, 'app_id') || Object.hasOwn(body, 'app_secret')) {
1702
+ void maybeSendSwitchCard(userId, oldCfg, options).catch(() => {});
1703
+ }
1696
1704
  return { inbound_started: Boolean(started), verify };
1697
1705
  }
1698
1706
 
@@ -1733,6 +1741,7 @@ export function startInboundAll(options = {}) {
1733
1741
  // 本节新增的依赖(ESM import 声明提升,等价于置顶;放末尾以严守「只追加」的写作用域)
1734
1742
  import { setFeishuBinding, setFeishuUserCfg } from './db.js';
1735
1743
  import { inboundStatus, userConfig } from './feishu_cfg.js';
1736
- import { startInboundAll as inboundStartAll, startInboundFor as inboundStartFor } from './feishu_inbound.js';
1744
+ import { startInboundAll as inboundStartAll, maybeSendSwitchCard,
1745
+ startInboundFor as inboundStartFor } from './feishu_inbound.js';
1737
1746
  import * as feishuConv from './feishu_conv.js';
1738
1747
  import { verifyCredentials } from './feishu_provision.js';
@@ -50,6 +50,12 @@ export const FEISHU_DEFAULT_EVENTS = Object.freeze(['blocked_interaction', 'task
50
50
 
51
51
  /** 入站线程注册表(`user_id` → 线程形对象;对齐 `feishu._WS_THREADS`,见文件头 M7.5 接缝) */
52
52
  const INBOUND_THREADS = new Map();
53
+ /**
54
+ * `user_id` → **拉起该线程时**用的 app_id(对齐 `feishu._WS_APP_ID`,M10 2026-10-17)。
55
+ * 为什么需要:lark SDK 无干净停止 ⇒ 换凭据后旧连接仍在跑、只有重启站点才换;
56
+ * doctor 靠它判「线程绑的应用 ≠ 配置里的应用」,否则那一项会一直假绿。
57
+ */
58
+ const INBOUND_APP_IDS = new Map();
53
59
 
54
60
  /**
55
61
  * Python 真值口径(`""`/`0`/`None`/`False`/空容器为假)。
@@ -193,7 +199,8 @@ export function hookOf(projectId, options = {}) {
193
199
  * `configured` = 应用凭据齐全(`appConfig` 非 null);`thread` = 注册表里的线程还活着。
194
200
  *
195
201
  * `thread: true` 只代表线程还在 `client.start()` 内(SDK 内置重连),连接失败细节看 server.log。
196
- * **当前恒为 false**——注册方是 M7.5(见文件头接缝),这不是占位假值而是今天的正确状态。
202
+ * **形状是 `GET /api/me/feishu-cfg` 响应的子对象 ⇒ 只许这两键**(「线程绑的哪个应用」
203
+ * 不放进这里,见 `inboundThreadAppId`;加键会改对外契约,node 的形状判据当场会红)。
197
204
  * @param {number} userId
198
205
  * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
199
206
  * @returns {{configured: boolean, thread: boolean}}
@@ -207,14 +214,28 @@ export function inboundStatus(userId, options = {}) {
207
214
  };
208
215
  }
209
216
 
217
+ /**
218
+ * **拉起入站线程时**用的 app_id(对齐 `feishu.inbound_thread_app_id`,M10 2026-10-17):
219
+ * 换凭据后旧连接仍在跑、只有重启站点才换 ⇒ doctor 靠它判「线程绑的应用 ≠ 配置里的应用」
220
+ * 的假绿。空串 = 本进程没为该用户拉过线程 ⇒ 调用方不许据此告警。
221
+ * 刻意**不并进 `inboundStatus`**:那是 `GET /api/me/feishu-cfg` 的响应子对象。
222
+ * @param {number} userId
223
+ * @returns {string}
224
+ */
225
+ export function inboundThreadAppId(userId) {
226
+ return INBOUND_APP_IDS.get(userId) || '';
227
+ }
228
+
210
229
  /**
211
230
  * 【M7.5 接缝】登记某用户的入站线程(对齐 `feishu._WS_THREADS[user_id] = t`,feishu.py:1643)。
212
231
  * 值的契约:`{ isAlive(): boolean }`(与 `threading.Thread` 同形)。幂等覆盖,不校验重复。
213
232
  * @param {number} userId
214
233
  * @param {{isAlive: () => boolean}} thread
234
+ * @param {string} [appId] 拉起该线程时用的 app_id(对齐 `feishu._WS_APP_ID[user_id]`)
215
235
  */
216
- export function setInboundThread(userId, thread) {
236
+ export function setInboundThread(userId, thread, appId = '') {
217
237
  INBOUND_THREADS.set(userId, thread);
238
+ INBOUND_APP_IDS.set(userId, appId || '');
218
239
  }
219
240
 
220
241
  /**
@@ -225,4 +246,17 @@ export function setInboundThread(userId, thread) {
225
246
  */
226
247
  export function clearInboundThread(userId) {
227
248
  INBOUND_THREADS.delete(userId);
249
+ INBOUND_APP_IDS.delete(userId);
250
+ }
251
+
252
+ /**
253
+ * 【M11 接缝】取某用户当前登记的入站 runner(**内部用**:M11 起要在进程内停连接,
254
+ * `isAlive()` 不够——得拿到 runner 背后的 ws 客户端,见 `feishu_inbound.js:stopInboundFor`)。
255
+ * Python 侧没有对应函数:那边直接读模块级 `_WS_THREADS`/`_WS_CLIENTS` 两个字典。
256
+ * **不并进 `inboundStatus`**(那是 `GET /api/me/feishu-cfg` 的响应子对象,形状只许两键)。
257
+ * @param {number} userId
258
+ * @returns {{isAlive: () => boolean}|null}
259
+ */
260
+ export function inboundRunner(userId) {
261
+ return INBOUND_THREADS.get(userId) || null;
228
262
  }