@mingmaple/dsh-handover 0.9.22 → 1.0.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 (81) hide show
  1. package/README.md +117 -113
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +1 -1
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +221 -61
  8. package/package.json +5 -2
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +12 -2
  11. package/scripts/blank-smoke.mjs +95 -44
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/portability.mjs +71 -0
  14. package/scripts/style-gate.mjs +571 -0
  15. package/src/client.js +1028 -994
  16. package/src/index.mjs +273 -109
  17. package/src/lib/absrefs.mjs +1 -1
  18. package/src/lib/api.mjs +9 -3
  19. package/src/lib/archive.mjs +1 -1
  20. package/src/lib/brief.mjs +8 -6
  21. package/src/lib/config.mjs +21 -6
  22. package/src/lib/docmap.mjs +5 -8
  23. package/src/lib/drift.mjs +1 -1
  24. package/src/lib/events.mjs +1 -1
  25. package/src/lib/facts.mjs +19 -12
  26. package/src/lib/gates.mjs +12 -12
  27. package/src/lib/global-task.mjs +69 -0
  28. package/src/lib/guide.mjs +80 -75
  29. package/src/lib/handover-io.mjs +69 -6
  30. package/src/lib/identity.mjs +330 -0
  31. package/src/lib/legacy.mjs +89 -0
  32. package/src/lib/lint.mjs +14 -11
  33. package/src/lib/manual.mjs +117 -80
  34. package/src/lib/mover.mjs +18 -14
  35. package/src/lib/office.mjs +42 -2
  36. package/src/lib/pack-ops.mjs +10 -10
  37. package/src/lib/pathkey.mjs +24 -0
  38. package/src/lib/phase.mjs +8 -13
  39. package/src/lib/profile.mjs +1 -1
  40. package/src/lib/reminder.mjs +110 -0
  41. package/src/lib/role.mjs +1 -1
  42. package/src/lib/scaffold.mjs +30 -10
  43. package/src/lib/schedule.mjs +5 -5
  44. package/src/lib/session-create.mjs +2 -2
  45. package/src/lib/session-ops.mjs +76 -47
  46. package/src/lib/sessions.mjs +40 -2
  47. package/src/lib/sevenzip.mjs +10 -1
  48. package/src/lib/status.mjs +337 -336
  49. package/src/lib/succession.mjs +14 -14
  50. package/src/lib/system.mjs +364 -0
  51. package/src/templates/Changelog.skeleton.md +3 -3
  52. package/src/templates/Charter.skeleton.md +13 -9
  53. package/src/templates/Handover.global.skeleton.md +20 -13
  54. package/src/templates/Handover.template.md +89 -0
  55. package/src/templates/Plan.skeleton.md +34 -0
  56. package/src/templates/Prompt_Templates.md +91 -10
  57. package/src/templates/Runtime_Manual.md +254 -139
  58. package/src/templates/System.skeleton.md +31 -0
  59. package/src/templates/Systems.global.skeleton.md +13 -0
  60. package/src/templates/rules/Delegation_Rules.md +45 -0
  61. package/src/templates/rules/Handover_Rules.md +46 -24
  62. package/src/templates/rules/Migration_Rules.md +7 -7
  63. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  64. package/src/templates/rules/Writing_Rules.md +33 -16
  65. package/src/templates/standards/README.md +19 -7
  66. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  67. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  68. package/src/tools/ho_backup.mjs +8 -8
  69. package/src/tools/ho_changelog.mjs +4 -4
  70. package/src/tools/ho_charter.mjs +7 -6
  71. package/src/tools/ho_checkpoint.mjs +34 -31
  72. package/src/tools/ho_docmap.mjs +135 -49
  73. package/src/tools/ho_gate.mjs +9 -4
  74. package/src/tools/ho_handover.mjs +61 -35
  75. package/src/tools/ho_office.mjs +45 -4
  76. package/src/tools/ho_profile.mjs +3 -3
  77. package/src/tools/ho_sessions.mjs +43 -22
  78. package/src/tools/ho_status.mjs +14 -9
  79. package/src/tools/ho_succeed.mjs +3 -3
  80. package/src/tools/ho_workspace.mjs +83 -51
  81. package/src/tools/peer.mjs +219 -8
package/src/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
- /**
1
+ /**
2
2
  * dsh-handover —— 全局运行期插件。
3
- * 判据进代码、流程进工具;规则正文留磁盘,需要时用 ho_charter 取节(完整流程 doc="runtime")。
4
- * 按会话角色载入工具与提示段:全局 14 个工具、项目 11、杂项 10、普通 0。
5
- * 源码不热加载:改完要重启 DSH。
3
+ * 判定条件写入代码,执行流程写入工具。
4
+ * 规则正文保留在磁盘,需要时用 ho_charter 按节读取(完整流程 doc="runtime")。
5
+ * 按会话角色装配工具与提示段:全局 14 个工具、项目 11 个、杂项 10 个、普通 0 个。
6
+ * 源码不热加载:改动后须重启 DSH。
6
7
  */
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -24,7 +25,7 @@ import { pmProfileTool } from './tools/ho_profile.mjs';
24
25
  import { pmOfficeTool } from './tools/ho_office.mjs';
25
26
  import { pmSucceedTool } from './tools/ho_succeed.mjs';
26
27
  import { pmDocmapTool } from './tools/ho_docmap.mjs';
27
- import { pmPeerTool } from './tools/peer.mjs';
28
+ import { pmPeerTool, collabMemberReason } from './tools/peer.mjs';
28
29
  import { resolveRegistry } from './lib/registry.mjs';
29
30
  import { registerStatusRoute } from './lib/webroute.mjs';
30
31
  import { createApi, statusSnapshot } from './lib/api.mjs';
@@ -35,28 +36,33 @@ import {
35
36
  ROLES, resolveRole, sessionMeta, handoverPathFor, toolsetFor,
36
37
  readRoles, writeRole, assertProjectName, assertTaskName,
37
38
  } from './lib/role.mjs';
38
- import { manualFor, organizeMessageFor } from './lib/manual.mjs';
39
+ import { manualFor, firstRoundMessageFor } from './lib/manual.mjs';
39
40
  import { sendPrompt } from './lib/prompt.mjs';
40
41
  import { resolveShortSid } from './lib/api.mjs';
41
42
  import { routeWorkspace } from './lib/workspace.mjs';
42
43
  import { stampTz } from './lib/brief.mjs';
43
44
  import { grantConsent, activeConsentsFor } from './lib/consent.mjs';
44
- import { docmapBlock } from './lib/docmap.mjs';
45
+ import { systemBlock, registrySignature } from './lib/system.mjs';
46
+ import { reminderTailNote } from './lib/reminder.mjs';
47
+ import { migrateIdentity, removeRegistryProject } from './lib/identity.mjs';
48
+ import { loadOffice, lineageKeyOf, syncHolderKind, removeHolder, registerHolder } from './lib/office.mjs';
45
49
  import { moveStatus } from './lib/mover.mjs';
46
50
 
47
51
  export const name = 'dsh-handover';
48
- // 必需服务(名字已核对:dsh-agent→agents、dsh-compaction→compaction、dsh-api-session-controller→sessionController)。
49
- // 只 inject 一定存在的服务:多写一个不存在的名字,cordis 会让整个插件静默不激活。
50
- // agents / compaction 用运行时解析(ctx[name] → ctx.get(name)),无法获取由 ho_status(view="diag")报出来、ho_checkpoint 明确报错。
52
+ // 必需服务(名称已核对):插件名与内核服务名一一对应。
53
+ // dsh-agent→agents、dsh-compaction→compaction、dsh-api-session-controller→sessionController。
54
+ // 只 inject 确定存在的服务:多写一个不存在的名称,cordis 会使整个插件静默不激活。
55
+ // agents 与 compaction 按运行期解析(ctx[name] → ctx.get(name))。
56
+ // 无法获取时由 ho_status(view="diag")报出,ho_checkpoint 明确报错。
51
57
  export const inject = ['tools', 'systemPrompt', 'sessionController'];
52
58
 
53
- /** 自身包信息:dist 里读 ./package.json,源码树里退回 ../package.json。 */
59
+ /** 自身包信息:dist 内读取 ./package.json,源码树内回退到 ../package.json。 */
54
60
  function pkgInfo() {
55
61
  for (const rel of ['./package.json', '../package.json']) {
56
62
  try {
57
63
  const j = JSON.parse(fs.readFileSync(new URL(rel, import.meta.url), 'utf8'));
58
64
  if (j && j.version) return { name: String(j.name || ''), version: String(j.version) };
59
- } catch { /* 试下一个 */ }
65
+ } catch { /* 尝试下一个 */ }
60
66
  }
61
67
  return { name: '@mingmaple/dsh-handover', version: '?' };
62
68
  }
@@ -69,13 +75,14 @@ function walkUpToWorkspace(start) {
69
75
  if (up === cur) break;
70
76
  cur = up;
71
77
  }
72
- // cwd 的父目录已删(或 cwd 是盘符根)时,向上找不到工作区。
73
- // 若把 start 原样返回,写操作就会在那个已失效的路径上重建一套 _Control_Center(幽灵目录)。
74
- // 此时以登记表里的工作区为准;登记也找不到才退回 start。
78
+ // cwd 的父目录已删除(或 cwd 为盘符根)时,向上无法找到工作区。
79
+ // 若原样返回 start,写操作会在该失效路径上重建一套 _Control_Center。
80
+ // 该目录没有对应工作区,后续流程无法识别。
81
+ // 此时以登记表记录的工作区为准;登记表也查不到时才返回 start。
75
82
  try {
76
83
  const fromReg = routeWorkspace({ cfg: {} });
77
84
  if (fromReg && fromReg !== start && isDir(path.join(fromReg, '_Control_Center'))) return fromReg;
78
- } catch { /* 登记读不到就保持旧行为 */ }
85
+ } catch { /* 登记表无法读取时保持旧行为 */ }
79
86
  return start;
80
87
  }
81
88
 
@@ -84,7 +91,7 @@ function msgOf(e) {
84
91
  }
85
92
 
86
93
  /**
87
- * 压缩调度器用的两个依赖:入参是**会话 id**,必须按 id 解析角色与交接路径。
94
+ * 压缩调度器依赖的 2 个函数:入参为**会话 id**,须按 id 解析角色与交接路径。
88
95
  * 单独导出,便于冒烟测试直接验证。
89
96
  */
90
97
  export function schedDepsFor(env) {
@@ -97,11 +104,12 @@ export function schedDepsFor(env) {
97
104
  export function apply(ctx, config) {
98
105
  const cfg = resolveConfig(config);
99
106
  const initErrors = [];
100
- const registered = new Set(); // 已注册的工具名(旧内核的限制式裁剪要用)
101
- // 0.2.2 起工具按「步」解析(tools.resolve):可用工具集合每一步重算,角色过滤直接放进注册条件。
102
- // 更早的内核忽略注册选项,只能退回 agent 作用域的 restrict;两条路径按能力二选一,不并行。
107
+ const registered = new Set(); // 已注册的工具名(旧内核的限制式裁剪使用)
108
+ // 自 0.2.2 起,工具按「步」解析(tools.resolve):可用工具集合每步重算,角色过滤写入注册条件。
109
+ // 更早的内核忽略注册选项,只能退回 agent 作用域的 restrict。2 条路径按能力择一,不并行。
103
110
  const perStep = (() => { try { return !!(ctx.tools && typeof ctx.tools.resolve === 'function'); } catch { return false; } })();
104
111
  const sectionDispose = new Map(); // sessionId → 撤销提示段
112
+ const systemSig = new Map(); // sessionId → 上次装配时的体系注册签名(热更新)
105
113
  const consentSig = new Map(); // sessionId → 上次装配时的授权签名(授权出现或过期时重装)
106
114
  const legacyRestrict = new Map(); // sessionId → 撤销限制(仅无按步解析的内核)
107
115
  const appliedRole = new Map(); // sessionId → 上次装配的角色
@@ -142,11 +150,11 @@ export function apply(ctx, config) {
142
150
  },
143
151
  schedule(sessionId, n, opts) {
144
152
  if (!scheduler) throw new Error("压缩调度器未就绪" + (schedulerError ? ":" + schedulerError : "(服务尚未注入)"));
145
- // consent 已由入口工具校验并消费;此处不再复查,复查必然落空。
153
+ // consent 已由入口工具校验并消费;此处不再复查,复查必然无效。
146
154
  scheduler.request(sessionId, Object.assign({ n, consent: 'checked' }, opts || {})).catch(() => {});
147
155
  },
148
156
  registry() { return resolveRegistry(ctx); },
149
- /** 迁移感知:根目录是否已搬到别处(旧路径变 junction)。给提示段与 ho_status(view="diag")用。 */
157
+ /** 迁移感知:根目录是否已迁移到其他位置(原路径成为 junction)。供提示段与 ho_status(view="diag")使用。 */
150
158
  moveInfo() {
151
159
  try {
152
160
  const src = cfg.workspaceRoot || walkUpToWorkspace(process.cwd());
@@ -195,7 +203,7 @@ export function apply(ctx, config) {
195
203
  staleNote(exec) {
196
204
  try { return stalePressureNote(this.selfId(exec)); } catch { return ''; }
197
205
  },
198
- /** 自身版本:从 dist/package.json 读,设置页与 ho_status(view="diag")共用同一值。 */
206
+ /** 自身版本:从 dist/package.json 读取,设置页与 ho_status(view="diag")共用同一值。 */
199
207
  version() {
200
208
  try { return pkgInfo().version; } catch { return '?'; }
201
209
  },
@@ -206,7 +214,7 @@ export function apply(ctx, config) {
206
214
  },
207
215
  };
208
216
 
209
- // ---------------- 会话标题:运行时 header.title 常常是空的,退回投影缓存
217
+ // ---------------- 会话标题:运行时 header.title 往往为空,改读投影缓存
210
218
  function titleOf(sessionId) {
211
219
  try {
212
220
  const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sessionId) : null;
@@ -230,9 +238,9 @@ export function apply(ctx, config) {
230
238
  try {
231
239
  const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(want) : null;
232
240
  if (a) return a;
233
- } catch { /* 退回列表查找 */ }
234
- // 宿主的注册表键不保证就是会话 id:取不到时按会话头的 id 在活体列表里再找一次。
235
- // 少了这一层,休眠会话在设置页被改角色时会被判成「不在内存」,只能等重启才装配。
241
+ } catch { /* 回退到列表查找 */ }
242
+ // 宿主的注册表键不保证等于会话 id:无法取到实例时,按会话头 id 在活体列表中再查一次。
243
+ // 缺这一层,休眠会话在设置页修改角色时会被判为「不在内存」,须等重启才装配。
236
244
  try {
237
245
  const list = ctx.agents && typeof ctx.agents.list === 'function' ? (ctx.agents.list() || []) : [];
238
246
  for (const a of list) {
@@ -244,8 +252,9 @@ export function apply(ctx, config) {
244
252
  }
245
253
 
246
254
  /**
247
- * 会话头就能判定的子级会话:子会话与团队成员的会话都带 origin=subagent 或 parentSession。
248
- * 只读 header,不碰磁盘,可放在「每一步」的条件启用里。
255
+ * 仅凭会话头即可判定的子级会话。
256
+ * 子会话与团队成员的会话都带 origin=subagent 或 parentSession。
257
+ * 只读 header,不访问磁盘,可置于「每一步」的条件启用中。
249
258
  */
250
259
  function isChildSession(agent) {
251
260
  try {
@@ -274,7 +283,8 @@ export function apply(ctx, config) {
274
283
  env.toolsetOf = (role) => toolsetFor(role);
275
284
  env.roleState = () => ({ mode: roleMode(), last: roleState.last, count: appliedRole.size });
276
285
  env.rolesSnapshot = () => readRoles(cfg).sessions;
277
- // 写角色表是一切角色变更的唯一入口:写完立刻让每步过滤的短缓存失效,下一步就用新角色。
286
+ // 写角色表是所有角色变更的唯一入口。
287
+ // 写入后立即使每步过滤的短缓存失效,下一步即按新角色判定。
278
288
  env.writeRole = (sid, patch) => {
279
289
  const saved = writeRole(cfg, sid, patch);
280
290
  roleCache.delete(String(sid || ''));
@@ -286,7 +296,7 @@ export function apply(ctx, config) {
286
296
  if (cfg.roleGating === 'off') return '关闭(roleGating=off,所有会话可见全部工具)';
287
297
  return perStep
288
298
  ? '按步过滤(工具表每步重算,换角色下一轮即生效)'
289
- : '旧式收窄(内核不支持按步解析,用 agent 作用域 restrict)';
299
+ : '旧式收窄(内核不支持按步解析,改用 agent 作用域 restrict)';
290
300
  }
291
301
 
292
302
  function handoverInfoFor(info, ws) {
@@ -297,7 +307,7 @@ export function apply(ctx, config) {
297
307
  return Object.assign({ ws, role: info.role, project: info.project, task: info.task }, t);
298
308
  }
299
309
 
300
- /** 只按会话 id 推工作区根:优先活体 cwd,其次会话元数据,最后配置。 */
310
+ /** 仅按会话 id 推断工作区根:优先活体 cwd,其次会话元数据,最后取配置。 */
301
311
  function workspaceForSid(sid) {
302
312
  if (cfg.workspaceRoot) return String(cfg.workspaceRoot);
303
313
  const a = agentOf(sid);
@@ -306,7 +316,7 @@ export function apply(ctx, config) {
306
316
  }
307
317
 
308
318
  env.handoverFor = (exec) => handoverInfoFor(env.roleOf(exec), env.resolveWs(exec));
309
- /** 跨会话用:凭 id 算它自己的角色与交接路径(不借调用者的上下文)。 */
319
+ /** 跨会话使用:按 id 计算该会话自身的角色与交接路径,不借用调用者上下文。 */
310
320
  env.handoverForSid = (sid) => {
311
321
  const id = String(sid || '');
312
322
  return handoverInfoFor(infoFor(id, {}), workspaceForSid(id));
@@ -315,7 +325,7 @@ export function apply(ctx, config) {
315
325
  env.agentOfSid = (sid) => agentOf(String(sid || ''));
316
326
  env.compactionStateOfSid = (sid) => compactionStateOf(String(sid || ''), null);
317
327
 
318
- // ---------------- 按角色装配:工具可见性由每步条件即时算,这里只维护提示段(旧内核另加 restrict)
328
+ // ---------------- 按角色装配:工具可见性由每步条件即时计算,此处只维护提示段(旧内核另加 restrict)
319
329
  function workspaceForAgent(agent) {
320
330
  if (cfg.workspaceRoot) return cfg.workspaceRoot;
321
331
  const cwd = agent && agent.session && agent.session.header ? String(agent.session.header.cwd || '') : '';
@@ -328,28 +338,33 @@ export function apply(ctx, config) {
328
338
  if (typeof old === 'function') { try { old(); } catch { /* 旧段已失效 */ } }
329
339
  sectionDispose.delete(sid);
330
340
  let text = '';
341
+ let ws = '';
331
342
  try {
332
- const ws = workspaceForAgent(agent);
333
- // 文档定位与授权状态都是「按会话现算」的:写盘后重装一次即可生效,不另开注入点。
334
- const docmap = docmapBlock(ws, info);
343
+ ws = workspaceForAgent(agent);
344
+ // 体系注册与授权状态均按会话实时计算:写入磁盘后重装一次即生效,不另设注入点。
345
+ const system = systemBlock(ws, info);
335
346
  const consents = activeConsentsFor(sid);
336
- text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null, docmap, consents });
347
+ text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null, system, consents });
337
348
  consentSig.set(sid, consents.map((r) => r.kind + ':' + r.until).join('|'));
349
+ systemSig.set(sid, registrySignature(ws, info));
338
350
  } catch (e) {
339
351
  initErrors.push('角色提示段:' + msgOf(e));
340
352
  return;
341
353
  }
342
354
  const sc = agent && agent.ctx ? agent.ctx : null;
343
- if (!text || !sc || !sc.systemPrompt || typeof sc.systemPrompt.section !== 'function') return;
344
- try {
345
- sectionDispose.set(sid, sc.systemPrompt.section({ name: 'dsh-handover:manual', order: 100, text, interpolate: false }));
346
- } catch (e) {
347
- // 插件重载后,旧实例注册在 agent 作用域上的同名提示段仍在(宿主按 agent 持有,插件卸载不移除),
348
- // 既无法移除也不能重复注册:视为已就位,沿用旧文本;换文本需重启 DSH 或让会话重建。
349
- if (!/already registered/.test(msgOf(e))) initErrors.push('角色提示段:' + msgOf(e));
355
+ if (text && sc && sc.systemPrompt && typeof sc.systemPrompt.section === 'function') {
356
+ try {
357
+ sectionDispose.set(sid, sc.systemPrompt.section({ name: 'dsh-handover:manual', order: 100, text, interpolate: false }));
358
+ } catch (e) {
359
+ // 插件重载后,旧实例注册在 agent 作用域上的同名提示段仍然存在。
360
+ // 宿主按 agent 持有该段,插件卸载不移除;该段既无法移除,也不能重复注册。
361
+ // 因此视为已就位并沿用旧文本;修改文本须重启 DSH 或重建会话。
362
+ if (!/already registered/.test(msgOf(e))) initErrors.push('角色提示段:' + msgOf(e));
363
+ }
350
364
  }
351
365
  }
352
366
 
367
+
353
368
  /** 旧内核(没有按步解析)才用的限制式裁剪。 */
354
369
  function applyLegacyRestrict(agent, sid, info) {
355
370
  const old = legacyRestrict.get(sid);
@@ -364,7 +379,7 @@ export function apply(ctx, config) {
364
379
  } catch { /* 旧内核裁剪失败只影响可见工具,不影响其余功能 */ }
365
380
  }
366
381
 
367
- /** 让一个活体会话的提示段与本会话当前角色对齐。info 可传入已解析好的角色,省一次判定。 */
382
+ /** 使一个活体会话的提示段与本会话当前角色对齐。info 可传入已解析的角色,省去一次判定。 */
368
383
  function applyRoleTo(agent, infoOpt) {
369
384
  const h = agent && agent.session ? agent.session.header : null;
370
385
  const sid = String((h && h.id) || (agent && agent.id) || '');
@@ -379,32 +394,98 @@ export function apply(ctx, config) {
379
394
  roleState.last = { sid, role: info.role, at: stampTz(Date.now()) };
380
395
  }
381
396
 
382
- /** 设定角色:写角色表 → 存活会话当场换提示段(工具在下一步按新角色重算)→ 可选投整理指令。 */
397
+ /** 设置角色:写入角色表,按需完成身份互转,存活会话当场更换提示段,随后投递首轮指令。 */
383
398
  async function applyRole(input) {
384
399
  const i = input || {};
385
400
  const sid = String(i.sessionId || '').trim();
386
401
  const role = String(i.role || '').trim();
387
402
  const project = String(i.project || '').trim();
388
403
  const task = String(i.task || '').trim();
389
- if (!sid) return { ok: false, error: '缺会话 id' };
390
- if (ROLES.indexOf(role) === -1) return { ok: false, error: '角色只能是 global、project、misc、plain' };
391
- if (role === 'project' && !project) return { ok: false, error: '项目角色要填项目名' };
392
- if (role === 'misc' && !task) return { ok: false, error: '杂项角色要填任务名' };
393
- // 名字要落成目录:不合法就当场拒绝,别等写角色表或拼路径时才失败。
404
+ if (!sid) return { ok: false, error: '缺少会话 id' };
405
+ if (ROLES.indexOf(role) === -1) return { ok: false, error: '角色只接受 global、project、misc、plain' };
406
+ if (role === 'project' && !project) return { ok: false, error: '项目角色须填写项目名' };
407
+ if (role === 'misc' && !task) return { ok: false, error: '杂项角色须填写任务名' };
408
+ // 名称要落成目录:不合法时立即拒绝,不待写角色表或拼接路径时才失败。
394
409
  if (role === 'project') { try { assertProjectName(project); } catch (e) { return { ok: false, error: msgOf(e) }; } }
395
410
  if (role === 'misc') { try { assertTaskName(task); } catch (e) { return { ok: false, error: msgOf(e) }; } }
411
+ const ws = routeWorkspace(env);
412
+ // 仅在无全局会话时允许将会话设为全局;已有在任全局会话时,只允许同一会话重复确认。
413
+ if (role === 'global') {
414
+ try {
415
+ const cur = loadOffice(ws).lineages['全局'] && loadOffice(ws).lineages['全局'].current;
416
+ if (cur && cur.sessionId && String(cur.sessionId) !== sid) {
417
+ return { ok: false, error: '已有全局在任:' + (cur.name || '') + '(' + cur.sessionId + ')。同一时刻仅允许一个全局会话,须先在设置页处理在任会话。' };
418
+ }
419
+ } catch { /* 登记表无法读取时不阻断,按首次设置处理 */ }
420
+ }
421
+ const before = readRoles(cfg).sessions[sid] || {};
422
+ const prevRole = String(before.role || '');
423
+ let migration = null;
424
+ // 项目与杂项互转:先做快照再移动,或按用户选择直接新建。
425
+ if ((prevRole === 'project' || prevRole === 'misc') && (role === 'project' || role === 'misc') && prevRole !== role) {
426
+ try {
427
+ migration = migrateIdentity({
428
+ ws,
429
+ prev: { role: prevRole, project: String(before.project || ''), task: String(before.task || '') },
430
+ next: { role, project, task },
431
+ mode: i.migrate === 'direct' ? 'direct' : 'auto',
432
+ });
433
+ } catch (e) {
434
+ return { ok: false, error: '身份互转未完成:' + msgOf(e) };
435
+ }
436
+ }
396
437
  try { writeRole(cfg, sid, { role, project, task, source: 'user' }); }
397
438
  catch (e) { return { ok: false, error: '写角色表失败:' + msgOf(e) }; }
439
+ // 注册同步:项目、杂项、全局角色更新名册类别;普通角色只清除注册,文件与目录原位保留。
440
+ try {
441
+ const oldProjectDir = prevRole === 'project' && before.project ? path.join(ws, '_Project_Meta', String(before.project)) : '';
442
+ const oldTaskDir = prevRole === 'misc' && before.task ? path.join(ws, '_Misc', 'tasks', String(before.task)) : '';
443
+ const markerOf = (dir) => path.join(dir, '.deregistered.json');
444
+ if (role === 'project') {
445
+ fs.mkdirSync(path.join(ws, '_Project_Meta', project), { recursive: true });
446
+ try { fs.rmSync(markerOf(path.join(ws, '_Project_Meta', project)), { force: true }); } catch { /* ignore */ }
447
+ }
448
+ if (role === 'misc') {
449
+ fs.mkdirSync(path.join(ws, '_Misc', 'tasks', task), { recursive: true });
450
+ try { fs.rmSync(markerOf(path.join(ws, '_Misc', 'tasks', task)), { force: true }); } catch { /* ignore */ }
451
+ }
452
+ if (role === 'plain') {
453
+ for (const dir of [oldProjectDir, oldTaskDir]) {
454
+ if (!dir || !fs.existsSync(dir)) continue;
455
+ try { fs.writeFileSync(markerOf(dir), JSON.stringify({ at: new Date().toISOString(), sessionId: sid }), 'utf8'); } catch { /* ignore */ }
456
+ }
457
+ if (prevRole === 'project' && before.project) { try { removeRegistryProject(ws, String(before.project)); } catch { /* ignore */ } }
458
+ if (prevRole === 'project' || prevRole === 'misc' || prevRole === 'global') {
459
+ try { removeHolder(ws, { kind: prevRole, project: String(before.project || ''), task: String(before.task || ''), sessionId: sid }); } catch { /* ignore */ }
460
+ }
461
+ } else if (role === 'project' || role === 'misc' || role === 'global') {
462
+ const okKind = syncHolderKind(ws, { kind: role, project, task, sessionId: sid });
463
+ if (!okKind) {
464
+ registerHolder(ws, { kind: role, project, task, name: titleOf(sid) || (role === 'project' ? project : task), sessionId: sid });
465
+ }
466
+ }
467
+ } catch (e) { void e; }
398
468
  roleCache.delete(sid);
399
469
  const info = { role, project, task, source: 'store', sessionId: sid };
400
470
  let live = false;
401
471
  try {
402
472
  const a = agentOf(sid);
403
473
  if (a) { live = true; applyRoleTo(a, info); }
404
- } catch { /* 活体装配失败不影响写盘 */ }
474
+ } catch { /* 活体会话装配失败不影响写盘 */ }
405
475
  let delivered = false;
406
476
  let error = '';
407
- const msg = organizeMessageFor(info, { ws: routeWorkspace(env) });
477
+ let registeredName = '';
478
+ try {
479
+ const lin = { project: role === 'project' ? project : '', task: role === 'misc' ? task : '', kind: role };
480
+ const cur = loadOffice(ws).lineages[lineageKeyOf(lin.kind, lin.project, lin.task)];
481
+ if (cur && cur.current && String(cur.current.sessionId) === sid) registeredName = String(cur.current.name || '');
482
+ } catch { /* 无法读取登记表时不写名字 */ }
483
+ const msg = firstRoundMessageFor(info, {
484
+ previousRole: prevRole,
485
+ migrate: i.migrate === 'direct' ? 'direct' : 'auto',
486
+ moveNote: migration ? migration.summary : '',
487
+ name: registeredName,
488
+ });
408
489
  if (i.wake !== false && msg) {
409
490
  try {
410
491
  const r = await sendPrompt(env.controller(), sid, msg, 'queue');
@@ -413,11 +494,16 @@ export function apply(ctx, config) {
413
494
  error = msgOf(e);
414
495
  }
415
496
  }
416
- return { ok: true, role, project, task, live, delivered, error };
497
+ return {
498
+ ok: true, role, project, task, live, delivered, error,
499
+ migrated: migration ? migration.mode : '',
500
+ migrationSummary: migration ? migration.summary : '',
501
+ registeredName,
502
+ };
417
503
  }
418
504
  env.applyRole = applyRole;
419
505
 
420
- /** 会话新建后立即按已登记角色装配(不唤醒、不投消息)。 */
506
+ /** 会话新建后立即按已登记角色装配(不唤醒会话、不投递消息)。 */
421
507
  env.applyScopedLive = (sid, info) => {
422
508
  try {
423
509
  const a = agentOf(String(sid || ''));
@@ -429,7 +515,7 @@ export function apply(ctx, config) {
429
515
  } catch { return false; }
430
516
  };
431
517
 
432
- /** 重装某会话的提示段:文档定位写盘后、授权到达后各调一次;不唤醒、不投消息。 */
518
+ /** 重装某会话的提示段:文档定位写盘后与授权到达后各调用一次;不唤醒会话,不投递消息。 */
433
519
  env.refreshPromptFor = (sid) => {
434
520
  try {
435
521
  const id = String(sid || '');
@@ -441,8 +527,8 @@ export function apply(ctx, config) {
441
527
  } catch { return false; }
442
528
  };
443
529
 
444
- // ---------------- 调度器(续跑文案要按角色取交接路径)
445
- // 注意:调度器拿到的是会话 id 字符串,不是 exec;这里用按 id 解析的版本,直接读角色表与磁盘。
530
+ // ---------------- 调度器(续跑文案须按角色取交接路径)
531
+ // 注意:调度器收到的是会话 id 字符串,不是 exec;此处使用按 id 解析的版本,直接读取角色表与磁盘。
446
532
  const schedDeps = { ctx, cfg, logger: (ctx.logger || null), roleOf: null, handoverFor: null };
447
533
  try {
448
534
  scheduler = createScheduler(schedDeps);
@@ -450,8 +536,8 @@ export function apply(ctx, config) {
450
536
  } catch (e) { schedulerError = msgOf(e); }
451
537
  Object.assign(schedDeps, schedDepsFor(env));
452
538
 
453
- // ---------------- 用户全局前置提示词(段序 950=工具段之前)
454
- // 与插件自身的会话约束无关:仅当用户开启且内容非空时注册;关闭或留空时完全不占用注入点位。
539
+ // ---------------- 用户全局前置提示词(段序 950,位于工具段之前)
540
+ // 与插件自身的会话约束无关:仅在用户开启且内容非空时注册;关闭或留空时不占用注入位置。
455
541
  let extraDispose = null;
456
542
  function applyExtraPrompt(state) {
457
543
  try { if (typeof extraDispose === 'function') extraDispose(); } catch { /* 旧段已失效 */ }
@@ -481,11 +567,11 @@ export function apply(ctx, config) {
481
567
  };
482
568
  try { applyExtraPrompt(readExtraPrompt()); } catch (e) { initErrors.push('全局前置提示词:' + msgOf(e)); }
483
569
 
484
- // ---------------- 工具注册(13 个 ho_* + 1 个 peer)
485
- // 0.2.2 起工具按「步」解析:可用工具集合每一步重算。子会话排除与角色过滤都在这里一次做完,
486
- // 不再另用 agent 作用域 restrict —— 两套机制并行时,换角色要么等到重启、要么互相覆盖。
570
+ // ---------------- 工具注册(13 个 ho_* 与 1 个 peer)
571
+ // 自 0.2.2 起,工具按「步」解析:可用工具集合每一步重算。子会话排除与角色过滤在此一并完成,
572
+ // 不再另用 agent 作用域 restrict;与 restrict 并用时,换角色或须等待重启,或互相覆盖。
487
573
  const roleToolsets = new Map(ROLES.map((r) => [r, new Set(toolsetFor(r).allow)]));
488
- /** 每步过滤:子会话一律不可用;角色按当前角色表算;「无 agent」的路径(命令行、演练)放行。 */
574
+ /** 每步过滤:子会话一律不可见;角色按当前角色表计算;无 agent 的路径(命令行、演练)放行。 */
489
575
  function visibleTo(name) {
490
576
  return (c) => {
491
577
  const agent = c && c.agent ? c.agent : null;
@@ -506,6 +592,29 @@ export function apply(ctx, config) {
506
592
  return allow ? allow.has(name) : true;
507
593
  };
508
594
  }
595
+ /**
596
+ * 提醒尾注入:插件工具返回结果后,在文本末尾追加一条 [System Note: …]。
597
+ * 只修改本次工具结果。
598
+ * 不改动系统提示词(避免改写前缀缓存),不写会话事件,不另起消息,不中断工具执行。
599
+ */
600
+ function withReminderTail(def) {
601
+ if (!def || typeof def.execute !== 'function') return def;
602
+ const run = def.execute.bind(def);
603
+ return {
604
+ ...def,
605
+ async execute(args, exec) {
606
+ const res = await run(args, exec);
607
+ try {
608
+ const sid = env.selfId(exec);
609
+ if (sid && res && typeof res.text === 'string' && res.text !== '') {
610
+ const note = reminderTailNote(cfg, sid);
611
+ if (note) return { ...res, text: res.text + String.fromCharCode(10, 10) + note };
612
+ }
613
+ } catch { /* 追加失败不影响工具结果 */ }
614
+ return res;
615
+ },
616
+ };
617
+ }
509
618
  const tools = [
510
619
  pmStatusTool(env, { schedulerReady: () => scheduler !== null, schedulerError: () => schedulerError }),
511
620
  pmGateTool(env),
@@ -525,10 +634,10 @@ export function apply(ctx, config) {
525
634
  for (let i = 0; i < tools.length; i++) {
526
635
  const key = keys[i];
527
636
  try {
528
- // 名字以工厂自带的为准;工厂没写才用位置表补,且顺序错位时报出来(不再静默错名)。
637
+ // 名称以工厂自带的为准;工厂未写时才用位置表补充,顺序错位时报告(不再静默使用错误名称)。
529
638
  if (!tools[i].name) tools[i].name = NAMES[key];
530
639
  else if (tools[i].name !== NAMES[key]) initErrors.push('工具名错位:数组第 ' + i + ' 项是 ' + tools[i].name + ',位置表期望 ' + NAMES[key]);
531
- ctx.tools.register(tools[i], { when: visibleTo(tools[i].name) });
640
+ ctx.tools.register(withReminderTail(tools[i]), { when: visibleTo(tools[i].name) });
532
641
  registered.add(tools[i].name);
533
642
  } catch (e) {
534
643
  initErrors.push(key + ':' + msgOf(e));
@@ -538,15 +647,16 @@ export function apply(ctx, config) {
538
647
  const peerTools = [pmPeerTool(env)];
539
648
  for (const t of peerTools) {
540
649
  try {
541
- ctx.tools.register(t, { when: visibleTo(t && t.name ? String(t.name) : '') });
650
+ ctx.tools.register(withReminderTail(t), { when: visibleTo(t && t.name ? String(t.name) : '') });
542
651
  if (t && t.name) registered.add(t.name);
543
652
  } catch (e) {
544
653
  initErrors.push((t && t.name ? String(t.name) : 'peer') + ':' + msgOf(e));
545
654
  }
546
655
  }
547
656
 
548
- // ---------------- 会话守卫(Charter §7.2.4):新建会话与设定角色仅属全局;
549
- // 会话之间允许直接 peer(action=send)通信(不必经全局转述),directPeer=off 时退回「只能向全局报告」的旧口径。
657
+ // ---------------- 会话守卫(Charter §7.2.4):新建会话与设置角色仅属全局;
658
+ // 会话之间可直接用 peer(action=send)通信,无须经全局转述。
659
+ // directPeer=off 时退回「只能向全局报告」的旧口径。
550
660
  if (cfg.wakeAuthority !== 'off' && typeof ctx.tools.guard === 'function') {
551
661
  try {
552
662
  ctx.tools.guard((exec) => {
@@ -555,7 +665,7 @@ export function apply(ctx, config) {
555
665
  let args = (exec && exec.arguments) || {};
556
666
  if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = {}; } }
557
667
  const action = String(args.action || '');
558
- // list/status/peek/rename 的权限由工具内部按角色判,这里只管两件有外部影响的事。
668
+ // list/status/peek/rename 的权限由工具内部按角色判定,此处只处理 2 类有外部影响的动作。
559
669
  if (action !== 'send' && action !== 'new') return undefined;
560
670
  const sid = env.selfId(exec) || '';
561
671
  let info;
@@ -563,37 +673,54 @@ export function apply(ctx, config) {
563
673
  const mk = (allow, why) => {
564
674
  guardLast = { sid, allow, source: why, title: titleOf(sid) };
565
675
  if (allow) return undefined;
566
- if (action === 'new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项落到 Misc 除外)。';
567
- return '会话间通信受限(directPeer=off):非全局会话只能向现任全局报告,不得唤醒其他会话。';
676
+ if (action === 'new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项会话建在 Misc 下者除外)。';
677
+ return '会话间通信受限(directPeer=off):非全局会话只能向在任全局会话报告,不得唤醒其他会话。';
568
678
  };
569
679
  if (info.role === 'global') return mk(true, '全局角色');
570
680
  if (action === 'send') {
571
- // 会话之间可直接通信;向全局报告也在此路。directPeer=off 才退回旧口径。
681
+ // 会话之间可直接通信;向全局报告亦走此路径。仅 directPeer=off 时退回旧口径。
572
682
  const target = String(args.session_id || '');
573
683
  let t = null;
574
684
  try { t = target ? infoFor(target, {}) : null; } catch { t = null; }
575
685
  const tRole = t ? t.role : '未知';
576
- if (t && t.role === 'global') return mk(true, '向现任全局报告');
577
- if (cfg.directPeer !== 'off') return mk(true, '会话之间直接通信(' + info.role + ' → ' + tRole + ')');
578
- return mk(false, 'directPeer=off:仅允许向全局报告(' + info.role + ' → ' + tRole + ')');
686
+ if (t && t.role === 'global') return mk(true, '向在任全局会话报告');
687
+ if (cfg.directPeer !== 'off') return mk(true, '会话之间直接通信(' + info.role + ' 至 ' + tRole + ')');
688
+ return mk(false, 'directPeer=off:仅允许向全局报告(' + info.role + ' 至 ' + tRole + ')');
579
689
  }
580
- // action=new:只有杂项可以落到 Misc
690
+ // action=new:只有杂项会话可以建在 Misc 下
581
691
  const target = String(args.workspace || args.cwd || '').toLowerCase();
582
- if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, '杂项会话新建到 Misc');
692
+ if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, '杂项会话新建于 Misc');
583
693
  return mk(false, '非全局角色不得新建平级会话(' + info.role + ')');
584
694
  });
585
- } catch { /* 老版本没有 guard:退化为规则约束 */ }
695
+ } catch { /* 旧版本无 guard:退化为规则约束 */ }
696
+ }
697
+
698
+ // ---------------- 协作守卫:协作注册期间,被交办方不得经由 DSH 自带的 send_message 发送明文消息。
699
+ // 不改动 DSH 本体,只挂一层工具守卫;普通会话与普通对话不受影响。
700
+ if (typeof ctx.tools.guard === 'function') {
701
+ try {
702
+ ctx.tools.guard((exec) => {
703
+ const nm = exec && exec.name;
704
+ if (nm !== 'send_message') return undefined;
705
+ const sid = env.selfId(exec) || '';
706
+ let why = '';
707
+ try { why = collabMemberReason(sid); } catch { why = ''; }
708
+ if (!why) return undefined;
709
+ guardLast = { sid, allow: false, source: '协作注册', title: titleOf(sid) };
710
+ return why;
711
+ });
712
+ } catch { /* 旧版本无 guard:退化为规则约束 */ }
586
713
  }
587
714
 
588
715
  // ---------------- 会话生命周期:创建时装配提示段;开轮前按角色表核对一次
589
- // 工具可见性不在这里处理:0.2.2 起每步条件已按当前角色算好(见 visibleTo),换角色下一轮即生效。
716
+ // 此处不处理工具可见性:自 0.2.2 起,每步条件已按当前角色计算(见 visibleTo),换角色下一轮即生效。
590
717
  function scopedMetaOf(h, sid) {
591
718
  return { title: (h && h.title) || titleOf(sid), cwd: (h && h.cwd) || '' };
592
719
  }
593
720
  if (typeof ctx.on === 'function') {
594
721
  try {
595
- // 事件作用域:宿主以 agent 作为事件的作用域载体,插件在根作用域裸注册收不到别的会话的事件。
596
- // 传 { global: true } 才能收到全部会话的事件(0.2.2 支持;旧内核忽略选项,退回裸注册)。
722
+ // 事件作用域:宿主以 agent 作为事件的作用域载体。
723
+ // 插件在根作用域直接注册时,收不到其他会话的事件。传入 { global: true } 才能收到全部会话的事件(0.2.2 支持;旧内核忽略该项,退回直接注册)。
597
724
  const onAgentEvent = (name, fn) => {
598
725
  try { ctx.on(name, fn, { global: true }); return true; }
599
726
  catch { try { ctx.on(name, fn); return true; } catch (e) { env.log('会话事件监听失败(' + name + '):' + msgOf(e)); return false; } }
@@ -601,7 +728,11 @@ export function apply(ctx, config) {
601
728
  onAgentEvent('agent/created', (payload) => {
602
729
  try {
603
730
  const agent = payload && payload.agent ? payload.agent : null;
604
- if (agent) applyRoleTo(agent);
731
+ if (agent) {
732
+ const h = agent.session && agent.session.header ? agent.session.header : null;
733
+ const sid0 = String((h && h.id) || agent.id || '');
734
+ applyRoleTo(agent);
735
+ }
605
736
  } catch (e) { env.log('角色装配失败:' + msgOf(e)); }
606
737
  });
607
738
  onAgentEvent('agent/disposed', (payload) => {
@@ -617,25 +748,29 @@ export function apply(ctx, config) {
617
748
  if (typeof ld === 'function') { try { ld(); } catch { /* ignore */ } }
618
749
  legacyRestrict.delete(sid);
619
750
  appliedRole.delete(sid);
751
+ systemSig.delete(sid);
620
752
  roleCache.delete(sid);
621
753
  } catch { /* ignore */ }
622
754
  });
623
- // 开轮前核对:休眠期间角色可能被改过,而宿主恢复同一活体时不保证再发创建事件。
624
- // 状态切到 running 时核一次,提示段与旧内核的限制同角色表对齐。
755
+ // 开轮前核对:休眠期间角色可能已被修改,宿主恢复同一活体时不保证再次发出创建事件。
756
+ // 状态切换到 running 时核对一次,使提示段与旧内核的限制同角色表对齐。
625
757
  onAgentEvent('agent/status', (payload) => {
626
758
  try {
627
- if (!payload || payload.status !== 'running') return;
759
+ if (!payload) return;
628
760
  const agent = payload.agent || null;
629
761
  if (!agent) return;
630
762
  const h = agent.session && agent.session.header ? agent.session.header : null;
631
763
  const sid = String((h && h.id) || agent.id || '');
632
764
  if (!sid) return;
765
+ if (payload.status !== 'running') return;
633
766
  const info = infoFor(sid, scopedMetaOf(h, sid));
634
- // 角色没变也要看授权签名:用户刚从设置页或斜杠命令授权时,下一轮就该看见那一行。
767
+ // 角色未变时仍须核对授权签名与体系注册签名:授权到达或注册改写后,下一轮即应看到新内容。
635
768
  let sig = '';
636
769
  try { sig = activeConsentsFor(sid).map((r) => r.kind + ':' + r.until).join('|'); } catch { sig = ''; }
637
- if (appliedRole.get(sid) === info.role && consentSig.get(sid) === sig) return;
638
- env.log('开轮前换装:' + sid + ' · 已装 ' + (appliedRole.get(sid) || '-') + ' · 目标 ' + info.role);
770
+ let sysSig = '';
771
+ try { sysSig = registrySignature(workspaceForAgent(agent), info); } catch { sysSig = ''; }
772
+ if (appliedRole.get(sid) === info.role && consentSig.get(sid) === sig && systemSig.get(sid) === sysSig) return;
773
+ env.log('开轮前重装提示段:' + sid + ' · 已装配 ' + (appliedRole.get(sid) || '-') + ' · 目标 ' + info.role);
639
774
  applyRoleTo(agent, info);
640
775
  } catch (e) { env.log('开轮前装配失败:' + msgOf(e)); }
641
776
  });
@@ -643,9 +778,9 @@ export function apply(ctx, config) {
643
778
  initErrors.push('会话事件监听:' + msgOf(e));
644
779
  }
645
780
 
646
- // 插件重新加载(停用再启用、或启动时活体会话早于插件)时,已存在的会话不会再收到
647
- // 创建事件;这里补一次装配,否则它们的提示段会随旧实例一起消失。
648
- // agents 未在 inject 里声明,激活瞬间可能还取不到,所以取不到时延迟再试一次。
781
+ // 插件重新加载(停用后启用,或启动时活体会话早于插件)时,已存在的会话不再收到
782
+ // 创建事件;此处补充一次装配,否则其提示段会随旧实例一起消失。
783
+ // agents 未在 inject 中声明,激活瞬间可能尚未就绪,故首次未取到时延迟重试一次。
649
784
  const reapplyLive = () => {
650
785
  let agents = null;
651
786
  try { agents = ctx.agents; } catch { agents = null; }
@@ -655,9 +790,15 @@ export function apply(ctx, config) {
655
790
  if (!agents || typeof agents.list !== 'function') return 0;
656
791
  let n = 0;
657
792
  for (const a of agents.list()) {
658
- try { applyRoleTo(a); n++; } catch { /* 单个会话装配失败不影响插件激活 */ }
793
+ try {
794
+ // 插件重载时正在运行的会话不再收到 running 事件,此处补记轮内状态,
795
+ // 否则提醒须待下一轮才判定。
796
+ const h = a && a.session && a.session.header ? a.session.header : null;
797
+ const sid = String((h && h.id) || (a && a.id) || '');
798
+ applyRoleTo(a); n++;
799
+ } catch { /* 单个会话装配失败不影响插件激活 */ }
659
800
  }
660
- if (n > 0) env.log('补装配 ' + n + ' 个活体会话');
801
+ if (n > 0) env.log('补充装配 ' + n + ' 个活体会话');
661
802
  return n;
662
803
  };
663
804
  try {
@@ -665,10 +806,10 @@ export function apply(ctx, config) {
665
806
  const t = setTimeout(() => { try { reapplyLive(); } catch { /* ignore */ } }, 1200);
666
807
  if (t && typeof t.unref === 'function') t.unref();
667
808
  }
668
- } catch { /* 拿不到 agents 服务就跳过补装配 */ }
809
+ } catch { /* 无法取得 agents 服务时跳过补充装配 */ }
669
810
  }
670
811
 
671
- // ---------------- 设置页数据接口:主通道=DSH 认证的 /api 载体;旧通道=/dsh-handover HTTP 路由
812
+ // ---------------- 设置页数据接口:主通道为 DSH 认证的 /api 载体,旧通道为 /dsh-handover HTTP 路由
672
813
  try {
673
814
  const info = pkgInfo();
674
815
  const pageInfo = {
@@ -681,9 +822,9 @@ export function apply(ctx, config) {
681
822
  env.api = api;
682
823
  registerStatusRoute(ctx, env, pageInfo, api);
683
824
  registerRpcCarrier(ctx, api, (m) => env.log(m));
684
- // 预热状态快照:设置页直接取缓存,避免请求等待现算。
825
+ // 预热状态快照:设置页直接读取缓存,避免请求时现算等待。
685
826
  try {
686
- // 只在「最近 5 分钟有人看过设置页」时预热:没人看就不算,避免空转占宿主主线程。
827
+ // 仅在最近 5 分钟内有人查看设置页时预热:无人查看即不计算,避免空转占用宿主主线程。
687
828
  const warm = () => {
688
829
  if (Date.now() - (env.lastStatusCallAt || 0) > 5 * 60 * 1000) return;
689
830
  try { statusSnapshot(env, pageInfo, 'warm'); } catch { /* ignore */ }
@@ -698,23 +839,23 @@ export function apply(ctx, config) {
698
839
  initErrors.push('设置页路由:' + msgOf(e));
699
840
  }
700
841
 
701
- // ---------------- 聊天页命令:用户在自己的输入框敲 /allow-compact、/allow-succeed 即可授权
702
- // 为什么要有它:设置页要切页面、还要填会话 id,太绕。斜杠命令是「用户亲手敲的一行」,
703
- // 天然满足「只有用户能产生同意」这条判据,且不产生模型消息、不花 token。
842
+ // ---------------- 聊天页命令:用户在输入框输入 /allow-compact、/allow-succeed 即可授权
843
+ // 设计理由:设置页须切换页面并填写会话 id,操作步骤较多。斜杠命令由用户亲手输入,
844
+ // 天然满足「同意只能由用户作出」的判据,且不产生模型消息、不消耗 token。
704
845
  try {
705
846
  const allowCommands = [
706
847
  {
707
848
  name: 'allow-compact',
708
- description: '允许本会话压缩一次(当前用户授权,5 分钟有效;可带会话短号授权别的会话)',
709
- hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
849
+ description: '允许本会话压缩一次(由当前用户授权,5 分钟有效;可附会话短号授权其他会话)',
850
+ hint: '可留空,默认授权本会话;填写 8 位短号或 session-id 则授权对应会话',
710
851
  kind: 'compact',
711
852
  note: '聊天页命令 /allow-compact',
712
853
  action: '压缩',
713
854
  },
714
855
  {
715
856
  name: 'allow-succeed',
716
- description: '允许本会话换任一次(当前用户授权,5 分钟有效;可带会话短号授权别的会话)',
717
- hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
857
+ description: '允许本会话换任一次(由当前用户授权,5 分钟有效;可附会话短号授权其他会话)',
858
+ hint: '可留空,默认授权本会话;填写 8 位短号或 session-id 则授权对应会话',
718
859
  kind: 'succeed',
719
860
  note: '聊天页命令 /allow-succeed',
720
861
  action: '换任',
@@ -732,10 +873,10 @@ export function apply(ctx, config) {
732
873
  const sid = arg ? resolveShortSid(env, arg) : (agent && agent.id ? String(agent.id) : '');
733
874
  if (!sid) return { kind: 'error', text: '无法获取会话 id:请写成 /' + cmd.name + ' session-xxxx' };
734
875
  const g = grantConsent(sid, cmd.note, cmd.kind);
735
- // 授权一到就重装提示段:目标会话下一轮(哪怕现在空闲)就能看见那一行。
876
+ // 授权到达后立即重装提示段:目标会话下一轮即可看到该行(当前空闲亦然)。
736
877
  try { env.refreshPromptFor(sid); } catch { /* 装配失败不影响授权本身 */ }
737
878
  try { env.log(cmd.action + '授权(命令):' + sid); } catch { /* ignore */ }
738
- return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内' + cmd.action + '一次(用掉即失效)。' };
879
+ return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内' + cmd.action + '一次(用后即失效)。' };
739
880
  },
740
881
  });
741
882
  if (typeof d === 'function' && typeof ctx.effect === 'function') {
@@ -743,7 +884,7 @@ export function apply(ctx, config) {
743
884
  }
744
885
  }
745
886
  };
746
- // 可选服务:能用 ctx.inject 就走注入(老宿主没有 commands 时插件照常激活),否则退回直取。
887
+ // 可选服务:可用 ctx.inject 时走注入(旧宿主没有 commands 时插件照常激活),否则直接获取。
747
888
  if (typeof ctx.inject === 'function') {
748
889
  ctx.inject(['commands'], (scope) => {
749
890
  try { registerAllow(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
@@ -754,4 +895,27 @@ export function apply(ctx, config) {
754
895
  } catch (e) {
755
896
  initErrors.push('命令注册:' + msgOf(e));
756
897
  }
898
+
899
+ // 插件重载后,已存在的会话不会自动重装提示段,故补注册提醒段。
900
+ // 角色提示段(manual)由宿主按 agent 持有,同名段无法修改文本;提醒段为新段,可以补注册。
901
+ try {
902
+ const timer = setTimeout(() => {
903
+ Promise.resolve().then(async () => {
904
+ try {
905
+ const controller = typeof env.controller === 'function' ? env.controller() : null;
906
+ if (!controller || typeof controller.list !== 'function') return;
907
+ const rows = await controller.list();
908
+ for (const row of rows || []) {
909
+ if (!row || row.agentAvailable !== true) continue;
910
+ const info = env.infoOfSid(row.sessionId);
911
+ if (!info || ['global', 'project', 'misc'].indexOf(info.role) === -1) continue;
912
+ env.refreshPromptFor(row.sessionId);
913
+ }
914
+ } catch (e) { void e; }
915
+ });
916
+ }, 1500);
917
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => clearTimeout(timer), 'dsh-handover: 启动补注册提醒段');
918
+ } catch (e) {
919
+ initErrors.push('启动补注册:' + msgOf(e));
920
+ }
757
921
  }