@mingmaple/dsh-handover 0.0.0-stage → 0.9.6

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +48 -0
  5. package/package.json +55 -3
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +820 -0
  8. package/scripts/build.mjs +58 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +887 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +631 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +234 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +36 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +99 -0
  28. package/src/lib/handover-io.mjs +49 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +329 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +34 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/registry.mjs +53 -0
  39. package/src/lib/retarget-helper.tmpl +46 -0
  40. package/src/lib/role.mjs +217 -0
  41. package/src/lib/rpc-carrier.mjs +65 -0
  42. package/src/lib/scaffold.mjs +118 -0
  43. package/src/lib/schedule.mjs +227 -0
  44. package/src/lib/session-ops.mjs +362 -0
  45. package/src/lib/sessions.mjs +142 -0
  46. package/src/lib/sevenzip.mjs +48 -0
  47. package/src/lib/status.mjs +171 -0
  48. package/src/lib/text.mjs +74 -0
  49. package/src/lib/webroute.mjs +130 -0
  50. package/src/lib/workspace.mjs +23 -0
  51. package/src/lib/zstd-log.mjs +65 -0
  52. package/src/selftest.mjs +84 -0
  53. package/src/templates/Changelog.skeleton.md +10 -0
  54. package/src/templates/Charter.skeleton.md +53 -0
  55. package/src/templates/Handover.global.skeleton.md +56 -0
  56. package/src/templates/Prompt_Templates.md +96 -0
  57. package/src/templates/Runtime_Manual.md +166 -0
  58. package/src/templates/rules/Handover_Rules.md +43 -0
  59. package/src/templates/rules/Migration_Rules.md +32 -0
  60. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  61. package/src/templates/rules/Writing_Rules.md +24 -0
  62. package/src/tools/ho_brief.mjs +57 -0
  63. package/src/tools/ho_changelog.mjs +59 -0
  64. package/src/tools/ho_charter.mjs +89 -0
  65. package/src/tools/ho_checkpoint.mjs +125 -0
  66. package/src/tools/ho_diag.mjs +85 -0
  67. package/src/tools/ho_drift.mjs +41 -0
  68. package/src/tools/ho_gate.mjs +69 -0
  69. package/src/tools/ho_handover.mjs +83 -0
  70. package/src/tools/ho_init.mjs +82 -0
  71. package/src/tools/ho_pack.mjs +38 -0
  72. package/src/tools/ho_phase.mjs +37 -0
  73. package/src/tools/ho_profile.mjs +97 -0
  74. package/src/tools/ho_sessions.mjs +114 -0
  75. package/src/tools/ho_snapshot.mjs +103 -0
  76. package/src/tools/ho_tokens.mjs +57 -0
  77. package/src/tools/ho_workspace.mjs +213 -0
  78. package/src/tools/peer_list.mjs +37 -0
  79. package/src/tools/peer_new.mjs +75 -0
  80. package/src/tools/peer_peek.mjs +41 -0
  81. package/src/tools/peer_rename.mjs +31 -0
  82. package/src/tools/peer_send.mjs +52 -0
  83. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,16 @@
1
+ # dsh-handover 默认装配(作为 bundle 安装时生效;手写 profile patch 时把下面 insert 行抄过去)
2
+ #
3
+ # 刻意不写死 workspaceRoot 与 libraries:空白 DSH 里装上就能跑。
4
+ # 工作区由插件自己找:config.workspaceRoot → 标题含「全局」且带 _Control_Center 的工作区
5
+ # → 任一带 _Control_Center 的工作区 → 会话 cwd 向上找 _Control_Center。
6
+ # 本机若要固定路径/库名,在 profile 自己的 insert 里写 config 覆盖(会与这里合并)。
7
+ - insert:
8
+ - id: dsh-handover
9
+ name: '@mingmaple/dsh-handover'
10
+ config:
11
+ warnRatio: 0.32
12
+ handoverRatio: 0.35
13
+ hardRatio: 0.85
14
+ maxCompactions: 3
15
+ autoResume: true
16
+ sessionScanHours: 12
package/src/index.mjs ADDED
@@ -0,0 +1,631 @@
1
+ /**
2
+ * dsh-handover —— 全局运行期插件。
3
+ * 判据进代码、流程进工具;规则正文留磁盘,需要时用 ho_charter 取节(完整流程 doc="runtime")。
4
+ * 按会话角色载入工具与提示段:全局 22 个工具、项目 9、杂项 9、普通 0。
5
+ * 源码不热加载:改完要重启 DSH。
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { isDir, readJsonDir, pick } from './lib/text.mjs';
10
+ import { resolveConfig } from './lib/config.mjs';
11
+ import { projCacheDir, pressurePct } from './lib/sessions.mjs';
12
+ import { createScheduler } from './lib/schedule.mjs';
13
+ import { compactionStateOf, stalePressureNote } from './lib/events.mjs';
14
+ import { pmBriefTool } from './tools/ho_brief.mjs';
15
+ import { pmGateTool } from './tools/ho_gate.mjs';
16
+ import { pmSessionsTool } from './tools/ho_sessions.mjs';
17
+ import { pmPhaseTool } from './tools/ho_phase.mjs';
18
+ import { pmCharterTool } from './tools/ho_charter.mjs';
19
+ import { pmHandoverTool } from './tools/ho_handover.mjs';
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
+ import { pmChangelogTool } from './tools/ho_changelog.mjs';
25
+ import { pmTokensTool } from './tools/ho_tokens.mjs';
26
+ import { pmPackTool } from './tools/ho_pack.mjs';
27
+ import { pmWorkspaceTool } from './tools/ho_workspace.mjs';
28
+ import { pmInitTool } from './tools/ho_init.mjs';
29
+ import { pmProfileTool } from './tools/ho_profile.mjs';
30
+ import { pmPeerListTool } from './tools/peer_list.mjs';
31
+ import { pmPeerStatusTool } from './tools/peer_status.mjs';
32
+ import { pmPeerSendTool } from './tools/peer_send.mjs';
33
+ import { pmPeerNewTool } from './tools/peer_new.mjs';
34
+ import { pmPeerRenameTool } from './tools/peer_rename.mjs';
35
+ import { pmPeerPeekTool } from './tools/peer_peek.mjs';
36
+ import { resolveRegistry } from './lib/registry.mjs';
37
+ import { registerStatusRoute } from './lib/webroute.mjs';
38
+ import { createApi, statusSnapshot } from './lib/api.mjs';
39
+ import { registerRpcCarrier } from './lib/rpc-carrier.mjs';
40
+ import { NAMES } from './lib/names.mjs';
41
+ import { readExtraPrompt, writeExtraPrompt, EXTRA_PROMPT_ORDER, EXTRA_PROMPT_SECTION } from './lib/extra-prompt.mjs';
42
+ import {
43
+ ROLES, resolveRole, sessionMeta, handoverPathFor, toolsetFor,
44
+ readRoles, writeRole, bootstrappedAt,
45
+ } from './lib/role.mjs';
46
+ import { manualFor, organizeMessageFor } from './lib/manual.mjs';
47
+ import { routeWorkspace } from './lib/workspace.mjs';
48
+ import { stampTz } from './lib/brief.mjs';
49
+ import { grantConsent } from './lib/consent.mjs';
50
+
51
+ export const name = 'dsh-handover';
52
+ // 必需服务(名字已核对:dsh-agent→agents、dsh-compaction→compaction、dsh-api-session-controller→sessionController)。
53
+ // 只 inject 一定存在的服务:多写一个不存在的名字,cordis 会让整个插件静默不激活。
54
+ // agents / compaction 用运行时解析(ctx[name] → ctx.get(name)),无法获取由 ho_diag 报出来、ho_checkpoint 明确报错。
55
+ export const inject = ['tools', 'systemPrompt', 'sessionController'];
56
+
57
+ /** 自身包信息:dist 里读 ./package.json,源码树里退回 ../package.json。 */
58
+ function pkgInfo() {
59
+ for (const rel of ['./package.json', '../package.json']) {
60
+ try {
61
+ const j = JSON.parse(fs.readFileSync(new URL(rel, import.meta.url), 'utf8'));
62
+ if (j && j.version) return { name: String(j.name || ''), version: String(j.version) };
63
+ } catch { /* 试下一个 */ }
64
+ }
65
+ return { name: '@mingmaple/dsh-handover', version: '?' };
66
+ }
67
+
68
+ function walkUpToWorkspace(start) {
69
+ let cur = start;
70
+ for (let i = 0; i < 8 && cur; i++) {
71
+ if (isDir(path.join(cur, '_Control_Center'))) return cur;
72
+ const up = path.dirname(cur);
73
+ if (up === cur) break;
74
+ cur = up;
75
+ }
76
+ // cwd 的父目录已删(或 cwd 是盘符根)时,向上找不到工作区。
77
+ // 若把 start 原样返回,写操作就会在那个已失效的路径上重建一套 _Control_Center(幽灵目录)。
78
+ // 此时以登记表里的工作区为准;登记也找不到才退回 start。
79
+ try {
80
+ const fromReg = routeWorkspace({ cfg: {} });
81
+ if (fromReg && fromReg !== start && isDir(path.join(fromReg, '_Control_Center'))) return fromReg;
82
+ } catch { /* 登记读不到就保持旧行为 */ }
83
+ return start;
84
+ }
85
+
86
+ function msgOf(e) {
87
+ return String(e && e.message ? e.message : e);
88
+ }
89
+
90
+ /**
91
+ * 压缩调度器用的两个依赖:入参是**会话 id**,必须按 id 解析角色与交接路径。
92
+ * 单独导出,便于冒烟测试直接验证。
93
+ */
94
+ export function schedDepsFor(env) {
95
+ return {
96
+ roleOf: (sid) => env.infoOfSid(sid),
97
+ handoverFor: (sid) => env.handoverForSid(sid),
98
+ };
99
+ }
100
+
101
+ export function apply(ctx, config) {
102
+ const cfg = resolveConfig(config);
103
+ const initErrors = [];
104
+ const registered = new Set();
105
+ const scoped = new Map(); // sessionId → dispose(撤销该会话的工具限制与提示段)
106
+ const roleState = { gating: '', last: null, bootstrap: '' };
107
+ let scheduler = null;
108
+ let schedulerError = "";
109
+ let guardLast = null;
110
+
111
+ const env = {
112
+ cfg,
113
+ cacheDir: () => projCacheDir(),
114
+ controller: () => {
115
+ try { if (ctx.sessionController) return ctx.sessionController; } catch { /* ignore */ }
116
+ try { return typeof ctx.get === 'function' ? ctx.get('sessionController') : null; } catch { return null; }
117
+ },
118
+ resolveWs(exec) {
119
+ if (cfg.workspaceRoot) return cfg.workspaceRoot;
120
+ const cwd = exec && exec.agent && exec.agent.session && exec.agent.session.header ? exec.agent.session.header.cwd : null;
121
+ return walkUpToWorkspace(cwd || process.cwd());
122
+ },
123
+ selfId(exec) {
124
+ const h = exec && exec.agent && exec.agent.session ? exec.agent.session.header : null;
125
+ return h && h.id ? h.id : null;
126
+ },
127
+ selfCwd(exec) {
128
+ const h = exec && exec.agent && exec.agent.session ? exec.agent.session.header : null;
129
+ return h && h.cwd ? h.cwd : null;
130
+ },
131
+ selfPct(id) {
132
+ if (!id) return null;
133
+ const target = id.indexOf('session-') === 0 ? id : 'session-' + id;
134
+ for (const f of readJsonDir(this.cacheDir())) {
135
+ const stem = f.name.replace('.json', '');
136
+ if (stem === target || stem === id) return pressurePct(f.data);
137
+ }
138
+ return null;
139
+ },
140
+ schedule(sessionId, n, opts) {
141
+ if (!scheduler) throw new Error("压缩调度器未就绪" + (schedulerError ? ":" + schedulerError : "(服务尚未注入)"));
142
+ // consent 已由入口工具校验并消费;此处不再复查,复查必然落空。
143
+ scheduler.request(sessionId, Object.assign({ n, consent: 'checked' }, opts || {})).catch(() => {});
144
+ },
145
+ registry() { return resolveRegistry(ctx); },
146
+ /** 迁移感知:根目录是否已搬到别处(旧路径变 junction)。给提示段与 ho_diag 用。 */
147
+ moveInfo() {
148
+ try {
149
+ const src = cfg.workspaceRoot || walkUpToWorkspace(process.cwd());
150
+ const st = moveStatus({ src });
151
+ return st && st.phase && st.phase !== 'none' ? st : null;
152
+ } catch { return null; }
153
+ },
154
+ guardLast() { return guardLast; },
155
+ injectList: () => inject.slice(),
156
+ initErrors: () => initErrors.slice(),
157
+ probeServices() {
158
+ const out = {};
159
+ for (const name of ['tools', 'systemPrompt', 'sessionController', 'agents', 'compaction', 'tokenMeter', 'llm', 'sessions', 'commands', 'workspaceRegistry']) {
160
+ let v = null;
161
+ try { v = ctx[name]; } catch { /* ignore */ }
162
+ if (!v) { try { v = typeof ctx.get === 'function' ? ctx.get(name) : null; } catch { /* ignore */ } }
163
+ out[name] = !!v;
164
+ }
165
+ return out;
166
+ },
167
+ probeAgentScope(exec) {
168
+ const out = { ctx: false, compaction: false, compactNow: false, tokenMeter: false, sessions: false, llm: false, agents: false };
169
+ let c = null;
170
+ try { c = exec && exec.agent ? exec.agent.ctx : null; } catch { /* ignore */ }
171
+ if (!c) return out;
172
+ out.ctx = true;
173
+ for (const name of ['compaction', 'tokenMeter', 'sessions', 'llm', 'agents']) {
174
+ let v = null;
175
+ try { v = c[name]; } catch { /* ignore */ }
176
+ if (!v) { try { v = typeof c.get === 'function' ? c.get(name) : null; } catch { /* ignore */ } }
177
+ out[name] = !!v;
178
+ if (name === 'compaction' && v && typeof v.compactNow === 'function') out.compactNow = true;
179
+ }
180
+ return out;
181
+ },
182
+ compactionCount(exec) {
183
+ const st = compactionStateOf(this.selfId(exec), exec);
184
+ return { used: st.used, note: '' };
185
+ },
186
+ lockState(exec) {
187
+ return compactionStateOf(this.selfId(exec), exec).lock;
188
+ },
189
+ compactionState(exec) {
190
+ return compactionStateOf(exec ? this.selfId(exec) : null, exec);
191
+ },
192
+ staleNote(exec) {
193
+ try { return stalePressureNote(this.selfId(exec)); } catch { return ''; }
194
+ },
195
+ log(m) {
196
+ try { if (ctx.logger && typeof ctx.logger.warn === 'function') ctx.logger.warn(String(m)); } catch { /* ignore */ }
197
+ },
198
+ };
199
+
200
+ // ---------------- 会话标题:运行时 header.title 常常是空的,退回投影缓存
201
+ function titleOf(sessionId) {
202
+ try {
203
+ const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sessionId) : null;
204
+ const t = a && a.session && a.session.header ? String(a.session.header.title || '') : '';
205
+ if (t) return t;
206
+ } catch { /* ignore */ }
207
+ try {
208
+ const target = String(sessionId || '');
209
+ for (const f of readJsonDir(projCacheDir())) {
210
+ const stem = f.name.replace('.json', '');
211
+ if (stem !== target && stem !== (target.indexOf('session-') === 0 ? target : 'session-' + target)) continue;
212
+ const row = pick(f.data, 'record', 'rows', 'title', 'val');
213
+ if (typeof row === 'string') return row;
214
+ }
215
+ } catch { /* ignore */ }
216
+ return '';
217
+ }
218
+
219
+ function agentOf(sid) {
220
+ try {
221
+ const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sid) : null;
222
+ return a || null;
223
+ } catch { return null; }
224
+ }
225
+
226
+ // ---------------- 角色判定
227
+ function infoFor(sid, opts) {
228
+ const o = opts || {};
229
+ const meta = sessionMeta(sid);
230
+ return resolveRole({
231
+ cfg,
232
+ sessionId: sid,
233
+ title: o.title !== undefined ? o.title : titleOf(sid),
234
+ cwd: o.cwd || meta.cwd,
235
+ firstPrompt: meta.firstPrompt,
236
+ subagent: meta.subagent,
237
+ });
238
+ }
239
+
240
+ env.roleOf = (exec) => infoFor(env.selfId(exec) || '', { cwd: env.selfCwd(exec) || '' });
241
+ env.toolsetOf = (role) => toolsetFor(role);
242
+ env.roleState = () => ({ gating: roleState.gating, last: roleState.last, bootstrap: roleState.bootstrap });
243
+ env.rolesSnapshot = () => readRoles(cfg).sessions;
244
+ env.writeRole = (sid, patch) => writeRole(cfg, sid, patch);
245
+
246
+ function handoverInfoFor(info, ws) {
247
+ if (cfg.handoverPath && info.role === 'global') {
248
+ return { path: cfg.handoverPath, kind: 'global', scope: '_Global', role: info.role, project: info.project, task: info.task, ws };
249
+ }
250
+ const t = handoverPathFor(ws, info);
251
+ return Object.assign({ ws, role: info.role, project: info.project, task: info.task }, t);
252
+ }
253
+
254
+ /** 只按会话 id 推工作区根:优先活体 cwd,其次会话元数据,最后配置。 */
255
+ function workspaceForSid(sid) {
256
+ if (cfg.workspaceRoot) return String(cfg.workspaceRoot);
257
+ const a = agentOf(sid);
258
+ const cwd = a && a.session && a.session.header ? String(a.session.header.cwd || '') : (sessionMeta(sid).cwd || '');
259
+ return walkUpToWorkspace(cwd || process.cwd());
260
+ }
261
+
262
+ env.handoverFor = (exec) => handoverInfoFor(env.roleOf(exec), env.resolveWs(exec));
263
+ /** 跨会话用:凭 id 算它自己的角色与交接路径(不借调用者的上下文)。 */
264
+ env.handoverForSid = (sid) => {
265
+ const id = String(sid || '');
266
+ return handoverInfoFor(infoFor(id, {}), workspaceForSid(id));
267
+ };
268
+ env.infoOfSid = (sid) => infoFor(String(sid || ''), {});
269
+ env.agentOfSid = (sid) => agentOf(String(sid || ''));
270
+ env.compactionStateOfSid = (sid) => compactionStateOf(String(sid || ''), null);
271
+
272
+ // ---------------- 按角色装配(工具限制 + 提示段 + 一次性引导)
273
+ function workspaceForAgent(agent) {
274
+ if (cfg.workspaceRoot) return cfg.workspaceRoot;
275
+ const cwd = agent && agent.session && agent.session.header ? String(agent.session.header.cwd || '') : '';
276
+ return walkUpToWorkspace(cwd || process.cwd());
277
+ }
278
+
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) 提示段:普通会话不注册;其余角色注册自己的段落。
307
+ try {
308
+ 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
+ }
314
+ } catch (e) {
315
+ initErrors.push('角色提示段:' + msgOf(e));
316
+ }
317
+
318
+ if (disposers.length) {
319
+ scoped.set(sid, () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } });
320
+ }
321
+ roleState.last = { sid, role: info.role, at: stampTz(Date.now()) };
322
+
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
+ }
329
+ }
330
+
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);
340
+ }
341
+
342
+ /** 设定角色:写角色表 → 存活会话当场换工具与提示 → 可选投一次性整理指令(唤醒它一轮)。 */
343
+ async function applyRole(input) {
344
+ const i = input || {};
345
+ const sid = String(i.sessionId || '').trim();
346
+ const role = String(i.role || '').trim();
347
+ const project = String(i.project || '').trim();
348
+ const task = String(i.task || '').trim();
349
+ if (!sid) return { ok: false, error: '缺会话 id' };
350
+ if (ROLES.indexOf(role) === -1) return { ok: false, error: '角色只能是 global、project、misc、plain' };
351
+ if (role === 'project' && !project) return { ok: false, error: '项目角色要填项目名' };
352
+ if (role === 'misc' && !task) return { ok: false, error: '杂项角色要填任务名' };
353
+ try { writeRole(cfg, sid, { role, project, task, source: 'user' }); }
354
+ catch (e) { return { ok: false, error: '写角色表失败:' + msgOf(e) }; }
355
+ const info = { role, project, task, source: 'store', sessionId: sid };
356
+ let live = false;
357
+ try {
358
+ const a = agentOf(sid);
359
+ if (a) { live = true; applyScoped(a, info); }
360
+ } catch { /* 活体装配失败不影响写盘 */ }
361
+ let delivered = false;
362
+ let error = '';
363
+ const msg = organizeMessageFor(info, { ws: routeWorkspace(env) });
364
+ if (i.wake !== false && msg) {
365
+ try {
366
+ const r = await promptSession(sid, msg);
367
+ delivered = !!(r && r.accepted);
368
+ } catch (e) {
369
+ error = msgOf(e);
370
+ }
371
+ }
372
+ return { ok: true, role, project, task, live, delivered, error };
373
+ }
374
+ env.applyRole = applyRole;
375
+
376
+ // ---------------- 调度器(续跑文案要按角色取交接路径)
377
+ // 注意:调度器拿到的是会话 id 字符串,不是 exec;这里用按 id 解析的版本,直接读角色表与磁盘。
378
+ const schedDeps = { ctx, cfg, logger: (ctx.logger || null), roleOf: null, handoverFor: null };
379
+ try {
380
+ scheduler = createScheduler(schedDeps);
381
+ if (typeof ctx.effect === "function") ctx.effect(() => () => { try { scheduler.dispose(); } catch { /* ignore */ } });
382
+ } catch (e) { schedulerError = msgOf(e); }
383
+ Object.assign(schedDeps, schedDepsFor(env));
384
+
385
+ // ---------------- 用户全局前置提示词(段序 950=工具段之前)
386
+ // 与插件自身的会话约束无关:仅当用户开启且内容非空时注册;关闭或留空时完全不占用注入点位。
387
+ let extraDispose = null;
388
+ function applyExtraPrompt(state) {
389
+ try { if (typeof extraDispose === 'function') extraDispose(); } catch { /* 旧段已失效 */ }
390
+ extraDispose = null;
391
+ const enabled = !!(state && state.enabled === true);
392
+ const body = String(state && state.text ? state.text : '').trim();
393
+ if (!enabled || !body) return { registered: false, chars: body.length, enabled: false };
394
+ if (!ctx.systemPrompt || typeof ctx.systemPrompt.section !== 'function') return { registered: false, chars: body.length, enabled: true, error: 'systemPrompt.section 不可用' };
395
+ try {
396
+ extraDispose = ctx.systemPrompt.section({
397
+ name: EXTRA_PROMPT_SECTION,
398
+ order: EXTRA_PROMPT_ORDER,
399
+ text: body,
400
+ interpolate: false,
401
+ });
402
+ return { registered: true, chars: body.length, enabled: true };
403
+ } catch (e) {
404
+ return { registered: false, chars: body.length, enabled: true, error: msgOf(e) };
405
+ }
406
+ }
407
+ env.extraPrompt = () => { try { return readExtraPrompt().text; } catch { return ''; } };
408
+ env.extraPromptEnabled = () => { try { return readExtraPrompt().enabled; } catch { return false; } };
409
+ env.setExtraPrompt = (text, enabled) => {
410
+ const saved = writeExtraPrompt(text, enabled);
411
+ const r = applyExtraPrompt(saved);
412
+ return { ok: true, chars: saved.text.length, enabled: saved.enabled, registered: r.registered, error: r.error || '' };
413
+ };
414
+ try { applyExtraPrompt(readExtraPrompt()); } catch (e) { initErrors.push('全局前置提示词:' + msgOf(e)); }
415
+
416
+ // ---------------- 工具注册(16 个 ho_* + 6 个 peer_*)
417
+ const tools = [
418
+ pmBriefTool(env),
419
+ pmGateTool(env),
420
+ pmSessionsTool(env),
421
+ pmPhaseTool(env),
422
+ pmCharterTool(env),
423
+ pmHandoverTool(env),
424
+ pmCheckpointTool(env),
425
+ pmSnapshotTool(env),
426
+ pmDiagTool(env, { schedulerReady: () => scheduler !== null, schedulerError: () => schedulerError }),
427
+ pmDriftTool(env),
428
+ pmChangelogTool(env),
429
+ pmTokensTool(env),
430
+ pmPackTool(env),
431
+ pmWorkspaceTool(env),
432
+ pmInitTool(env),
433
+ pmProfileTool(env),
434
+ ];
435
+ const keys = ['brief', 'gate', 'sessions', 'phase', 'charter', 'handover', 'checkpoint', 'snapshot', 'diag', 'drift', 'changelog', 'tokens', 'pack', 'workspace', 'init', 'profile'];
436
+ for (let i = 0; i < tools.length; i++) {
437
+ const key = keys[i];
438
+ try {
439
+ // 名字以工厂自带的为准;工厂没写才用位置表补,且顺序错位时报出来(不再静默错名)。
440
+ if (!tools[i].name) tools[i].name = NAMES[key];
441
+ else if (tools[i].name !== NAMES[key]) initErrors.push('工具名错位:数组第 ' + i + ' 项是 ' + tools[i].name + ',位置表期望 ' + NAMES[key]);
442
+ ctx.tools.register(tools[i]);
443
+ registered.add(tools[i].name);
444
+ } catch (e) {
445
+ initErrors.push(key + ':' + msgOf(e));
446
+ }
447
+ }
448
+
449
+ const peerTools = [
450
+ pmPeerListTool(env),
451
+ pmPeerStatusTool(env),
452
+ pmPeerSendTool(env),
453
+ pmPeerNewTool(env),
454
+ pmPeerRenameTool(env),
455
+ pmPeerPeekTool(env),
456
+ ];
457
+ for (const t of peerTools) {
458
+ try {
459
+ ctx.tools.register(t);
460
+ if (t && t.name) registered.add(t.name);
461
+ } catch (e) {
462
+ initErrors.push((t && t.name ? String(t.name) : 'peer_?') + ':' + msgOf(e));
463
+ }
464
+ }
465
+
466
+ // ---------------- 唤醒权(Charter §7.2.4-9.1):按角色判定,只有全局可以唤醒别的会话
467
+ if (cfg.wakeAuthority !== 'off' && typeof ctx.tools.guard === 'function') {
468
+ try {
469
+ ctx.tools.guard((exec) => {
470
+ const nm = exec && exec.name;
471
+ if (nm !== 'peer_send' && nm !== 'peer_new') return undefined;
472
+ const sid = env.selfId(exec) || '';
473
+ const args = (exec && exec.arguments) || {};
474
+ let info;
475
+ try { info = env.roleOf(exec); } catch { info = { role: 'plain', source: 'error' }; }
476
+ const mk = (allow, why) => {
477
+ guardLast = { sid, allow, source: why, title: titleOf(sid) };
478
+ return allow ? undefined : '唤醒权只在全局:非全局会话只能 peer_send 给现任全局报告,不能唤醒其他项目会话(杂项自建 Misc 换任除外)。请把结论发回现任全局。';
479
+ };
480
+ if (info.role === 'global') return mk(true, 'global/' + info.source);
481
+ if (nm === 'peer_send') {
482
+ // 项目、杂项会话可以向「全局」报告;不许唤醒别的项目会话。
483
+ const target = String(args.session_id || '');
484
+ let t = null;
485
+ try { t = target ? infoFor(target, {}) : null; } catch { t = null; }
486
+ if (t && t.role === 'global') return mk(true, 'report-to-global/' + (t.source || ''));
487
+ return mk(false, info.source + '/' + info.role + ' → ' + (t ? t.role : 'unknown'));
488
+ }
489
+ // peer_new:只有杂项可以自建 Misc 换任
490
+ const target = String(args.workspace || args.cwd || '').toLowerCase();
491
+ if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, 'misc/' + info.source);
492
+ return mk(false, info.source + '/' + info.role);
493
+ });
494
+ } catch { /* 老版本没有 guard:退化为规则约束 */ }
495
+ }
496
+
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
+ }
506
+ function scopedMetaOf(h, sid) {
507
+ return { title: (h && h.title) || titleOf(sid), cwd: (h && h.cwd) || '' };
508
+ }
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
+ if (typeof ctx.on === 'function') {
527
+ try {
528
+ ctx.on('agent/created', (payload) => {
529
+ try {
530
+ const agent = payload && payload.agent ? payload.agent : null;
531
+ if (!agent) return;
532
+ const h = agent.session && agent.session.header ? agent.session.header : null;
533
+ const sid = String((h && h.id) || agent.id || '');
534
+ if (!sid) return;
535
+ 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 */ }
548
+ });
549
+ } catch (e) {
550
+ initErrors.push('agent/created 监听:' + msgOf(e));
551
+ }
552
+ }
553
+
554
+ // ---------------- 设置页数据接口:主通道=DSH 认证的 /api 载体;旧通道=/dsh-handover HTTP 路由
555
+ try {
556
+ const info = pkgInfo();
557
+ const pageInfo = {
558
+ name: info.name,
559
+ version: info.version,
560
+ tools: tools.length + peerTools.length,
561
+ scheduler: scheduler ? '就绪' : '未就绪' + (schedulerError ? ':' + schedulerError : ''),
562
+ };
563
+ const api = createApi(env, pageInfo);
564
+ env.api = api;
565
+ registerStatusRoute(ctx, env, pageInfo, api);
566
+ registerRpcCarrier(ctx, api, (m) => env.log(m));
567
+ // 预热状态快照:设置页直接取缓存,避免请求等待现算。
568
+ try {
569
+ // 只在「最近 5 分钟有人看过设置页」时预热:没人看就不算,避免空转占宿主主线程。
570
+ const warm = () => {
571
+ if (Date.now() - (env.lastStatusCallAt || 0) > 5 * 60 * 1000) return;
572
+ try { statusSnapshot(env, pageInfo, 'warm'); } catch { /* ignore */ }
573
+ };
574
+ const t0 = setTimeout(warm, 1500);
575
+ if (t0 && typeof t0.unref === 'function') t0.unref();
576
+ const iv = setInterval(warm, 30000);
577
+ if (iv && typeof iv.unref === 'function') iv.unref();
578
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => { clearInterval(iv); }, 'dsh-handover: 状态预热');
579
+ } catch { /* ignore */ }
580
+ } catch (e) {
581
+ initErrors.push('设置页路由:' + msgOf(e));
582
+ }
583
+
584
+ // ---------------- 聊天页命令:用户在自己的输入框敲 /allow-compact 即可授权压缩
585
+ // 为什么要有它:设置页要切页面、还要填会话 id,太绕。斜杠命令是「用户亲手敲的一行」,
586
+ // 天然满足「只有用户能产生同意」这条判据,且不产生模型消息、不花 token。
587
+ 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({
604
+ name: 'allow-compact',
605
+ 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');
618
+ }
619
+ };
620
+ // 可选服务:能用 ctx.inject 就走注入(老宿主没有 commands 时插件照常激活),否则退回直取。
621
+ if (typeof ctx.inject === 'function') {
622
+ ctx.inject(['commands'], (scope) => {
623
+ try { registerAllowCompact(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
624
+ });
625
+ } else {
626
+ registerAllowCompact(ctx.commands);
627
+ }
628
+ } catch (e) {
629
+ initErrors.push('命令注册:' + msgOf(e));
630
+ }
631
+ }