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