@mingmaple/dsh-handover 0.9.6 → 0.9.13

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 (50) hide show
  1. package/README.md +53 -17
  2. package/docs/migrate.md +1 -1
  3. package/docs/releases/0.9.12.md +28 -0
  4. package/docs/releases/0.9.13.md +30 -0
  5. package/docs/troubleshoot.md +43 -6
  6. package/package.json +70 -57
  7. package/scripts/blank-dsh.mjs +6 -5
  8. package/scripts/blank-smoke.mjs +268 -58
  9. package/scripts/build.mjs +3 -0
  10. package/scripts/check-docs.mjs +23 -2
  11. package/scripts/office.mjs +81 -0
  12. package/src/client.js +92 -23
  13. package/src/index.mjs +264 -145
  14. package/src/lib/api.mjs +67 -9
  15. package/src/lib/config.mjs +4 -3
  16. package/src/lib/consent.mjs +59 -20
  17. package/src/lib/facts.mjs +5 -4
  18. package/src/lib/guide.mjs +14 -7
  19. package/src/lib/handover-io.mjs +20 -0
  20. package/src/lib/manual.mjs +23 -12
  21. package/src/lib/names.mjs +2 -2
  22. package/src/lib/office.mjs +272 -0
  23. package/src/lib/pack-ops.mjs +3 -0
  24. package/src/lib/phase.mjs +6 -4
  25. package/src/lib/prompt.mjs +18 -0
  26. package/src/lib/role.mjs +52 -18
  27. package/src/lib/schedule.mjs +10 -14
  28. package/src/lib/session-create.mjs +42 -0
  29. package/src/lib/session-ops.mjs +183 -79
  30. package/src/lib/sessions.mjs +9 -8
  31. package/src/lib/status.mjs +8 -2
  32. package/src/lib/succession.mjs +244 -0
  33. package/src/lib/text.mjs +27 -0
  34. package/src/lib/webroute.mjs +1 -1
  35. package/src/lib/workspace.mjs +1 -1
  36. package/src/selftest.mjs +15 -0
  37. package/src/templates/Charter.skeleton.md +1 -1
  38. package/src/templates/Prompt_Templates.md +9 -17
  39. package/src/templates/Runtime_Manual.md +17 -14
  40. package/src/templates/rules/Handover_Rules.md +3 -2
  41. package/src/templates/rules/Misc_Task_Standard.md +3 -3
  42. package/src/tools/ho_checkpoint.mjs +11 -4
  43. package/src/tools/ho_diag.mjs +5 -3
  44. package/src/tools/ho_handover.mjs +7 -6
  45. package/src/tools/ho_office.mjs +59 -0
  46. package/src/tools/ho_profile.mjs +10 -2
  47. package/src/tools/ho_sessions.mjs +3 -2
  48. package/src/tools/ho_snapshot.mjs +2 -1
  49. package/src/tools/ho_succeed.mjs +24 -0
  50. package/src/tools/ho_workspace.mjs +7 -3
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-handover —— 全局运行期插件。
3
3
  * 判据进代码、流程进工具;规则正文留磁盘,需要时用 ho_charter 取节(完整流程 doc="runtime")。
4
- * 按会话角色载入工具与提示段:全局 22 个工具、项目 9、杂项 9、普通 0。
4
+ * 按会话角色载入工具与提示段:全局 24 个工具、项目 11、杂项 11、普通 0。
5
5
  * 源码不热加载:改完要重启 DSH。
6
6
  */
7
7
  import fs from 'node:fs';
@@ -27,6 +27,8 @@ import { pmPackTool } from './tools/ho_pack.mjs';
27
27
  import { pmWorkspaceTool } from './tools/ho_workspace.mjs';
28
28
  import { pmInitTool } from './tools/ho_init.mjs';
29
29
  import { pmProfileTool } from './tools/ho_profile.mjs';
30
+ import { pmOfficeTool } from './tools/ho_office.mjs';
31
+ import { pmSucceedTool } from './tools/ho_succeed.mjs';
30
32
  import { pmPeerListTool } from './tools/peer_list.mjs';
31
33
  import { pmPeerStatusTool } from './tools/peer_status.mjs';
32
34
  import { pmPeerSendTool } from './tools/peer_send.mjs';
@@ -41,9 +43,11 @@ import { NAMES } from './lib/names.mjs';
41
43
  import { readExtraPrompt, writeExtraPrompt, EXTRA_PROMPT_ORDER, EXTRA_PROMPT_SECTION } from './lib/extra-prompt.mjs';
42
44
  import {
43
45
  ROLES, resolveRole, sessionMeta, handoverPathFor, toolsetFor,
44
- readRoles, writeRole, bootstrappedAt,
46
+ readRoles, writeRole, assertProjectName, assertTaskName,
45
47
  } from './lib/role.mjs';
46
48
  import { manualFor, organizeMessageFor } from './lib/manual.mjs';
49
+ import { sendPrompt } from './lib/prompt.mjs';
50
+ import { resolveShortSid } from './lib/api.mjs';
47
51
  import { routeWorkspace } from './lib/workspace.mjs';
48
52
  import { stampTz } from './lib/brief.mjs';
49
53
  import { grantConsent } from './lib/consent.mjs';
@@ -101,9 +105,15 @@ export function schedDepsFor(env) {
101
105
  export function apply(ctx, config) {
102
106
  const cfg = resolveConfig(config);
103
107
  const initErrors = [];
104
- const registered = new Set();
105
- const scoped = new Map(); // sessionId → dispose(撤销该会话的工具限制与提示段)
106
- const roleState = { gating: '', last: null, bootstrap: '' };
108
+ const registered = new Set(); // 已注册的工具名(旧内核的限制式裁剪要用)
109
+ // 0.2.2 起工具按「步」解析(tools.resolve):可用工具集合每一步重算,角色过滤直接放进注册条件。
110
+ // 更早的内核忽略注册选项,只能退回 agent 作用域的 restrict;两条路径按能力二选一,不并行。
111
+ const perStep = (() => { try { return !!(ctx.tools && typeof ctx.tools.resolve === 'function'); } catch { return false; } })();
112
+ const sectionDispose = new Map(); // sessionId → 撤销提示段
113
+ const legacyRestrict = new Map(); // sessionId → 撤销限制(仅无按步解析的内核)
114
+ const appliedRole = new Map(); // sessionId → 上次装配的角色
115
+ const roleCache = new Map(); // sessionId → { at, info }(每步过滤用,短 TTL)
116
+ const roleState = { last: null };
107
117
  let scheduler = null;
108
118
  let schedulerError = "";
109
119
  let guardLast = null;
@@ -192,6 +202,12 @@ export function apply(ctx, config) {
192
202
  staleNote(exec) {
193
203
  try { return stalePressureNote(this.selfId(exec)); } catch { return ''; }
194
204
  },
205
+ /** 自身版本:从 dist/package.json 读,设置页与 ho_diag 共用同一值。 */
206
+ version() {
207
+ try { return pkgInfo().version; } catch { return '?'; }
208
+ },
209
+ /** 当前已装配提示段的会话数(诊断用)。 */
210
+ assembledCount() { return sectionDispose.size; },
195
211
  log(m) {
196
212
  try { if (ctx.logger && typeof ctx.logger.warn === 'function') ctx.logger.warn(String(m)); } catch { /* ignore */ }
197
213
  },
@@ -217,12 +233,36 @@ export function apply(ctx, config) {
217
233
  }
218
234
 
219
235
  function agentOf(sid) {
236
+ const want = String(sid || '');
237
+ try {
238
+ const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(want) : null;
239
+ if (a) return a;
240
+ } catch { /* 退回列表查找 */ }
241
+ // 宿主的注册表键不保证就是会话 id:取不到时按会话头的 id 在活体列表里再找一次。
242
+ // 少了这一层,休眠会话在设置页被改角色时会被判成「不在内存」,只能等重启才装配。
220
243
  try {
221
- const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sid) : null;
222
- return a || null;
223
- } catch { return null; }
244
+ const list = ctx.agents && typeof ctx.agents.list === 'function' ? (ctx.agents.list() || []) : [];
245
+ for (const a of list) {
246
+ const h = a && a.session ? a.session.header : null;
247
+ if (h && String(h.id || '') === want) return a;
248
+ }
249
+ } catch { /* ignore */ }
250
+ return null;
224
251
  }
225
252
 
253
+ /**
254
+ * 会话头就能判定的子级会话:子会话与团队成员的会话都带 origin=subagent 或 parentSession。
255
+ * 只读 header,不碰磁盘,可放在「每一步」的条件启用里。
256
+ */
257
+ function isChildSession(agent) {
258
+ try {
259
+ const h = agent && agent.session ? agent.session.header : null;
260
+ if (!h) return false;
261
+ return h.origin === 'subagent' || !!h.parentSession;
262
+ } catch { return false; }
263
+ }
264
+
265
+
226
266
  // ---------------- 角色判定
227
267
  function infoFor(sid, opts) {
228
268
  const o = opts || {};
@@ -239,9 +279,22 @@ export function apply(ctx, config) {
239
279
 
240
280
  env.roleOf = (exec) => infoFor(env.selfId(exec) || '', { cwd: env.selfCwd(exec) || '' });
241
281
  env.toolsetOf = (role) => toolsetFor(role);
242
- env.roleState = () => ({ gating: roleState.gating, last: roleState.last, bootstrap: roleState.bootstrap });
282
+ env.roleState = () => ({ mode: roleMode(), last: roleState.last, count: appliedRole.size });
243
283
  env.rolesSnapshot = () => readRoles(cfg).sessions;
244
- env.writeRole = (sid, patch) => writeRole(cfg, sid, patch);
284
+ // 写角色表是一切角色变更的唯一入口:写完立刻让每步过滤的短缓存失效,下一步就用新角色。
285
+ env.writeRole = (sid, patch) => {
286
+ const saved = writeRole(cfg, sid, patch);
287
+ roleCache.delete(String(sid || ''));
288
+ return saved;
289
+ };
290
+
291
+ /** 当前生效的裁剪机制,供 ho_diag 报出。 */
292
+ function roleMode() {
293
+ if (cfg.roleGating === 'off') return '关闭(roleGating=off,所有会话可见全部工具)';
294
+ return perStep
295
+ ? '按步过滤(工具表每步重算,换角色下一轮即生效)'
296
+ : '旧式收窄(内核不支持按步解析,用 agent 作用域 restrict)';
297
+ }
245
298
 
246
299
  function handoverInfoFor(info, ws) {
247
300
  if (cfg.handoverPath && info.role === 'global') {
@@ -269,77 +322,67 @@ export function apply(ctx, config) {
269
322
  env.agentOfSid = (sid) => agentOf(String(sid || ''));
270
323
  env.compactionStateOfSid = (sid) => compactionStateOf(String(sid || ''), null);
271
324
 
272
- // ---------------- 按角色装配(工具限制 + 提示段 + 一次性引导)
325
+ // ---------------- 按角色装配:工具可见性由每步条件即时算,这里只维护提示段(旧内核另加 restrict)
273
326
  function workspaceForAgent(agent) {
274
327
  if (cfg.workspaceRoot) return cfg.workspaceRoot;
275
328
  const cwd = agent && agent.session && agent.session.header ? String(agent.session.header.cwd || '') : '';
276
329
  return walkUpToWorkspace(cwd || process.cwd());
277
330
  }
278
331
 
279
- function applyScoped(agent, info) {
280
- const sid = agent && agent.id ? String(agent.id) : (agent && agent.session && agent.session.header ? String(agent.session.header.id || '') : '');
281
- const old = scoped.get(sid);
282
- if (typeof old === 'function') { try { old(); } catch { /* 旧限制已失效 */ } }
283
- scoped.delete(sid);
284
- const disposers = [];
285
-
286
- // 1) 工具:只留本角色需要的(restrict 掩码取交集,所以必须先把旧限制撤掉)
287
- if (cfg.roleGating !== 'off' && info.role !== 'global') {
288
- const deny = toolsetFor(info.role).deny.filter((n) => registered.has(n));
289
- if (deny.length) {
290
- try {
291
- const sc = agent && agent.ctx ? agent.ctx : null;
292
- if (sc && sc.tools && typeof sc.tools.restrict === 'function') {
293
- disposers.push(sc.tools.restrict({ deny }));
294
- roleState.gating = '可用';
295
- } else {
296
- roleState.gating = '不可用(agent.ctx.tools.restrict 缺失,本会话仍可见全部工具)';
297
- }
298
- } catch (e) {
299
- roleState.gating = '失败:' + msgOf(e);
300
- }
301
- }
302
- } else if (info.role === 'global') {
303
- roleState.gating = '可用(全局不裁剪)';
304
- }
305
-
306
- // 2) 提示段:普通会话不注册;其余角色注册自己的段落。
332
+ /** 注册/替换本会话的角色提示段;普通会话不注册。 */
333
+ function applySection(agent, sid, info) {
334
+ const old = sectionDispose.get(sid);
335
+ if (typeof old === 'function') { try { old(); } catch { /* 旧段已失效 */ } }
336
+ sectionDispose.delete(sid);
337
+ let text = '';
307
338
  try {
308
339
  const ws = workspaceForAgent(agent);
309
- const text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null });
310
- const sc = agent && agent.ctx ? agent.ctx : null;
311
- if (text && sc && sc.systemPrompt && typeof sc.systemPrompt.section === 'function') {
312
- disposers.push(sc.systemPrompt.section({ name: 'dsh-handover:manual', order: 100, text, interpolate: false }));
313
- }
340
+ text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null });
314
341
  } catch (e) {
315
342
  initErrors.push('角色提示段:' + msgOf(e));
343
+ return;
316
344
  }
317
-
318
- if (disposers.length) {
319
- scoped.set(sid, () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } });
345
+ 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));
320
353
  }
321
- roleState.last = { sid, role: info.role, at: stampTz(Date.now()) };
354
+ }
322
355
 
323
- // 不向会话历史写任何内容:会话格式 v4 拒绝插件自造的 source,写盘失败会连累该会话后续每次写盘。
324
- // 角色需要的静态信息全部由第 2 步的同名提示段承载。
325
- if (info.role === 'project' || info.role === 'misc') {
326
- const was = bootstrappedAt(cfg, sid);
327
- roleState.bootstrap = was ? '不写历史(提示段承载)· ' + sid : '不写历史(提示段承载)';
328
- }
356
+ /** 旧内核(没有按步解析)才用的限制式裁剪。 */
357
+ function applyLegacyRestrict(agent, sid, info) {
358
+ const old = legacyRestrict.get(sid);
359
+ if (typeof old === 'function') { try { old(); } catch { /* 旧限制已失效 */ } }
360
+ legacyRestrict.delete(sid);
361
+ if (cfg.roleGating === 'off' || info.role === 'global') return;
362
+ try {
363
+ const sc = agent && agent.ctx ? agent.ctx : null;
364
+ if (!sc || !sc.tools || typeof sc.tools.restrict !== 'function') return;
365
+ const deny = toolsetFor(info.role).deny.filter((n) => registered.has(n));
366
+ if (deny.length) legacyRestrict.set(sid, sc.tools.restrict({ deny }));
367
+ } catch { /* 旧内核裁剪失败只影响可见工具,不影响其余功能 */ }
329
368
  }
330
369
 
331
- function promptSession(sessionId, text) {
332
- const controller = env.controller();
333
- if (!controller || typeof controller.prompt !== 'function') throw new Error('sessionController.prompt 不可用');
334
- return controller.prompt({
335
- requestId: (globalThis.crypto && globalThis.crypto.randomUUID) ? globalThis.crypto.randomUUID() : String(Date.now()),
336
- sessionId,
337
- mode: 'queue',
338
- content: [{ type: 'text', text }],
339
- }, new AbortController().signal);
370
+ /** 让一个活体会话的提示段与本会话当前角色对齐。info 可传入已解析好的角色,省一次判定。 */
371
+ function applyRoleTo(agent, infoOpt) {
372
+ const h = agent && agent.session ? agent.session.header : null;
373
+ const sid = String((h && h.id) || (agent && agent.id) || '');
374
+ if (!sid) return;
375
+ let info = infoOpt || null;
376
+ if (!info) {
377
+ try { info = infoFor(sid, scopedMetaOf(h, sid)); } catch { return; }
378
+ }
379
+ applySection(agent, sid, info);
380
+ if (!perStep) applyLegacyRestrict(agent, sid, info);
381
+ appliedRole.set(sid, info.role);
382
+ roleState.last = { sid, role: info.role, at: stampTz(Date.now()) };
340
383
  }
341
384
 
342
- /** 设定角色:写角色表 → 存活会话当场换工具与提示 → 可选投一次性整理指令(唤醒它一轮)。 */
385
+ /** 设定角色:写角色表 → 存活会话当场换提示段(工具在下一步按新角色重算)→ 可选投整理指令。 */
343
386
  async function applyRole(input) {
344
387
  const i = input || {};
345
388
  const sid = String(i.sessionId || '').trim();
@@ -350,20 +393,24 @@ export function apply(ctx, config) {
350
393
  if (ROLES.indexOf(role) === -1) return { ok: false, error: '角色只能是 global、project、misc、plain' };
351
394
  if (role === 'project' && !project) return { ok: false, error: '项目角色要填项目名' };
352
395
  if (role === 'misc' && !task) return { ok: false, error: '杂项角色要填任务名' };
396
+ // 名字要落成目录:不合法就当场拒绝,别等写角色表或拼路径时才失败。
397
+ if (role === 'project') { try { assertProjectName(project); } catch (e) { return { ok: false, error: msgOf(e) }; } }
398
+ if (role === 'misc') { try { assertTaskName(task); } catch (e) { return { ok: false, error: msgOf(e) }; } }
353
399
  try { writeRole(cfg, sid, { role, project, task, source: 'user' }); }
354
400
  catch (e) { return { ok: false, error: '写角色表失败:' + msgOf(e) }; }
401
+ roleCache.delete(sid);
355
402
  const info = { role, project, task, source: 'store', sessionId: sid };
356
403
  let live = false;
357
404
  try {
358
405
  const a = agentOf(sid);
359
- if (a) { live = true; applyScoped(a, info); }
406
+ if (a) { live = true; applyRoleTo(a, info); }
360
407
  } catch { /* 活体装配失败不影响写盘 */ }
361
408
  let delivered = false;
362
409
  let error = '';
363
410
  const msg = organizeMessageFor(info, { ws: routeWorkspace(env) });
364
411
  if (i.wake !== false && msg) {
365
412
  try {
366
- const r = await promptSession(sid, msg);
413
+ const r = await sendPrompt(env.controller(), sid, msg, 'queue');
367
414
  delivered = !!(r && r.accepted);
368
415
  } catch (e) {
369
416
  error = msgOf(e);
@@ -373,6 +420,18 @@ export function apply(ctx, config) {
373
420
  }
374
421
  env.applyRole = applyRole;
375
422
 
423
+ /** 会话新建后立即按已登记角色装配(不唤醒、不投消息)。 */
424
+ env.applyScopedLive = (sid, info) => {
425
+ try {
426
+ const a = agentOf(String(sid || ''));
427
+ if (!a) return false;
428
+ const i = Object.assign({ role: 'plain', project: '', task: '', source: 'live' }, info || {});
429
+ if (!i.role) i.role = 'plain';
430
+ applyRoleTo(a, i);
431
+ return true;
432
+ } catch { return false; }
433
+ };
434
+
376
435
  // ---------------- 调度器(续跑文案要按角色取交接路径)
377
436
  // 注意:调度器拿到的是会话 id 字符串,不是 exec;这里用按 id 解析的版本,直接读角色表与磁盘。
378
437
  const schedDeps = { ctx, cfg, logger: (ctx.logger || null), roleOf: null, handoverFor: null };
@@ -413,7 +472,31 @@ export function apply(ctx, config) {
413
472
  };
414
473
  try { applyExtraPrompt(readExtraPrompt()); } catch (e) { initErrors.push('全局前置提示词:' + msgOf(e)); }
415
474
 
416
- // ---------------- 工具注册(16 个 ho_* + 6 个 peer_*)
475
+ // ---------------- 工具注册(18 个 ho_* + 6 个 peer_*)
476
+ // 0.2.2 起工具按「步」解析:可用工具集合每一步重算。子会话排除与角色过滤都在这里一次做完,
477
+ // 不再另用 agent 作用域 restrict —— 两套机制并行时,换角色要么等到重启、要么互相覆盖。
478
+ const roleToolsets = new Map(ROLES.map((r) => [r, new Set(toolsetFor(r).allow)]));
479
+ /** 每步过滤:子会话一律不可用;角色按当前角色表算;「无 agent」的路径(命令行、演练)放行。 */
480
+ function visibleTo(name) {
481
+ return (c) => {
482
+ const agent = c && c.agent ? c.agent : null;
483
+ if (isChildSession(agent)) return false;
484
+ if (!perStep || cfg.roleGating === 'off' || !agent) return true;
485
+ const h = agent.session && agent.session.header ? agent.session.header : null;
486
+ const sid = String((h && h.id) || agent.id || '');
487
+ if (!sid) return true;
488
+ let info = null;
489
+ const hit = roleCache.get(sid);
490
+ if (hit && Date.now() - hit.at < 2000) info = hit.info;
491
+ else {
492
+ try { info = infoFor(sid, scopedMetaOf(h, sid)); } catch { info = null; }
493
+ if (info) roleCache.set(sid, { at: Date.now(), info });
494
+ }
495
+ if (!info) return true;
496
+ const allow = roleToolsets.get(info.role);
497
+ return allow ? allow.has(name) : true;
498
+ };
499
+ }
417
500
  const tools = [
418
501
  pmBriefTool(env),
419
502
  pmGateTool(env),
@@ -431,15 +514,17 @@ export function apply(ctx, config) {
431
514
  pmWorkspaceTool(env),
432
515
  pmInitTool(env),
433
516
  pmProfileTool(env),
517
+ pmOfficeTool(env),
518
+ pmSucceedTool(env),
434
519
  ];
435
- const keys = ['brief', 'gate', 'sessions', 'phase', 'charter', 'handover', 'checkpoint', 'snapshot', 'diag', 'drift', 'changelog', 'tokens', 'pack', 'workspace', 'init', 'profile'];
520
+ const keys = ['brief', 'gate', 'sessions', 'phase', 'charter', 'handover', 'checkpoint', 'snapshot', 'diag', 'drift', 'changelog', 'tokens', 'pack', 'workspace', 'init', 'profile', 'office', 'succeed'];
436
521
  for (let i = 0; i < tools.length; i++) {
437
522
  const key = keys[i];
438
523
  try {
439
524
  // 名字以工厂自带的为准;工厂没写才用位置表补,且顺序错位时报出来(不再静默错名)。
440
525
  if (!tools[i].name) tools[i].name = NAMES[key];
441
526
  else if (tools[i].name !== NAMES[key]) initErrors.push('工具名错位:数组第 ' + i + ' 项是 ' + tools[i].name + ',位置表期望 ' + NAMES[key]);
442
- ctx.tools.register(tools[i]);
527
+ ctx.tools.register(tools[i], { when: visibleTo(tools[i].name) });
443
528
  registered.add(tools[i].name);
444
529
  } catch (e) {
445
530
  initErrors.push(key + ':' + msgOf(e));
@@ -456,14 +541,15 @@ export function apply(ctx, config) {
456
541
  ];
457
542
  for (const t of peerTools) {
458
543
  try {
459
- ctx.tools.register(t);
544
+ ctx.tools.register(t, { when: visibleTo(t && t.name ? String(t.name) : '') });
460
545
  if (t && t.name) registered.add(t.name);
461
546
  } catch (e) {
462
547
  initErrors.push((t && t.name ? String(t.name) : 'peer_?') + ':' + msgOf(e));
463
548
  }
464
549
  }
465
550
 
466
- // ---------------- 唤醒权(Charter §7.2.4-9.1):按角色判定,只有全局可以唤醒别的会话
551
+ // ---------------- 会话守卫(Charter §7.2.4):新建会话与设定角色仅属全局;
552
+ // 会话之间允许直接 peer_send(不必经全局转述),directPeer=off 时退回「只能向全局报告」的旧口径。
467
553
  if (cfg.wakeAuthority !== 'off' && typeof ctx.tools.guard === 'function') {
468
554
  try {
469
555
  ctx.tools.guard((exec) => {
@@ -475,18 +561,21 @@ export function apply(ctx, config) {
475
561
  try { info = env.roleOf(exec); } catch { info = { role: 'plain', source: 'error' }; }
476
562
  const mk = (allow, why) => {
477
563
  guardLast = { sid, allow, source: why, title: titleOf(sid) };
478
- return allow ? undefined : '唤醒权只在全局:非全局会话只能 peer_send 给现任全局报告,不能唤醒其他项目会话(杂项自建 Misc 换任除外)。请把结论发回现任全局。';
564
+ if (allow) return undefined;
565
+ if (nm === 'peer_new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项落到 Misc 除外)。';
566
+ return '会话间通信受限(directPeer=off):非全局会话只能向现任全局报告,不得唤醒其他会话。';
479
567
  };
480
568
  if (info.role === 'global') return mk(true, 'global/' + info.source);
481
569
  if (nm === 'peer_send') {
482
- // 项目、杂项会话可以向「全局」报告;不许唤醒别的项目会话。
570
+ // 会话之间可直接对话;向全局报告也在此路。directPeer=off 才退回旧口径。
483
571
  const target = String(args.session_id || '');
484
572
  let t = null;
485
573
  try { t = target ? infoFor(target, {}) : null; } catch { t = null; }
486
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'));
487
576
  return mk(false, info.source + '/' + info.role + ' → ' + (t ? t.role : 'unknown'));
488
577
  }
489
- // peer_new:只有杂项可以自建 Misc 换任
578
+ // peer_new:只有杂项可以落到 Misc
490
579
  const target = String(args.workspace || args.cwd || '').toLowerCase();
491
580
  if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, 'misc/' + info.source);
492
581
  return mk(false, info.source + '/' + info.role);
@@ -494,61 +583,84 @@ export function apply(ctx, config) {
494
583
  } catch { /* 老版本没有 guard:退化为规则约束 */ }
495
584
  }
496
585
 
497
- // ---------------- 会话创建:按角色装配工具/提示段(载荷是 { agent, source, signal })
498
- // 出生竞态:ho_sessions(action="new") 先 controller.create(本监听当场跑完)再写角色表,
499
- // 新建会话出生时会被判成普通并收窄为零工具。做法是普通出生先不收窄,排一次延迟复核:
500
- // 角色到位就按真角色装配,仍是普通才收窄。
501
- const lateChecks = new Map();
502
- function clearLateCheck(sid) {
503
- const t = lateChecks.get(sid);
504
- if (t) { try { clearTimeout(t); } catch { /* ignore */ } lateChecks.delete(sid); }
505
- }
586
+ // ---------------- 会话生命周期:创建时装配提示段;开轮前按角色表核对一次
587
+ // 工具可见性不在这里处理:0.2.2 起每步条件已按当前角色算好(见 visibleTo),换角色下一轮即生效。
506
588
  function scopedMetaOf(h, sid) {
507
589
  return { title: (h && h.title) || titleOf(sid), cwd: (h && h.cwd) || '' };
508
590
  }
509
- function scheduleLateScoped(agent, sid, h) {
510
- clearLateCheck(sid);
511
- const arm = (delay, last) => {
512
- const t = setTimeout(() => {
513
- let info = null;
514
- try { info = infoFor(sid, scopedMetaOf(h, sid)); } catch { /* 读角色表失败就再等一轮 */ }
515
- if (info && info.role !== 'plain') { applyScoped(agent, info); lateChecks.delete(sid); return; }
516
- if (!last) { arm(3000, true); return; }
517
- lateChecks.delete(sid);
518
- if (!info) return;
519
- try { applyScoped(agent, info); } catch { /* 普通会话收窄失败不影响会话 */ }
520
- }, delay);
521
- if (t && typeof t.unref === 'function') t.unref();
522
- lateChecks.set(sid, t);
523
- };
524
- arm(1500, false);
525
- }
526
591
  if (typeof ctx.on === 'function') {
527
592
  try {
528
- ctx.on('agent/created', (payload) => {
593
+ // 事件作用域:宿主以 agent 作为事件的作用域载体,插件在根作用域裸注册收不到别的会话的事件。
594
+ // 传 { global: true } 才能收到全部会话的事件(0.2.2 支持;旧内核忽略选项,退回裸注册)。
595
+ const onAgentEvent = (name, fn) => {
596
+ try { ctx.on(name, fn, { global: true }); return true; }
597
+ catch { try { ctx.on(name, fn); return true; } catch (e) { env.log('会话事件监听失败(' + name + '):' + msgOf(e)); return false; } }
598
+ };
599
+ onAgentEvent('agent/created', (payload) => {
529
600
  try {
530
601
  const agent = payload && payload.agent ? payload.agent : null;
602
+ if (agent) applyRoleTo(agent);
603
+ } catch (e) { env.log('角色装配失败:' + msgOf(e)); }
604
+ });
605
+ onAgentEvent('agent/disposed', (payload) => {
606
+ try {
607
+ const agent = payload && payload.agent ? payload.agent : null;
608
+ const h = agent && agent.session ? agent.session.header : null;
609
+ const sid = String((h && h.id) || (agent && agent.id) || '');
610
+ if (!sid) return;
611
+ const d = sectionDispose.get(sid);
612
+ if (typeof d === 'function') { try { d(); } catch { /* ignore */ } }
613
+ sectionDispose.delete(sid);
614
+ const ld = legacyRestrict.get(sid);
615
+ if (typeof ld === 'function') { try { ld(); } catch { /* ignore */ } }
616
+ legacyRestrict.delete(sid);
617
+ appliedRole.delete(sid);
618
+ roleCache.delete(sid);
619
+ } catch { /* ignore */ }
620
+ });
621
+ // 开轮前核对:休眠期间角色可能被改过,而宿主恢复同一活体时不保证再发创建事件。
622
+ // 状态切到 running 时核一次,提示段与旧内核的限制同角色表对齐。
623
+ onAgentEvent('agent/status', (payload) => {
624
+ try {
625
+ if (!payload || payload.status !== 'running') return;
626
+ const agent = payload.agent || null;
531
627
  if (!agent) return;
532
628
  const h = agent.session && agent.session.header ? agent.session.header : null;
533
629
  const sid = String((h && h.id) || agent.id || '');
534
630
  if (!sid) return;
535
631
  const info = infoFor(sid, scopedMetaOf(h, sid));
536
- if (info.role === 'plain') { scheduleLateScoped(agent, sid, h); return; }
537
- applyScoped(agent, info);
538
- } catch (e) {
539
- env.log('角色装配失败:' + msgOf(e));
540
- }
541
- });
542
- ctx.on('agent/disposed', (payload) => {
543
- try {
544
- const agent = payload && payload.agent ? payload.agent : null;
545
- const sid = agent && agent.id ? String(agent.id) : '';
546
- if (sid) { scoped.delete(sid); clearLateCheck(sid); }
547
- } catch { /* ignore */ }
632
+ if (appliedRole.get(sid) === info.role) return;
633
+ env.log('开轮前换装:' + sid + ' · 已装 ' + (appliedRole.get(sid) || '-') + ' · 目标 ' + info.role);
634
+ applyRoleTo(agent, info);
635
+ } catch (e) { env.log('开轮前装配失败:' + msgOf(e)); }
548
636
  });
549
637
  } catch (e) {
550
- initErrors.push('agent/created 监听:' + msgOf(e));
638
+ initErrors.push('会话事件监听:' + msgOf(e));
551
639
  }
640
+
641
+ // 插件重新加载(停用再启用、或启动时活体会话早于插件)时,已存在的会话不会再收到
642
+ // 创建事件;这里补一次装配,否则它们的提示段会随旧实例一起消失。
643
+ // agents 未在 inject 里声明,激活瞬间可能还取不到,所以取不到时延迟再试一次。
644
+ const reapplyLive = () => {
645
+ let agents = null;
646
+ try { agents = ctx.agents; } catch { agents = null; }
647
+ if (!agents || typeof agents.list !== 'function') {
648
+ try { agents = typeof ctx.get === 'function' ? ctx.get('agents') : null; } catch { agents = null; }
649
+ }
650
+ if (!agents || typeof agents.list !== 'function') return 0;
651
+ let n = 0;
652
+ for (const a of agents.list()) {
653
+ try { applyRoleTo(a); n++; } catch { /* 单个会话装配失败不影响插件激活 */ }
654
+ }
655
+ if (n > 0) env.log('补装配 ' + n + ' 个活体会话');
656
+ return n;
657
+ };
658
+ try {
659
+ if (reapplyLive() === 0) {
660
+ const t = setTimeout(() => { try { reapplyLive(); } catch { /* ignore */ } }, 1200);
661
+ if (t && typeof t.unref === 'function') t.unref();
662
+ }
663
+ } catch { /* 拿不到 agents 服务就跳过补装配 */ }
552
664
  }
553
665
 
554
666
  // ---------------- 设置页数据接口:主通道=DSH 认证的 /api 载体;旧通道=/dsh-handover HTTP 路由
@@ -581,49 +693,56 @@ export function apply(ctx, config) {
581
693
  initErrors.push('设置页路由:' + msgOf(e));
582
694
  }
583
695
 
584
- // ---------------- 聊天页命令:用户在自己的输入框敲 /allow-compact 即可授权压缩
696
+ // ---------------- 聊天页命令:用户在自己的输入框敲 /allow-compact、/allow-succeed 即可授权
585
697
  // 为什么要有它:设置页要切页面、还要填会话 id,太绕。斜杠命令是「用户亲手敲的一行」,
586
698
  // 天然满足「只有用户能产生同意」这条判据,且不产生模型消息、不花 token。
587
699
  try {
588
- const registerAllowCompact = (service) => {
589
- if (!service || typeof service.register !== 'function') return;
590
- const sidByShort = (short) => {
591
- const s = String(short || '').trim();
592
- if (!s) return '';
593
- if (s.indexOf('session-') === 0) return s;
594
- try {
595
- for (const f of readJsonDir(projCacheDir())) {
596
- const stem = f.name.replace('.json', '');
597
- if (stem.indexOf('session-' + s) === 0) return stem;
598
- if (stem.slice(8, 8 + s.length) === s) return stem;
599
- }
600
- } catch { /* ignore */ }
601
- return s;
602
- };
603
- const d = service.register({
700
+ const allowCommands = [
701
+ {
604
702
  name: 'allow-compact',
605
703
  description: '允许本会话压缩一次(用户授权,5 分钟有效;可带会话短号授权别的会话)',
606
- input: { hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话' },
607
- handler: ({ agent, rawInput }) => {
608
- const arg = String(rawInput || '').trim();
609
- const sid = arg ? sidByShort(arg) : (agent && agent.id ? String(agent.id) : '');
610
- if (!sid) return { kind: 'error', text: '拿不到会话 id:请写成 /allow-compact session-xxxx' };
611
- const g = grantConsent(sid, '聊天页命令 /allow-compact');
612
- try { env.log('压缩授权(命令):' + sid); } catch { /* ignore */ }
613
- return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内压缩一次(用掉即失效)。' };
614
- },
615
- });
616
- if (typeof d === 'function' && typeof ctx.effect === 'function') {
617
- ctx.effect(() => d, 'dsh-handover: /allow-compact');
704
+ hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
705
+ kind: 'compact',
706
+ note: '聊天页命令 /allow-compact',
707
+ action: '压缩',
708
+ },
709
+ {
710
+ name: 'allow-succeed',
711
+ description: '允许本会话换任一次(用户授权,5 分钟有效;可带会话短号授权别的会话)',
712
+ hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话',
713
+ kind: 'succeed',
714
+ note: '聊天页命令 /allow-succeed',
715
+ action: '换任',
716
+ },
717
+ ];
718
+ const registerAllow = (service) => {
719
+ if (!service || typeof service.register !== 'function') return;
720
+ for (const cmd of allowCommands) {
721
+ const d = service.register({
722
+ name: cmd.name,
723
+ description: cmd.description,
724
+ input: { hint: cmd.hint },
725
+ handler: ({ agent, rawInput }) => {
726
+ const arg = String(rawInput || '').trim();
727
+ const sid = arg ? resolveShortSid(env, arg) : (agent && agent.id ? String(agent.id) : '');
728
+ if (!sid) return { kind: 'error', text: '拿不到会话 id:请写成 /' + cmd.name + ' session-xxxx' };
729
+ const g = grantConsent(sid, cmd.note, cmd.kind);
730
+ try { env.log(cmd.action + '授权(命令):' + sid); } catch { /* ignore */ }
731
+ return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内' + cmd.action + '一次(用掉即失效)。' };
732
+ },
733
+ });
734
+ if (typeof d === 'function' && typeof ctx.effect === 'function') {
735
+ ctx.effect(() => d, 'dsh-handover: /' + cmd.name);
736
+ }
618
737
  }
619
738
  };
620
739
  // 可选服务:能用 ctx.inject 就走注入(老宿主没有 commands 时插件照常激活),否则退回直取。
621
740
  if (typeof ctx.inject === 'function') {
622
741
  ctx.inject(['commands'], (scope) => {
623
- try { registerAllowCompact(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
742
+ try { registerAllow(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
624
743
  });
625
744
  } else {
626
- registerAllowCompact(ctx.commands);
745
+ registerAllow(ctx.commands);
627
746
  }
628
747
  } catch (e) {
629
748
  initErrors.push('命令注册:' + msgOf(e));