@shgroup/dsh-serenity-hooks 1.29.0 → 1.29.2

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.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.29.0",
3
+ "version": "1.29.2",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):真实 DSH 工具 cc_fs/session/acc_msm/eap/neat/cce/handyman/session_rebuild/skiff_admin + 拦截缝机械约束(safe-mode/路径守卫)+ 高级设定面板(双端口网关/账号管理)+ Skiff 认知子集角色(实验性)。适配 DSH 公开版(0.1.0-rc,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -2919,6 +2919,43 @@ function isSessionDirName(dirName) {
2919
2919
  return /^\d{4}-\d{2}-\d{2}--/.test(dirName) && dirName.length > 11;
2920
2920
  }
2921
2921
  /**
2922
+ * 从 dsh 会话标题解析 SESSION 目录(编码无关 best-match,U3/U4)——
2923
+ * 标题不假设 S### 前缀(CCC 可自定义编码:apaas-xxx / P### / 完整目录名等)。
2924
+ * 匹配优先级(全部对 AGENT_SESSIONS 现有目录 best-match,不猜):
2925
+ * ① 标题即完整目录名(含日期前缀 `YYYY-MM-DD--`)→ 精确命中
2926
+ * ② 标题含 `--<code>--` 段(如完整目录名被截断为 `<code>-日期-概括` 前段)→
2927
+ * 按 code 段匹配:取标题首 token(`-` 前),与各目录 `--<code>--`/`--<code>` 尾段比对
2928
+ * ③ 唯一模糊子串匹配(多个则返回 null 防误猜)
2929
+ * @param title dsh 会话标题(如 `S142-2026-08-24-概括` / `apaas-26116-…` / 完整目录名)
2930
+ * @param sessionsDir AGENT_SESSIONS 绝对路径
2931
+ * @returns 命中目录的绝对路径(SESSION.md);无/歧义返回 null
2932
+ */
2933
+ function resolveSessionByTitle(title, sessionsDir) {
2934
+ const t = (title ?? "").trim();
2935
+ if (!t) return null;
2936
+ const all = readAllSessions(sessionsDir);
2937
+ if (all.length === 0) return null;
2938
+ if (isSessionDirName(t)) {
2939
+ const exact = all.find((s) => s.dirName === t);
2940
+ if (exact) return join(exact.path, SESSION_MD);
2941
+ }
2942
+ const codeToken = t.split("-")[0]?.trim() ?? "";
2943
+ if (codeToken) {
2944
+ const byCode = all.filter((s) => {
2945
+ const m = s.dirName.match(/--([^--]+)--/);
2946
+ const code = m ? m[1] : null;
2947
+ const tailMatch = s.dirName.match(/--([^--]+)$/);
2948
+ const tailCode = tailMatch && !s.dirName.includes("--", s.dirName.lastIndexOf("--") + 3) ? tailMatch[1] : null;
2949
+ return code === codeToken || tailCode === codeToken || s.dirName === codeToken || s.dirName.includes(`--${codeToken}`);
2950
+ });
2951
+ if (byCode.length === 1) return join(byCode[0].path, SESSION_MD);
2952
+ if (byCode.length > 1) return null;
2953
+ }
2954
+ const fuzzy = all.filter((s) => s.dirName.toLowerCase().includes(t.toLowerCase()));
2955
+ if (fuzzy.length === 1) return join(fuzzy[0].path, SESSION_MD);
2956
+ return null;
2957
+ }
2958
+ /**
2922
2959
  * 约定回退(v1.24.11):AGENT_SESSIONS 下最新修改的**未完成**会话的 SESSION.md。
2923
2960
  * readAllSessions 已按「未完成优先 + mtime 降序」排序 → 首个未完成且含 SESSION.md 即最新活动。
2924
2961
  * 只作最后手段(内存/events/锚点全缺时),保证重建锚点至少指向一个真实存在的轨迹。
@@ -3179,6 +3216,56 @@ function qaCheck(root, key) {
3179
3216
  return lines.join("\n");
3180
3217
  }
3181
3218
  //#endregion
3219
+ //#region src/session-bound.ts
3220
+ /** 事件 type 常量(声明 + 读取共用) */
3221
+ const SESSION_BOUND_EVENT = "serenity/bound";
3222
+ /** 事件形状归一(运行时数据经 session.append 深冻结,此处仅类型收窄) */
3223
+ function asBoundEvent(e) {
3224
+ const ev = e;
3225
+ if (!ev || ev.type !== "serenity/bound") return null;
3226
+ const d = ev.data;
3227
+ if (!d || typeof d.dirName !== "string" || typeof d.mdPath !== "string") return null;
3228
+ return d;
3229
+ }
3230
+ /**
3231
+ * 读取会话日志中**最后一条** `serenity/bound`(权威绑定,latest-wins)。
3232
+ * 尾到头扫描(时间序最新在后);无 bound 事件返回 null。
3233
+ * 纯逻辑零 DSH 依赖(经 sessionEvents helper 读 snapshotEvents/events 兜底)。
3234
+ */
3235
+ function readLastBound(session) {
3236
+ const events = sessionEvents(session);
3237
+ for (let i = events.length - 1; i >= 0; i--) {
3238
+ const b = asBoundEvent(events[i]);
3239
+ if (b) return b;
3240
+ }
3241
+ return null;
3242
+ }
3243
+ /**
3244
+ * append 一条 `serenity/bound` 绑定事件(log-only,无 surfaceOp——元数据不进模型可见面)。
3245
+ * @param session 目标 dsh 会话(真实 Session;append 泛型经内部断言兼容——类型由
3246
+ * `serenity/bound` 声明面保证,append 接受已声明事件)
3247
+ * @param action 绑定动作
3248
+ * @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
3249
+ * @returns 是否成功(append 抛错/会话不可用时 false——绑定失败不阻断主流程)
3250
+ */
3251
+ function appendBound(session, action, rec) {
3252
+ if (!session || typeof session.append !== "function") return false;
3253
+ try {
3254
+ const append = session.append;
3255
+ append(SESSION_BOUND_EVENT, {
3256
+ dirName: rec.dirName,
3257
+ mdPath: rec.mdPath,
3258
+ ...rec.sessionId ? { sessionId: rec.sessionId } : {},
3259
+ action,
3260
+ at: Date.now(),
3261
+ ...rec.note ? { note: rec.note } : {}
3262
+ });
3263
+ return true;
3264
+ } catch {
3265
+ return false;
3266
+ }
3267
+ }
3268
+ //#endregion
3182
3269
  //#region src/tools/session.ts
3183
3270
  /**
3184
3271
  * session.ts — session 真实 DSH 工具定义(defineTool)
@@ -3203,6 +3290,21 @@ function agentCwd$5(exec) {
3203
3290
  function agentScope$2(exec) {
3204
3291
  return exec.agent?.session?.id ?? "default";
3205
3292
  }
3293
+ /** 当前 dsh 会话对象(append bound 用);无 → null */
3294
+ function agentDshSession(exec) {
3295
+ const s = exec.agent?.session;
3296
+ return s && typeof s.append === "function" ? { append: s.append } : null;
3297
+ }
3298
+ /**
3299
+ * 当前绑定的权威 dirName(优先持久化 bound——防内存被误 use 污染;无 bound 回退内存 active)。
3300
+ * 供 G1 守卫对比目标会话是否切换。
3301
+ */
3302
+ function currentBoundDirName(exec, scope) {
3303
+ const dsh = agentDshSession(exec);
3304
+ const bound = dsh ? readLastBound(dsh) : null;
3305
+ if (bound) return bound.dirName;
3306
+ return getActiveSessionInfo(scope)?.dirName ?? null;
3307
+ }
3206
3308
  /**
3207
3309
  * 清洗 + 截断会话概括(需求② S142 用户拍板:编号日期后加 ≤20 字内容概括)。
3208
3310
  * 规则(服务端统一,不信任 LLM 输入):
@@ -3275,18 +3377,6 @@ function renameDshSessionOnUse(deps, session, titles, active, summary) {
3275
3377
  }
3276
3378
  }
3277
3379
  /**
3278
- * 从 create 结果构造命名用 ActiveSessionInfo(v1.25.11,S142 用户:create 也要命名):
3279
- * createSession 返回的 result 已含 sessionId(S###/issue)与 dirName——无需等待 use 激活,
3280
- * 直接构造(mdPath = sessionPath/SESSION.md,与 use 时一致)即可驱动 renameDshSessionOnUse。
3281
- */
3282
- function activeInfoFromCreate(result) {
3283
- return {
3284
- sessionId: result.sessionId,
3285
- dirName: result.dirName,
3286
- mdPath: join(result.sessionPath, "SESSION.md")
3287
- };
3288
- }
3289
- /**
3290
3380
  * 把当前 dsh 会话重命名为指定 SESSION 的命名标题(use/create 共用;v1.25.11)。
3291
3381
  * 需求②:summary 参数(≤20 字概括)透传——标题带概括(编号日期固定派生)。
3292
3382
  * 门控/失败可见性与 renameDshSessionOnUse 一致(不静默:成功 log / 失败 warn)。
@@ -3440,6 +3530,10 @@ function createSessionTool(ctx) {
3440
3530
  type: "boolean",
3441
3531
  description: "close must be true (prevents accidental close)"
3442
3532
  },
3533
+ force: {
3534
+ type: "boolean",
3535
+ description: "use: allow switching away from the currently-bound session (binding guard override)"
3536
+ },
3443
3537
  dryRun: {
3444
3538
  type: "boolean",
3445
3539
  description: "create/archive preview mode (no actual changes)"
@@ -3474,7 +3568,12 @@ function createSessionTool(ctx) {
3474
3568
  dryRun: isDryRun
3475
3569
  });
3476
3570
  let message = result.message;
3477
- if (!isDryRun) renameDshSessionForActive(ctx, exec, activeInfoFromCreate(result), args.summary ?? args.issue);
3571
+ if (!isDryRun) appendBound(agentDshSession(exec), "create", {
3572
+ dirName: result.dirName,
3573
+ mdPath: join(result.sessionPath, "SESSION.md"),
3574
+ sessionId: result.sessionId,
3575
+ note: "created (binding unchanged until explicit use)"
3576
+ });
3478
3577
  if (!isDryRun && cccHooks.includes("create-transform")) try {
3479
3578
  const hookResult = await runMsmAsync(root, {
3480
3579
  action: "exec",
@@ -3492,14 +3591,47 @@ function createSessionTool(ctx) {
3492
3591
  if (!args.name) throw new Error("use requires name (S### or directory name)");
3493
3592
  if (!args.summary || args.summary.trim() === "") throw new Error("use requires --summary <content summary ≤20 chars> (appended to the dsh session title as S###-YYYY-MM-DD-<summary>; id and date stay server-derived)");
3494
3593
  const scope = agentScope$2(exec);
3594
+ const targetEntry = findSession(join(root, "AGENT_SESSIONS"), args.name);
3595
+ if (!targetEntry) throw new Error(`Session not found: "${args.name}". Use "list" to see available sessions.`);
3596
+ const targetDirName = targetEntry.dirName;
3597
+ const currentDir = currentBoundDirName(exec, scope);
3598
+ const switching = currentDir !== null && targetDirName !== currentDir;
3599
+ const force = args.force === true;
3600
+ if (switching && !force) throw new Error(`Session is bound to ${currentDir}. Switching to ${targetDirName} would orphan the current trajectory. Re-run with --force to switch (or close the current session first).`);
3495
3601
  const active = useSession(root, args.name, scope);
3496
3602
  const info = getActiveSessionInfo(scope);
3497
3603
  if (info) renameDshSessionForActive(ctx, exec, info, args.summary);
3604
+ const dsh = agentDshSession(exec);
3605
+ if (dsh && info) {
3606
+ const prevBound = readLastBound(dsh);
3607
+ appendBound(dsh, switching && prevBound ? "switch" : "activate", {
3608
+ dirName: info.dirName,
3609
+ mdPath: info.mdPath,
3610
+ sessionId: info.sessionId,
3611
+ ...switching ? { note: "forced switch" } : {}
3612
+ });
3613
+ }
3498
3614
  return active;
3499
3615
  }
3500
- case "close":
3501
- if (!args.name) throw new Error("close requires name (S### or directory name)");
3502
- return closeSession(root, args.name, args.confirm ?? false, agentScope$2(exec));
3616
+ case "close": {
3617
+ const scope = agentScope$2(exec);
3618
+ const requested = args.name?.trim() ?? "";
3619
+ const boundDir = currentBoundDirName(exec, scope);
3620
+ if (boundDir && requested) {
3621
+ if ((findSession(join(root, "AGENT_SESSIONS"), requested)?.dirName ?? requested) !== boundDir) throw new Error(`Session is bound to ${boundDir}. close targets the bound session — pass the bound session (or omit name) to close it; closing ${requested} while bound to another session is not allowed.`);
3622
+ }
3623
+ if (!boundDir && !requested) throw new Error("close requires name (no active binding to close) — pass the session to close.");
3624
+ const closed = closeSession(root, boundDir ? boundDir : requested, args.confirm ?? false, scope);
3625
+ const dsh = agentDshSession(exec);
3626
+ const bound = dsh ? readLastBound(dsh) : null;
3627
+ if (bound) appendBound(dsh, "release", {
3628
+ dirName: bound.dirName,
3629
+ mdPath: bound.mdPath,
3630
+ sessionId: bound.sessionId,
3631
+ note: "session closed"
3632
+ });
3633
+ return closed;
3634
+ }
3503
3635
  case "archive": return archiveSessions(root, {
3504
3636
  name: args.name,
3505
3637
  dryRun: args.dryRun ?? false
@@ -3883,15 +4015,19 @@ function stripAckSuffix(text) {
3883
4015
  /**
3884
4016
  * 构建重建锚点消息(v1.22.4 定稿语义 + v1.22.5 保留 first-anchor 正文):
3885
4017
  * 「[TRAJECTORY-REBUILD] + first-anchor 协议正文(去 acknowledge 尾句)+ 继续 {SESSION 名} 的工作」。
4018
+ * v1.29.2(R1):可选 task focus 段——简短任务焦点文字传入重建后会话(不含历史,
4019
+ * 历史完整在 SESSION.md;让重建后的自己第一时间理解本轮焦点,降低冷启动认知成本)。
3886
4020
  * @param root - CCC 根
3887
4021
  * @param sessionName - 当前 use 的宁静号 SESSION 名(如 S142);无激活则通用指令
3888
4022
  * @param activeMdPath - 持久轨迹 SESSION.md 绝对路径(保持原位)
3889
4023
  * @param anchorMessages - first-anchor 协议消息序列(缺省 DEFAULT_ANCHOR_MESSAGES;可注入测试)
4024
+ * @param focus - 可选任务焦点(单行化 + ≤200 字消毒;无则不输出 focus 段——向后兼容)
3890
4025
  */
3891
- function buildRebuildAnchor(root, sessionName, activeMdPath, anchorMessages = DEFAULT_ANCHOR_MESSAGES) {
4026
+ function buildRebuildAnchor(root, sessionName, activeMdPath, anchorMessages = DEFAULT_ANCHOR_MESSAGES, focus) {
3892
4027
  const rel = activeMdPath.startsWith(root) ? activeMdPath.slice(root.length + 1) : activeMdPath;
3893
4028
  const sessionDir = basename(dirname(activeMdPath));
3894
4029
  const sessionLine = sessionDir !== "AGENT_SESSIONS" ? `- Serenity session: ${sessionName !== "" ? `${sessionName} (${sessionDir})` : sessionDir}` : null;
4030
+ const focusLine = sanitizeFocusLine(focus);
3895
4031
  return [
3896
4032
  `${eventToken("rebuild")} The conversation has been cleared and rebuilt (Ship of Theseus: the carrier is replaced, the trajectory continues).`,
3897
4033
  "",
@@ -3900,9 +4036,22 @@ function buildRebuildAnchor(root, sessionName, activeMdPath, anchorMessages = DE
3900
4036
  ...sessionLine ? [sessionLine] : [],
3901
4037
  `- Persistent trajectory — SESSION.md path: ${rel}`,
3902
4038
  ` (the trajectory's persistent body — stays in place through rebuilds)`,
3903
- `- Read that SESSION.md first (goal/decisions/progress/unresolved), then continue from the last checkpoint.`
4039
+ `- Read that SESSION.md first (goal/decisions/progress/unresolved), then continue from the last checkpoint.`,
4040
+ ...focusLine ? [`- Task focus: ${focusLine}`] : []
3904
4041
  ].join("\n");
3905
4042
  }
4043
+ /**
4044
+ * 任务焦点行消毒(纯函数可单测):单行化(换行/回车 → 空格)+ 控制字符清除 +
4045
+ * ≤200 字截断。空/纯空白 → null(不输出 focus 段)。防 LLM 传多行注入伪造锚点结构。
4046
+ */
4047
+ function sanitizeFocusLine(focus) {
4048
+ if (!focus) return null;
4049
+ const single = focus.replace(/[\r\n\t]+/g, " ").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trim();
4050
+ if (single === "") return null;
4051
+ return single.length <= FOCUS_MAX_CHARS ? single : single.slice(0, FOCUS_MAX_CHARS);
4052
+ }
4053
+ /** 任务焦点最大长度(字/码点;200 足够一句话任务焦点——非历史) */
4054
+ const FOCUS_MAX_CHARS = 200;
3906
4055
  /** 从会话 surface 首条 user 消息(重建锚点)解析 SESSION.md 路径(events 异常/缺失时的兜底) */
3907
4056
  function parseAnchorMdPath(session) {
3908
4057
  try {
@@ -3988,11 +4137,20 @@ async function queueRebuild(ctx, opts) {
3988
4137
  if (!session) throw new Error(`Unable to locate dsh session ${dshSessionId} (session may be closed)`);
3989
4138
  const mdPath = resolveSessionMdPath(root, dshSessionId, session);
3990
4139
  if (!mdPath) throw new Error("Unable to determine the active SESSION.md — no session context found in this conversation. Run \"session use <S###> --summary <内容概括 ≤20 字>\" first to activate the trajectory to resume, then retry session_rebuild.");
3991
- const anchor = buildRebuildAnchor(root, getActiveSessionInfo(dshSessionId)?.sessionId ?? sessionNameFromMdPath(mdPath), mdPath);
4140
+ const sessionName = getActiveSessionInfo(dshSessionId)?.sessionId ?? sessionNameFromMdPath(mdPath);
4141
+ const focus = note && note.trim() !== "" ? note : void 0;
4142
+ const anchor = buildRebuildAnchor(root, sessionName, mdPath, DEFAULT_ANCHOR_MESSAGES, focus);
4143
+ appendBound(session, "rebuild", {
4144
+ dirName: basename(dirname(mdPath)),
4145
+ mdPath,
4146
+ sessionId: sessionName.startsWith("S") ? sessionName : void 0,
4147
+ note: "rebuild queued"
4148
+ });
3992
4149
  pendingRebuilds.set(dshSessionId, {
3993
4150
  anchor,
3994
4151
  summary,
3995
4152
  mdPath,
4153
+ focus,
3996
4154
  queuedAt: Date.now()
3997
4155
  });
3998
4156
  writeRebuildDiag(root, {
@@ -4184,7 +4342,7 @@ function createRebuildTool(ctx) {
4184
4342
  parameters: {
4185
4343
  note: {
4186
4344
  type: "string",
4187
- description: "Optional: one-sentence rebuild background note (for the rebuilt self)"
4345
+ description: "Optional: task focus ≤200 chars for the rebuilt self — what to work on next (short, no history; SESSION.md holds the full history). Injected as \"- Task focus: …\" into the rebuild anchor."
4188
4346
  },
4189
4347
  summary: {
4190
4348
  type: "string",
@@ -4812,25 +4970,60 @@ function registerAutopilot(ctx) {
4812
4970
  ctx.on("serenity/settings-changed", () => startTimer());
4813
4971
  } catch {}
4814
4972
  }
4815
- /** 目标 agent:从 SESSION.md 反向定位 dsh 会话(标题 F3 命名 S###-日期 匹配 + cwd 归属校验);不可得 → null */
4973
+ /**
4974
+ * 目标 agent:从 SESSION.md 反向定位 dsh 会话(cwd 归属校验 + **bound 精确匹配优先**,
4975
+ * 标题 F3 命名回退);不可得 → null。
4976
+ *
4977
+ * v1.29.2(R2,用户"autopilot 会话绑定应当更稳固——唤起的时候能唤起最新的、
4978
+ * 绑定 autopilot SESSION 的会话"):v1.29.1 `serenity/bound` 事件是权威绑定
4979
+ * (session use 激活时 append,dirName = 完整 AGENT_SESSIONS 目录名,编码无关 U4)。
4980
+ * 旧实现只按标题猜(=== sid / startsWith(sid-))——若绑定该 SESSION 的 dsh 会话
4981
+ * 标题不是 S###- 前缀(LLM 改过/重建后 rename 异常),绑定明明在却唤起失败。
4982
+ * 加强:**bound.dirName 精确匹配优先**(权威证据),标题匹配降级为回退(存量
4983
+ * 无 bound 的 live 会话兼容);多候选(罕见:同 SESSION 绑定多个 live 会话)取
4984
+ * **绑定最新**(bound.at 最大——最近 use 过 = 最可能当前在用)。
4985
+ */
4816
4986
  function resolveTargetAgent(ctx, mdPath) {
4817
- const idMatch = basename(dirname(mdPath)).match(/--S(\d{3,})--/);
4987
+ const dirName = basename(dirname(mdPath));
4988
+ const idMatch = dirName.match(/--S(\d{3,})--/);
4818
4989
  const sid = idMatch ? `S${idMatch[1]}` : null;
4819
4990
  const targetRoot = findSerenityRoot(mdPath);
4991
+ const sessions = ctx.sessions;
4992
+ const agents = ctx.agents;
4993
+ const candidates = [];
4820
4994
  try {
4821
- const sessions = ctx.sessions;
4822
4995
  for (const s of sessions?.list?.() ?? []) {
4823
- const cwd = s?.header?.cwd ?? "";
4996
+ const sess = s;
4997
+ const cwd = sess?.header?.cwd ?? "";
4824
4998
  if (targetRoot) {
4825
4999
  if (findSerenityRoot(cwd) !== targetRoot) continue;
4826
5000
  } else if (cwd !== "" && !mdPath.startsWith(cwd.endsWith("/") ? cwd : cwd + "/")) continue;
4827
- const title = readSessionTitle(s);
4828
- if (sid && title && (title === sid || title.startsWith(`${sid}-`))) {
4829
- const agent = ctx.agents?.get?.(s.id ?? "");
4830
- if (agent) return agent;
4831
- }
5001
+ const bound = readLastBound(sess);
5002
+ const title = readSessionTitle(sess);
5003
+ const boundMatched = bound !== null && bound.dirName === dirName;
5004
+ const titleMatched = sid !== null && title !== null && (title === sid || title.startsWith(`${sid}-`));
5005
+ if (boundMatched) candidates.push({
5006
+ session: sess,
5007
+ boundAt: bound?.at ?? null,
5008
+ titleMatched
5009
+ });
5010
+ else if (titleMatched) candidates.push({
5011
+ session: sess,
5012
+ boundAt: null,
5013
+ titleMatched
5014
+ });
4832
5015
  }
4833
5016
  } catch {}
5017
+ if (candidates.length === 0) return null;
5018
+ candidates.sort((a, b) => {
5019
+ const aBound = a.boundAt !== null;
5020
+ if (aBound !== (b.boundAt !== null)) return aBound ? -1 : 1;
5021
+ return (b.boundAt ?? 0) - (a.boundAt ?? 0);
5022
+ });
5023
+ for (const c of candidates) {
5024
+ const agent = agents?.get?.(c.session.id ?? "");
5025
+ if (agent) return agent;
5026
+ }
4834
5027
  return null;
4835
5028
  }
4836
5029
  /**
@@ -4850,31 +5043,38 @@ function readSessionTitle(session) {
4850
5043
  }
4851
5044
  /**
4852
5045
  * 诊断:目标会话 agent 为何不可得(供 performAutopilotWake 失败信息——
4853
- * 区分"live 会话里无匹配" vs "匹配但 agent 未加载")。返回诊断文本(无则 null)。
5046
+ * 区分"live 会话" / "标题不匹配" / "bound 或标题命中但 agent 未加载")。
5047
+ * v1.29.2(R2):bound 命中但 agent 未加载也归入「已匹配未加载」(原只查标题)。
5048
+ * 返回诊断文本(无则 null)。
4854
5049
  */
4855
5050
  function diagnoseTargetUnavailable(ctx, mdPath) {
4856
- const idMatch = basename(dirname(mdPath)).match(/--S(\d{3,})--/);
5051
+ const dirName = basename(dirname(mdPath));
5052
+ const idMatch = dirName.match(/--S(\d{3,})--/);
4857
5053
  const sid = idMatch ? `S${idMatch[1]}` : null;
4858
5054
  const targetRoot = findSerenityRoot(mdPath);
4859
5055
  const sameCccTitles = [];
4860
- const agentMissing = { sid: false };
5056
+ const agentMissing = { matched: false };
4861
5057
  try {
4862
5058
  const sessions = ctx.sessions;
4863
5059
  for (const s of sessions?.list?.() ?? []) {
4864
- const cwd = s?.header?.cwd ?? "";
5060
+ const sess = s;
5061
+ const cwd = sess?.header?.cwd ?? "";
4865
5062
  if (targetRoot) {
4866
5063
  if (findSerenityRoot(cwd) !== targetRoot) continue;
4867
5064
  } else if (cwd !== "" && !mdPath.startsWith(cwd.endsWith("/") ? cwd : cwd + "/")) continue;
4868
- const title = readSessionTitle(s);
5065
+ const bound = readLastBound(sess);
5066
+ const title = readSessionTitle(sess);
4869
5067
  if (title) sameCccTitles.push(title);
4870
- if (sid && title && (title === sid || title.startsWith(`${sid}-`))) {
4871
- if (!ctx.agents?.get?.(s.id ?? "")) agentMissing.sid = true;
5068
+ const boundMatched = bound !== null && bound.dirName === dirName;
5069
+ const titleMatched = sid !== null && title !== null && (title === sid || title.startsWith(`${sid}-`));
5070
+ if (boundMatched || titleMatched) {
5071
+ if (!ctx.agents?.get?.(sess.id ?? "")) agentMissing.matched = true;
4872
5072
  }
4873
5073
  }
4874
5074
  } catch {}
4875
- if (agentMissing.sid) return `会话 ${sid} 已打开但 agent 未加载(会话可能刚创建/正在恢复——稍后重试)`;
4876
- if (sameCccTitles.length > 0) return `目标 CCC 内 live 会话标题: [${sameCccTitles.join(", ")}]——均不匹配 ${sid}(目标会话未在 WebUI 打开,或命名未生效)`;
4877
- return `目标 CCC 内无 live 会话(先在 WebUI 打开 ${sid} 会话后重试)`;
5075
+ if (agentMissing.matched) return `会话已绑定/匹配 ${sid ?? dirName} agent 未加载(会话可能刚创建/正在恢复——稍后重试)`;
5076
+ if (sameCccTitles.length > 0) return `目标 CCC 内 live 会话标题: [${sameCccTitles.join(", ")}]——均不匹配 ${sid ?? dirName}(目标会话未在 WebUI 打开,或绑定/命名未生效)`;
5077
+ return `目标 CCC 内无 live 会话(先在 WebUI 打开 ${sid ?? dirName} 会话后重试)`;
4878
5078
  }
4879
5079
  /** Autopilot 绑定的回退 CCC 根:进程 cwd 上溯 .serenity 优先,回退任一 live 会话 root */
4880
5080
  function resolveAutopilotRoot(ctx) {
@@ -6281,6 +6481,17 @@ function registerEntrySkillSection(agent, root) {
6281
6481
  //#endregion
6282
6482
  //#region src/seams/context.ts
6283
6483
  const DEFAULT_ENTRY_SKILL_MAX_CHARS = 3e4;
6484
+ /** 从 dsh 会话日志读标题(latest-wins `session/title` 事件;无返回 null)——供标题 reconcile(U3) */
6485
+ function readDshSessionTitle(session) {
6486
+ try {
6487
+ const events = sessionEvents(session);
6488
+ for (let i = events.length - 1; i >= 0; i--) {
6489
+ const e = events[i];
6490
+ if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
6491
+ }
6492
+ } catch {}
6493
+ return null;
6494
+ }
6284
6495
  function accIdentityText(root, configPaths = DEFAULT_SERENITY_CONFIG_PATHS, entrySkillMaxChars = DEFAULT_ENTRY_SKILL_MAX_CHARS) {
6285
6496
  loadSerenityConfig(root, configPaths);
6286
6497
  const defaultModel = readHandymanConfig(root, configPaths)?.defaultModel;
@@ -6368,17 +6579,56 @@ function registerContext(ctx, opts = {}) {
6368
6579
  const scope = agentScope(agent);
6369
6580
  if (shouldRestoreActive(agent) && getActiveSessionInfo(scope) === null) try {
6370
6581
  if (loadSerenityConfig(root, configPaths).hooks?.autoRestoreSession ?? true) {
6371
- const info = parseSessionContextFromEvents(sessionEvents(agent.session));
6372
- if (info) {
6373
- const abs = info.mdPath.startsWith(root) ? info.mdPath : resolve(root, info.mdPath);
6374
- if (existsSync(abs)) {
6375
- setActiveSessionInfo(scope, {
6376
- ...info,
6377
- mdPath: abs
6378
- });
6379
- console.log(`[serenity-hooks] ↻ 从历史恢复激活会话: ${info.dirName}`);
6582
+ const dshSession = agent.session;
6583
+ let restored = null;
6584
+ const bound = readLastBound(dshSession);
6585
+ if (bound) {
6586
+ const abs = bound.mdPath.startsWith(root) ? bound.mdPath : resolve(root, bound.mdPath);
6587
+ if (existsSync(abs)) restored = {
6588
+ dirName: bound.dirName,
6589
+ mdPath: abs,
6590
+ sessionId: bound.sessionId
6591
+ };
6592
+ }
6593
+ if (!restored) {
6594
+ const info = parseSessionContextFromEvents(sessionEvents(dshSession));
6595
+ if (info) {
6596
+ const abs = info.mdPath.startsWith(root) ? info.mdPath : resolve(root, info.mdPath);
6597
+ if (existsSync(abs)) restored = {
6598
+ dirName: info.dirName,
6599
+ mdPath: abs,
6600
+ sessionId: info.sessionId
6601
+ };
6380
6602
  }
6381
6603
  }
6604
+ if (!restored) {
6605
+ const title = readDshSessionTitle(dshSession);
6606
+ if (title) {
6607
+ const md = resolveSessionByTitle(title, sessionsRoot(root));
6608
+ if (md && existsSync(md)) {
6609
+ const dirName = basename(dirname(md));
6610
+ restored = {
6611
+ dirName,
6612
+ mdPath: md,
6613
+ sessionId: void 0
6614
+ };
6615
+ appendBound(dshSession, "reconcile", {
6616
+ dirName,
6617
+ mdPath: md,
6618
+ note: "auto from title"
6619
+ });
6620
+ console.log(`[serenity-hooks] ↻ 标题兼容持久化绑定: ${title} → ${dirName}`);
6621
+ }
6622
+ }
6623
+ }
6624
+ if (restored) {
6625
+ const derived = restored.sessionId ?? (restored.dirName.match(/--([^--]+)--/) ? restored.dirName.match(/--([^--]+)--/)[1] : void 0) ?? restored.dirName;
6626
+ setActiveSessionInfo(scope, {
6627
+ ...restored,
6628
+ sessionId: derived
6629
+ });
6630
+ console.log(`[serenity-hooks] ↻ 从历史恢复激活会话: ${restored.dirName}`);
6631
+ }
6382
6632
  }
6383
6633
  } catch {}
6384
6634
  if (injected.has(key)) return;
package/lib/rebuild.d.ts CHANGED
@@ -45,12 +45,20 @@ export declare function stripAckSuffix(text: string): string;
45
45
  /**
46
46
  * 构建重建锚点消息(v1.22.4 定稿语义 + v1.22.5 保留 first-anchor 正文):
47
47
  * 「[TRAJECTORY-REBUILD] + first-anchor 协议正文(去 acknowledge 尾句)+ 继续 {SESSION 名} 的工作」。
48
+ * v1.29.2(R1):可选 task focus 段——简短任务焦点文字传入重建后会话(不含历史,
49
+ * 历史完整在 SESSION.md;让重建后的自己第一时间理解本轮焦点,降低冷启动认知成本)。
48
50
  * @param root - CCC 根
49
51
  * @param sessionName - 当前 use 的宁静号 SESSION 名(如 S142);无激活则通用指令
50
52
  * @param activeMdPath - 持久轨迹 SESSION.md 绝对路径(保持原位)
51
53
  * @param anchorMessages - first-anchor 协议消息序列(缺省 DEFAULT_ANCHOR_MESSAGES;可注入测试)
54
+ * @param focus - 可选任务焦点(单行化 + ≤200 字消毒;无则不输出 focus 段——向后兼容)
52
55
  */
53
- export declare function buildRebuildAnchor(root: string, sessionName: string, activeMdPath: string, anchorMessages?: string[]): string;
56
+ export declare function buildRebuildAnchor(root: string, sessionName: string, activeMdPath: string, anchorMessages?: string[], focus?: string | null): string;
57
+ /**
58
+ * 任务焦点行消毒(纯函数可单测):单行化(换行/回车 → 空格)+ 控制字符清除 +
59
+ * ≤200 字截断。空/纯空白 → null(不输出 focus 段)。防 LLM 传多行注入伪造锚点结构。
60
+ */
61
+ export declare function sanitizeFocusLine(focus: string | null | undefined): string | null;
54
62
  export interface RebuildResult {
55
63
  /** 是否成功排队(turn 结束时执行清空重建) */
56
64
  queued: boolean;
@@ -73,6 +81,8 @@ interface PendingRebuild {
73
81
  summary: string;
74
82
  /** 持久轨迹 SESSION.md(重建后重命名标题的编号/日期来源) */
75
83
  mdPath: string;
84
+ /** v1.29.2(R1):任务焦点(可选——重建后会话理解当前任务;不含历史) */
85
+ focus?: string;
76
86
  /** 排队时间(防陈旧队列误清空——超时丢弃) */
77
87
  queuedAt: number;
78
88
  }
@@ -13,6 +13,8 @@ import type { Context } from 'cordis';
13
13
  import type { Agent } from '@deepseek-ai/dsh-agent';
14
14
  import type { UserMessage } from '@deepseek-ai/dsh-session';
15
15
  export declare const DEFAULT_ENTRY_SKILL_MAX_CHARS = 30000;
16
+ /** 从 dsh 会话日志读标题(latest-wins `session/title` 事件;无返回 null)——供标题 reconcile(U3) */
17
+ export declare function readDshSessionTitle(session: unknown): string | null;
16
18
  export declare function accIdentityText(root: string, configPaths?: string[], entrySkillMaxChars?: number): string;
17
19
  /**
18
20
  * ACC 注入消息(S134 去重):**只含简短身份锚点**([ACC] 已激活 + CCC 根 + 约束 + handyman 模型 + Phase 2)。
@@ -0,0 +1,80 @@
1
+ /**
2
+ * session-bound.ts — SESSION 绑定持久化(serenity/bound 会话事件)
3
+ *
4
+ * 目标(S142 用户拍板,方案 v1.0):让「dsh 会话(载体)↔ SESSION(宁静号轨迹)」
5
+ * 的绑定坚固——LLM 不能因智力因素(幻觉/理解偏差)在过程中或 session_rebuild 后
6
+ * 自行更换 SESSION。
7
+ *
8
+ * 机制(DSH 原生,零改 harness):
9
+ * `SessionEventMap` 是 merge-extensible(先例:dsh-compaction 扩展 compaction/*;
10
+ * dsp 已在 append compaction/prune)——插件可 declare module 自定义事件,
11
+ * `Session.append(type, data)` 将其写入会话 append-only 日志,随 session.jsonl
12
+ * 持久化落盘,进程重启后 snapshotEvents() 可完整读取。
13
+ *
14
+ * 权威绑定 = 会话日志中**最后一条** `serenity/bound`(append-only latest-wins)。
15
+ * 本事件是 log-only 元数据(无 surfaceOp——永不上模型可见面,不进系统提示词)。
16
+ *
17
+ * 编码无关(U4):绑定锚 = **完整 AGENT_SESSIONS 目录名**(磁盘唯一存在),
18
+ * sessionId(S###/apaas-xxx/自定义)仅是派生展示字段——绝不假设 S 前缀。
19
+ */
20
+ import type { Session } from '@deepseek-ai/dsh-session';
21
+ import type { SessionEventMap } from '@deepseek-ai/dsh-session/types';
22
+ declare module '@deepseek-ai/dsh-session/types' {
23
+ interface SessionEventMap {
24
+ /**
25
+ * Serenity SESSION binding — authoritative; appended on every binding change.
26
+ * Code-agnostic (keyed by full dir name), log-only (no surfaceOp).
27
+ */
28
+ 'serenity/bound': {
29
+ /** Full AGENT_SESSIONS dir name — the ONLY hard identity. */
30
+ dirName: string;
31
+ /** SESSION.md absolute path. */
32
+ mdPath: string;
33
+ /** Display code when parseable (S142 / apaas-26116 / custom); informational. */
34
+ sessionId?: string;
35
+ /** Why this binding record was written. */
36
+ action: 'activate' | 'switch' | 'create' | 'rebuild' | 'reconcile' | 'release';
37
+ /** Epoch ms. */
38
+ at: number;
39
+ /** Optional reason (e.g. 'auto from title'). */
40
+ note?: string;
41
+ };
42
+ }
43
+ }
44
+ export type SessionBoundAction = SessionEventMap['serenity/bound']['action'];
45
+ export interface SessionBoundRecord {
46
+ dirName: string;
47
+ mdPath: string;
48
+ sessionId?: string;
49
+ action: SessionBoundAction;
50
+ at: number;
51
+ note?: string;
52
+ }
53
+ /** 事件 type 常量(声明 + 读取共用) */
54
+ export declare const SESSION_BOUND_EVENT: "serenity/bound";
55
+ /**
56
+ * 读取会话日志中**最后一条** `serenity/bound`(权威绑定,latest-wins)。
57
+ * 尾到头扫描(时间序最新在后);无 bound 事件返回 null。
58
+ * 纯逻辑零 DSH 依赖(经 sessionEvents helper 读 snapshotEvents/events 兜底)。
59
+ */
60
+ export declare function readLastBound(session: unknown): SessionBoundRecord | null;
61
+ /**
62
+ * 判定会话日志是否已有任何 bound 事件(供 reconcile 判定:无绑定才标题升级)。
63
+ */
64
+ export declare function hasAnyBound(session: unknown): boolean;
65
+ /**
66
+ * append 一条 `serenity/bound` 绑定事件(log-only,无 surfaceOp——元数据不进模型可见面)。
67
+ * @param session 目标 dsh 会话(真实 Session;append 泛型经内部断言兼容——类型由
68
+ * `serenity/bound` 声明面保证,append 接受已声明事件)
69
+ * @param action 绑定动作
70
+ * @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
71
+ * @returns 是否成功(append 抛错/会话不可用时 false——绑定失败不阻断主流程)
72
+ */
73
+ export declare function appendBound(session: Session | {
74
+ append: (type: string, data: unknown) => unknown;
75
+ } | null | undefined, action: SessionBoundAction, rec: {
76
+ dirName: string;
77
+ mdPath: string;
78
+ sessionId?: string;
79
+ note?: string;
80
+ }): boolean;
@@ -117,6 +117,19 @@ export declare function parseSessionContextFromEvents(events: readonly unknown[]
117
117
  /** 从文本提取 SESSION.md 路径(use 上下文 / 重建锚点规范行通用;无匹配返回 null)。
118
118
  * 同行已知尾注(如系统提示词 Session 块的 persistent-body 注释)剥除——规范行只取路径本体。 */
119
119
  export declare function extractSessionMdPathFromText(text: string): string | null;
120
+ /**
121
+ * 从 dsh 会话标题解析 SESSION 目录(编码无关 best-match,U3/U4)——
122
+ * 标题不假设 S### 前缀(CCC 可自定义编码:apaas-xxx / P### / 完整目录名等)。
123
+ * 匹配优先级(全部对 AGENT_SESSIONS 现有目录 best-match,不猜):
124
+ * ① 标题即完整目录名(含日期前缀 `YYYY-MM-DD--`)→ 精确命中
125
+ * ② 标题含 `--<code>--` 段(如完整目录名被截断为 `<code>-日期-概括` 前段)→
126
+ * 按 code 段匹配:取标题首 token(`-` 前),与各目录 `--<code>--`/`--<code>` 尾段比对
127
+ * ③ 唯一模糊子串匹配(多个则返回 null 防误猜)
128
+ * @param title dsh 会话标题(如 `S142-2026-08-24-概括` / `apaas-26116-…` / 完整目录名)
129
+ * @param sessionsDir AGENT_SESSIONS 绝对路径
130
+ * @returns 命中目录的绝对路径(SESSION.md);无/歧义返回 null
131
+ */
132
+ export declare function resolveSessionByTitle(title: string, sessionsDir: string): string | null;
120
133
  /**
121
134
  * 约定回退(v1.24.11):AGENT_SESSIONS 下最新修改的**未完成**会话的 SESSION.md。
122
135
  * readAllSessions 已按「未完成优先 + mtime 降序」排序 → 首个未完成且含 SESSION.md 即最新活动。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.29.0",
3
+ "version": "1.29.2",
4
4
  "description": "宁静号 ACC harness — Native Cordis 插件(DeepSeek Harness 运行时)。真实 DSH 工具注册(cc_fs/session/acc_msm 等 9 工具)+ 拦截缝机械约束(safe-mode/路径守卫/会话落盘)+ 系统提示词注入(ACC/CCE/Constraints/SKILL/Session 五块)。适配 DSH 公开版(deepseek-ai/deepseek-harness 0.1.0-rc)。",
5
5
  "license": "MIT",
6
6
  "repository": {