@mingmaple/dsh-handover 0.0.0-stage → 0.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/docs/migrate.md +61 -0
- package/docs/troubleshoot.md +77 -0
- package/package.json +70 -5
- package/scripts/blank-dsh.mjs +203 -0
- package/scripts/blank-smoke.mjs +981 -0
- package/scripts/build.mjs +61 -0
- package/scripts/check-docs.mjs +74 -0
- package/scripts/e2e-migration.mjs +127 -0
- package/scripts/lib/register.mjs +103 -0
- package/src/client.js +945 -0
- package/src/cordis.patch.yml +16 -0
- package/src/index.mjs +726 -0
- package/src/lib/absrefs.mjs +112 -0
- package/src/lib/api.mjs +291 -0
- package/src/lib/archive.mjs +198 -0
- package/src/lib/brief.mjs +74 -0
- package/src/lib/charter.mjs +28 -0
- package/src/lib/config.mjs +37 -0
- package/src/lib/consent.mjs +84 -0
- package/src/lib/drift.mjs +65 -0
- package/src/lib/events.mjs +89 -0
- package/src/lib/extra-prompt.mjs +41 -0
- package/src/lib/facts.mjs +70 -0
- package/src/lib/gates.mjs +148 -0
- package/src/lib/guide.mjs +103 -0
- package/src/lib/handover-io.mjs +69 -0
- package/src/lib/lint.mjs +152 -0
- package/src/lib/manual.mjs +124 -0
- package/src/lib/mover.mjs +663 -0
- package/src/lib/names.mjs +33 -0
- package/src/lib/output.mjs +11 -0
- package/src/lib/pack-ops.mjs +332 -0
- package/src/lib/peer.mjs +260 -0
- package/src/lib/phase.mjs +36 -0
- package/src/lib/profile.mjs +204 -0
- package/src/lib/prompt.mjs +18 -0
- package/src/lib/registry.mjs +53 -0
- package/src/lib/retarget-helper.tmpl +46 -0
- package/src/lib/role.mjs +251 -0
- package/src/lib/rpc-carrier.mjs +65 -0
- package/src/lib/scaffold.mjs +118 -0
- package/src/lib/schedule.mjs +223 -0
- package/src/lib/session-create.mjs +42 -0
- package/src/lib/session-ops.mjs +408 -0
- package/src/lib/sessions.mjs +143 -0
- package/src/lib/sevenzip.mjs +48 -0
- package/src/lib/status.mjs +177 -0
- package/src/lib/text.mjs +101 -0
- package/src/lib/webroute.mjs +130 -0
- package/src/lib/workspace.mjs +23 -0
- package/src/lib/zstd-log.mjs +65 -0
- package/src/selftest.mjs +84 -0
- package/src/templates/Changelog.skeleton.md +10 -0
- package/src/templates/Charter.skeleton.md +53 -0
- package/src/templates/Handover.global.skeleton.md +56 -0
- package/src/templates/Prompt_Templates.md +96 -0
- package/src/templates/Runtime_Manual.md +166 -0
- package/src/templates/rules/Handover_Rules.md +44 -0
- package/src/templates/rules/Migration_Rules.md +32 -0
- package/src/templates/rules/Misc_Task_Standard.md +23 -0
- package/src/templates/rules/Writing_Rules.md +24 -0
- package/src/tools/ho_brief.mjs +57 -0
- package/src/tools/ho_changelog.mjs +59 -0
- package/src/tools/ho_charter.mjs +89 -0
- package/src/tools/ho_checkpoint.mjs +132 -0
- package/src/tools/ho_diag.mjs +87 -0
- package/src/tools/ho_drift.mjs +41 -0
- package/src/tools/ho_gate.mjs +69 -0
- package/src/tools/ho_handover.mjs +84 -0
- package/src/tools/ho_init.mjs +82 -0
- package/src/tools/ho_pack.mjs +38 -0
- package/src/tools/ho_phase.mjs +37 -0
- package/src/tools/ho_profile.mjs +105 -0
- package/src/tools/ho_sessions.mjs +115 -0
- package/src/tools/ho_snapshot.mjs +104 -0
- package/src/tools/ho_tokens.mjs +57 -0
- package/src/tools/ho_workspace.mjs +217 -0
- package/src/tools/peer_list.mjs +37 -0
- package/src/tools/peer_new.mjs +75 -0
- package/src/tools/peer_peek.mjs +41 -0
- package/src/tools/peer_rename.mjs +31 -0
- package/src/tools/peer_send.mjs +52 -0
- package/src/tools/peer_status.mjs +46 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
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, assertProjectName, assertTaskName,
|
|
45
|
+
} from './lib/role.mjs';
|
|
46
|
+
import { manualFor, organizeMessageFor } from './lib/manual.mjs';
|
|
47
|
+
import { sendPrompt } from './lib/prompt.mjs';
|
|
48
|
+
import { resolveShortSid } from './lib/api.mjs';
|
|
49
|
+
import { routeWorkspace } from './lib/workspace.mjs';
|
|
50
|
+
import { stampTz } from './lib/brief.mjs';
|
|
51
|
+
import { grantConsent } from './lib/consent.mjs';
|
|
52
|
+
|
|
53
|
+
export const name = 'dsh-handover';
|
|
54
|
+
// 必需服务(名字已核对:dsh-agent→agents、dsh-compaction→compaction、dsh-api-session-controller→sessionController)。
|
|
55
|
+
// 只 inject 一定存在的服务:多写一个不存在的名字,cordis 会让整个插件静默不激活。
|
|
56
|
+
// agents / compaction 用运行时解析(ctx[name] → ctx.get(name)),无法获取由 ho_diag 报出来、ho_checkpoint 明确报错。
|
|
57
|
+
export const inject = ['tools', 'systemPrompt', 'sessionController'];
|
|
58
|
+
|
|
59
|
+
/** 自身包信息:dist 里读 ./package.json,源码树里退回 ../package.json。 */
|
|
60
|
+
function pkgInfo() {
|
|
61
|
+
for (const rel of ['./package.json', '../package.json']) {
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(fs.readFileSync(new URL(rel, import.meta.url), 'utf8'));
|
|
64
|
+
if (j && j.version) return { name: String(j.name || ''), version: String(j.version) };
|
|
65
|
+
} catch { /* 试下一个 */ }
|
|
66
|
+
}
|
|
67
|
+
return { name: '@mingmaple/dsh-handover', version: '?' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function walkUpToWorkspace(start) {
|
|
71
|
+
let cur = start;
|
|
72
|
+
for (let i = 0; i < 8 && cur; i++) {
|
|
73
|
+
if (isDir(path.join(cur, '_Control_Center'))) return cur;
|
|
74
|
+
const up = path.dirname(cur);
|
|
75
|
+
if (up === cur) break;
|
|
76
|
+
cur = up;
|
|
77
|
+
}
|
|
78
|
+
// cwd 的父目录已删(或 cwd 是盘符根)时,向上找不到工作区。
|
|
79
|
+
// 若把 start 原样返回,写操作就会在那个已失效的路径上重建一套 _Control_Center(幽灵目录)。
|
|
80
|
+
// 此时以登记表里的工作区为准;登记也找不到才退回 start。
|
|
81
|
+
try {
|
|
82
|
+
const fromReg = routeWorkspace({ cfg: {} });
|
|
83
|
+
if (fromReg && fromReg !== start && isDir(path.join(fromReg, '_Control_Center'))) return fromReg;
|
|
84
|
+
} catch { /* 登记读不到就保持旧行为 */ }
|
|
85
|
+
return start;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function msgOf(e) {
|
|
89
|
+
return String(e && e.message ? e.message : e);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 压缩调度器用的两个依赖:入参是**会话 id**,必须按 id 解析角色与交接路径。
|
|
94
|
+
* 单独导出,便于冒烟测试直接验证。
|
|
95
|
+
*/
|
|
96
|
+
export function schedDepsFor(env) {
|
|
97
|
+
return {
|
|
98
|
+
roleOf: (sid) => env.infoOfSid(sid),
|
|
99
|
+
handoverFor: (sid) => env.handoverForSid(sid),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function apply(ctx, config) {
|
|
104
|
+
const cfg = resolveConfig(config);
|
|
105
|
+
const initErrors = [];
|
|
106
|
+
const registered = new Set(); // 已注册的工具名(旧内核的限制式裁剪要用)
|
|
107
|
+
// 0.2.2 起工具按「步」解析(tools.resolve):可用工具集合每一步重算,角色过滤直接放进注册条件。
|
|
108
|
+
// 更早的内核忽略注册选项,只能退回 agent 作用域的 restrict;两条路径按能力二选一,不并行。
|
|
109
|
+
const perStep = (() => { try { return !!(ctx.tools && typeof ctx.tools.resolve === 'function'); } catch { return false; } })();
|
|
110
|
+
const sectionDispose = new Map(); // sessionId → 撤销提示段
|
|
111
|
+
const legacyRestrict = new Map(); // sessionId → 撤销限制(仅无按步解析的内核)
|
|
112
|
+
const appliedRole = new Map(); // sessionId → 上次装配的角色
|
|
113
|
+
const roleCache = new Map(); // sessionId → { at, info }(每步过滤用,短 TTL)
|
|
114
|
+
const roleState = { last: null };
|
|
115
|
+
let scheduler = null;
|
|
116
|
+
let schedulerError = "";
|
|
117
|
+
let guardLast = null;
|
|
118
|
+
|
|
119
|
+
const env = {
|
|
120
|
+
cfg,
|
|
121
|
+
cacheDir: () => projCacheDir(),
|
|
122
|
+
controller: () => {
|
|
123
|
+
try { if (ctx.sessionController) return ctx.sessionController; } catch { /* ignore */ }
|
|
124
|
+
try { return typeof ctx.get === 'function' ? ctx.get('sessionController') : null; } catch { return null; }
|
|
125
|
+
},
|
|
126
|
+
resolveWs(exec) {
|
|
127
|
+
if (cfg.workspaceRoot) return cfg.workspaceRoot;
|
|
128
|
+
const cwd = exec && exec.agent && exec.agent.session && exec.agent.session.header ? exec.agent.session.header.cwd : null;
|
|
129
|
+
return walkUpToWorkspace(cwd || process.cwd());
|
|
130
|
+
},
|
|
131
|
+
selfId(exec) {
|
|
132
|
+
const h = exec && exec.agent && exec.agent.session ? exec.agent.session.header : null;
|
|
133
|
+
return h && h.id ? h.id : null;
|
|
134
|
+
},
|
|
135
|
+
selfCwd(exec) {
|
|
136
|
+
const h = exec && exec.agent && exec.agent.session ? exec.agent.session.header : null;
|
|
137
|
+
return h && h.cwd ? h.cwd : null;
|
|
138
|
+
},
|
|
139
|
+
selfPct(id) {
|
|
140
|
+
if (!id) return null;
|
|
141
|
+
const target = id.indexOf('session-') === 0 ? id : 'session-' + id;
|
|
142
|
+
for (const f of readJsonDir(this.cacheDir())) {
|
|
143
|
+
const stem = f.name.replace('.json', '');
|
|
144
|
+
if (stem === target || stem === id) return pressurePct(f.data);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
},
|
|
148
|
+
schedule(sessionId, n, opts) {
|
|
149
|
+
if (!scheduler) throw new Error("压缩调度器未就绪" + (schedulerError ? ":" + schedulerError : "(服务尚未注入)"));
|
|
150
|
+
// consent 已由入口工具校验并消费;此处不再复查,复查必然落空。
|
|
151
|
+
scheduler.request(sessionId, Object.assign({ n, consent: 'checked' }, opts || {})).catch(() => {});
|
|
152
|
+
},
|
|
153
|
+
registry() { return resolveRegistry(ctx); },
|
|
154
|
+
/** 迁移感知:根目录是否已搬到别处(旧路径变 junction)。给提示段与 ho_diag 用。 */
|
|
155
|
+
moveInfo() {
|
|
156
|
+
try {
|
|
157
|
+
const src = cfg.workspaceRoot || walkUpToWorkspace(process.cwd());
|
|
158
|
+
const st = moveStatus({ src });
|
|
159
|
+
return st && st.phase && st.phase !== 'none' ? st : null;
|
|
160
|
+
} catch { return null; }
|
|
161
|
+
},
|
|
162
|
+
guardLast() { return guardLast; },
|
|
163
|
+
injectList: () => inject.slice(),
|
|
164
|
+
initErrors: () => initErrors.slice(),
|
|
165
|
+
probeServices() {
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const name of ['tools', 'systemPrompt', 'sessionController', 'agents', 'compaction', 'tokenMeter', 'llm', 'sessions', 'commands', 'workspaceRegistry']) {
|
|
168
|
+
let v = null;
|
|
169
|
+
try { v = ctx[name]; } catch { /* ignore */ }
|
|
170
|
+
if (!v) { try { v = typeof ctx.get === 'function' ? ctx.get(name) : null; } catch { /* ignore */ } }
|
|
171
|
+
out[name] = !!v;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
},
|
|
175
|
+
probeAgentScope(exec) {
|
|
176
|
+
const out = { ctx: false, compaction: false, compactNow: false, tokenMeter: false, sessions: false, llm: false, agents: false };
|
|
177
|
+
let c = null;
|
|
178
|
+
try { c = exec && exec.agent ? exec.agent.ctx : null; } catch { /* ignore */ }
|
|
179
|
+
if (!c) return out;
|
|
180
|
+
out.ctx = true;
|
|
181
|
+
for (const name of ['compaction', 'tokenMeter', 'sessions', 'llm', 'agents']) {
|
|
182
|
+
let v = null;
|
|
183
|
+
try { v = c[name]; } catch { /* ignore */ }
|
|
184
|
+
if (!v) { try { v = typeof c.get === 'function' ? c.get(name) : null; } catch { /* ignore */ } }
|
|
185
|
+
out[name] = !!v;
|
|
186
|
+
if (name === 'compaction' && v && typeof v.compactNow === 'function') out.compactNow = true;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
},
|
|
190
|
+
compactionCount(exec) {
|
|
191
|
+
const st = compactionStateOf(this.selfId(exec), exec);
|
|
192
|
+
return { used: st.used, note: '' };
|
|
193
|
+
},
|
|
194
|
+
lockState(exec) {
|
|
195
|
+
return compactionStateOf(this.selfId(exec), exec).lock;
|
|
196
|
+
},
|
|
197
|
+
compactionState(exec) {
|
|
198
|
+
return compactionStateOf(exec ? this.selfId(exec) : null, exec);
|
|
199
|
+
},
|
|
200
|
+
staleNote(exec) {
|
|
201
|
+
try { return stalePressureNote(this.selfId(exec)); } catch { return ''; }
|
|
202
|
+
},
|
|
203
|
+
/** 自身版本:从 dist/package.json 读,设置页与 ho_diag 共用同一值。 */
|
|
204
|
+
version() {
|
|
205
|
+
try { return pkgInfo().version; } catch { return '?'; }
|
|
206
|
+
},
|
|
207
|
+
/** 当前已装配提示段的会话数(诊断用)。 */
|
|
208
|
+
assembledCount() { return sectionDispose.size; },
|
|
209
|
+
log(m) {
|
|
210
|
+
try { if (ctx.logger && typeof ctx.logger.warn === 'function') ctx.logger.warn(String(m)); } catch { /* ignore */ }
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ---------------- 会话标题:运行时 header.title 常常是空的,退回投影缓存
|
|
215
|
+
function titleOf(sessionId) {
|
|
216
|
+
try {
|
|
217
|
+
const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sessionId) : null;
|
|
218
|
+
const t = a && a.session && a.session.header ? String(a.session.header.title || '') : '';
|
|
219
|
+
if (t) return t;
|
|
220
|
+
} catch { /* ignore */ }
|
|
221
|
+
try {
|
|
222
|
+
const target = String(sessionId || '');
|
|
223
|
+
for (const f of readJsonDir(projCacheDir())) {
|
|
224
|
+
const stem = f.name.replace('.json', '');
|
|
225
|
+
if (stem !== target && stem !== (target.indexOf('session-') === 0 ? target : 'session-' + target)) continue;
|
|
226
|
+
const row = pick(f.data, 'record', 'rows', 'title', 'val');
|
|
227
|
+
if (typeof row === 'string') return row;
|
|
228
|
+
}
|
|
229
|
+
} catch { /* ignore */ }
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function agentOf(sid) {
|
|
234
|
+
const want = String(sid || '');
|
|
235
|
+
try {
|
|
236
|
+
const a = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(want) : null;
|
|
237
|
+
if (a) return a;
|
|
238
|
+
} catch { /* 退回列表查找 */ }
|
|
239
|
+
// 宿主的注册表键不保证就是会话 id:取不到时按会话头的 id 在活体列表里再找一次。
|
|
240
|
+
// 少了这一层,休眠会话在设置页被改角色时会被判成「不在内存」,只能等重启才装配。
|
|
241
|
+
try {
|
|
242
|
+
const list = ctx.agents && typeof ctx.agents.list === 'function' ? (ctx.agents.list() || []) : [];
|
|
243
|
+
for (const a of list) {
|
|
244
|
+
const h = a && a.session ? a.session.header : null;
|
|
245
|
+
if (h && String(h.id || '') === want) return a;
|
|
246
|
+
}
|
|
247
|
+
} catch { /* ignore */ }
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 会话头就能判定的子级会话:子会话与团队成员的会话都带 origin=subagent 或 parentSession。
|
|
253
|
+
* 只读 header,不碰磁盘,可放在「每一步」的条件启用里。
|
|
254
|
+
*/
|
|
255
|
+
function isChildSession(agent) {
|
|
256
|
+
try {
|
|
257
|
+
const h = agent && agent.session ? agent.session.header : null;
|
|
258
|
+
if (!h) return false;
|
|
259
|
+
return h.origin === 'subagent' || !!h.parentSession;
|
|
260
|
+
} catch { return false; }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
// ---------------- 角色判定
|
|
265
|
+
function infoFor(sid, opts) {
|
|
266
|
+
const o = opts || {};
|
|
267
|
+
const meta = sessionMeta(sid);
|
|
268
|
+
return resolveRole({
|
|
269
|
+
cfg,
|
|
270
|
+
sessionId: sid,
|
|
271
|
+
title: o.title !== undefined ? o.title : titleOf(sid),
|
|
272
|
+
cwd: o.cwd || meta.cwd,
|
|
273
|
+
firstPrompt: meta.firstPrompt,
|
|
274
|
+
subagent: meta.subagent,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
env.roleOf = (exec) => infoFor(env.selfId(exec) || '', { cwd: env.selfCwd(exec) || '' });
|
|
279
|
+
env.toolsetOf = (role) => toolsetFor(role);
|
|
280
|
+
env.roleState = () => ({ mode: roleMode(), last: roleState.last, count: appliedRole.size });
|
|
281
|
+
env.rolesSnapshot = () => readRoles(cfg).sessions;
|
|
282
|
+
// 写角色表是一切角色变更的唯一入口:写完立刻让每步过滤的短缓存失效,下一步就用新角色。
|
|
283
|
+
env.writeRole = (sid, patch) => {
|
|
284
|
+
const saved = writeRole(cfg, sid, patch);
|
|
285
|
+
roleCache.delete(String(sid || ''));
|
|
286
|
+
return saved;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** 当前生效的裁剪机制,供 ho_diag 报出。 */
|
|
290
|
+
function roleMode() {
|
|
291
|
+
if (cfg.roleGating === 'off') return '关闭(roleGating=off,所有会话可见全部工具)';
|
|
292
|
+
return perStep
|
|
293
|
+
? '按步过滤(工具表每步重算,换角色下一轮即生效)'
|
|
294
|
+
: '旧式收窄(内核不支持按步解析,用 agent 作用域 restrict)';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function handoverInfoFor(info, ws) {
|
|
298
|
+
if (cfg.handoverPath && info.role === 'global') {
|
|
299
|
+
return { path: cfg.handoverPath, kind: 'global', scope: '_Global', role: info.role, project: info.project, task: info.task, ws };
|
|
300
|
+
}
|
|
301
|
+
const t = handoverPathFor(ws, info);
|
|
302
|
+
return Object.assign({ ws, role: info.role, project: info.project, task: info.task }, t);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 只按会话 id 推工作区根:优先活体 cwd,其次会话元数据,最后配置。 */
|
|
306
|
+
function workspaceForSid(sid) {
|
|
307
|
+
if (cfg.workspaceRoot) return String(cfg.workspaceRoot);
|
|
308
|
+
const a = agentOf(sid);
|
|
309
|
+
const cwd = a && a.session && a.session.header ? String(a.session.header.cwd || '') : (sessionMeta(sid).cwd || '');
|
|
310
|
+
return walkUpToWorkspace(cwd || process.cwd());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
env.handoverFor = (exec) => handoverInfoFor(env.roleOf(exec), env.resolveWs(exec));
|
|
314
|
+
/** 跨会话用:凭 id 算它自己的角色与交接路径(不借调用者的上下文)。 */
|
|
315
|
+
env.handoverForSid = (sid) => {
|
|
316
|
+
const id = String(sid || '');
|
|
317
|
+
return handoverInfoFor(infoFor(id, {}), workspaceForSid(id));
|
|
318
|
+
};
|
|
319
|
+
env.infoOfSid = (sid) => infoFor(String(sid || ''), {});
|
|
320
|
+
env.agentOfSid = (sid) => agentOf(String(sid || ''));
|
|
321
|
+
env.compactionStateOfSid = (sid) => compactionStateOf(String(sid || ''), null);
|
|
322
|
+
|
|
323
|
+
// ---------------- 按角色装配:工具可见性由每步条件即时算,这里只维护提示段(旧内核另加 restrict)
|
|
324
|
+
function workspaceForAgent(agent) {
|
|
325
|
+
if (cfg.workspaceRoot) return cfg.workspaceRoot;
|
|
326
|
+
const cwd = agent && agent.session && agent.session.header ? String(agent.session.header.cwd || '') : '';
|
|
327
|
+
return walkUpToWorkspace(cwd || process.cwd());
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 注册/替换本会话的角色提示段;普通会话不注册。 */
|
|
331
|
+
function applySection(agent, sid, info) {
|
|
332
|
+
const old = sectionDispose.get(sid);
|
|
333
|
+
if (typeof old === 'function') { try { old(); } catch { /* 旧段已失效 */ } }
|
|
334
|
+
sectionDispose.delete(sid);
|
|
335
|
+
let text = '';
|
|
336
|
+
try {
|
|
337
|
+
const ws = workspaceForAgent(agent);
|
|
338
|
+
text = manualFor(info, { ws, cfg, move: typeof env.moveInfo === 'function' ? env.moveInfo() : null });
|
|
339
|
+
} catch (e) {
|
|
340
|
+
initErrors.push('角色提示段:' + msgOf(e));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const sc = agent && agent.ctx ? agent.ctx : null;
|
|
344
|
+
if (!text || !sc || !sc.systemPrompt || typeof sc.systemPrompt.section !== 'function') return;
|
|
345
|
+
try {
|
|
346
|
+
sectionDispose.set(sid, sc.systemPrompt.section({ name: 'dsh-handover:manual', order: 100, text, interpolate: false }));
|
|
347
|
+
} catch (e) {
|
|
348
|
+
// 插件重载后,旧实例注册在 agent 作用域上的同名提示段仍在(宿主按 agent 持有,插件卸载不移除),
|
|
349
|
+
// 既无法移除也不能重复注册:视为已就位,沿用旧文本;换文本需重启 DSH 或让会话重建。
|
|
350
|
+
if (!/already registered/.test(msgOf(e))) initErrors.push('角色提示段:' + msgOf(e));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 旧内核(没有按步解析)才用的限制式裁剪。 */
|
|
355
|
+
function applyLegacyRestrict(agent, sid, info) {
|
|
356
|
+
const old = legacyRestrict.get(sid);
|
|
357
|
+
if (typeof old === 'function') { try { old(); } catch { /* 旧限制已失效 */ } }
|
|
358
|
+
legacyRestrict.delete(sid);
|
|
359
|
+
if (cfg.roleGating === 'off' || info.role === 'global') return;
|
|
360
|
+
try {
|
|
361
|
+
const sc = agent && agent.ctx ? agent.ctx : null;
|
|
362
|
+
if (!sc || !sc.tools || typeof sc.tools.restrict !== 'function') return;
|
|
363
|
+
const deny = toolsetFor(info.role).deny.filter((n) => registered.has(n));
|
|
364
|
+
if (deny.length) legacyRestrict.set(sid, sc.tools.restrict({ deny }));
|
|
365
|
+
} catch { /* 旧内核裁剪失败只影响可见工具,不影响其余功能 */ }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 让一个活体会话的提示段与本会话当前角色对齐。info 可传入已解析好的角色,省一次判定。 */
|
|
369
|
+
function applyRoleTo(agent, infoOpt) {
|
|
370
|
+
const h = agent && agent.session ? agent.session.header : null;
|
|
371
|
+
const sid = String((h && h.id) || (agent && agent.id) || '');
|
|
372
|
+
if (!sid) return;
|
|
373
|
+
let info = infoOpt || null;
|
|
374
|
+
if (!info) {
|
|
375
|
+
try { info = infoFor(sid, scopedMetaOf(h, sid)); } catch { return; }
|
|
376
|
+
}
|
|
377
|
+
applySection(agent, sid, info);
|
|
378
|
+
if (!perStep) applyLegacyRestrict(agent, sid, info);
|
|
379
|
+
appliedRole.set(sid, info.role);
|
|
380
|
+
roleState.last = { sid, role: info.role, at: stampTz(Date.now()) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 设定角色:写角色表 → 存活会话当场换提示段(工具在下一步按新角色重算)→ 可选投整理指令。 */
|
|
384
|
+
async function applyRole(input) {
|
|
385
|
+
const i = input || {};
|
|
386
|
+
const sid = String(i.sessionId || '').trim();
|
|
387
|
+
const role = String(i.role || '').trim();
|
|
388
|
+
const project = String(i.project || '').trim();
|
|
389
|
+
const task = String(i.task || '').trim();
|
|
390
|
+
if (!sid) return { ok: false, error: '缺会话 id' };
|
|
391
|
+
if (ROLES.indexOf(role) === -1) return { ok: false, error: '角色只能是 global、project、misc、plain' };
|
|
392
|
+
if (role === 'project' && !project) return { ok: false, error: '项目角色要填项目名' };
|
|
393
|
+
if (role === 'misc' && !task) return { ok: false, error: '杂项角色要填任务名' };
|
|
394
|
+
// 名字要落成目录:不合法就当场拒绝,别等写角色表或拼路径时才失败。
|
|
395
|
+
if (role === 'project') { try { assertProjectName(project); } catch (e) { return { ok: false, error: msgOf(e) }; } }
|
|
396
|
+
if (role === 'misc') { try { assertTaskName(task); } catch (e) { return { ok: false, error: msgOf(e) }; } }
|
|
397
|
+
try { writeRole(cfg, sid, { role, project, task, source: 'user' }); }
|
|
398
|
+
catch (e) { return { ok: false, error: '写角色表失败:' + msgOf(e) }; }
|
|
399
|
+
roleCache.delete(sid);
|
|
400
|
+
const info = { role, project, task, source: 'store', sessionId: sid };
|
|
401
|
+
let live = false;
|
|
402
|
+
try {
|
|
403
|
+
const a = agentOf(sid);
|
|
404
|
+
if (a) { live = true; applyRoleTo(a, info); }
|
|
405
|
+
} catch { /* 活体装配失败不影响写盘 */ }
|
|
406
|
+
let delivered = false;
|
|
407
|
+
let error = '';
|
|
408
|
+
const msg = organizeMessageFor(info, { ws: routeWorkspace(env) });
|
|
409
|
+
if (i.wake !== false && msg) {
|
|
410
|
+
try {
|
|
411
|
+
const r = await sendPrompt(env.controller(), sid, msg, 'queue');
|
|
412
|
+
delivered = !!(r && r.accepted);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
error = msgOf(e);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return { ok: true, role, project, task, live, delivered, error };
|
|
418
|
+
}
|
|
419
|
+
env.applyRole = applyRole;
|
|
420
|
+
|
|
421
|
+
/** 会话新建后立即按已登记角色装配(不唤醒、不投消息)。 */
|
|
422
|
+
env.applyScopedLive = (sid, info) => {
|
|
423
|
+
try {
|
|
424
|
+
const a = agentOf(String(sid || ''));
|
|
425
|
+
if (!a) return false;
|
|
426
|
+
const i = Object.assign({ role: 'plain', project: '', task: '', source: 'live' }, info || {});
|
|
427
|
+
if (!i.role) i.role = 'plain';
|
|
428
|
+
applyRoleTo(a, i);
|
|
429
|
+
return true;
|
|
430
|
+
} catch { return false; }
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// ---------------- 调度器(续跑文案要按角色取交接路径)
|
|
434
|
+
// 注意:调度器拿到的是会话 id 字符串,不是 exec;这里用按 id 解析的版本,直接读角色表与磁盘。
|
|
435
|
+
const schedDeps = { ctx, cfg, logger: (ctx.logger || null), roleOf: null, handoverFor: null };
|
|
436
|
+
try {
|
|
437
|
+
scheduler = createScheduler(schedDeps);
|
|
438
|
+
if (typeof ctx.effect === "function") ctx.effect(() => () => { try { scheduler.dispose(); } catch { /* ignore */ } });
|
|
439
|
+
} catch (e) { schedulerError = msgOf(e); }
|
|
440
|
+
Object.assign(schedDeps, schedDepsFor(env));
|
|
441
|
+
|
|
442
|
+
// ---------------- 用户全局前置提示词(段序 950=工具段之前)
|
|
443
|
+
// 与插件自身的会话约束无关:仅当用户开启且内容非空时注册;关闭或留空时完全不占用注入点位。
|
|
444
|
+
let extraDispose = null;
|
|
445
|
+
function applyExtraPrompt(state) {
|
|
446
|
+
try { if (typeof extraDispose === 'function') extraDispose(); } catch { /* 旧段已失效 */ }
|
|
447
|
+
extraDispose = null;
|
|
448
|
+
const enabled = !!(state && state.enabled === true);
|
|
449
|
+
const body = String(state && state.text ? state.text : '').trim();
|
|
450
|
+
if (!enabled || !body) return { registered: false, chars: body.length, enabled: false };
|
|
451
|
+
if (!ctx.systemPrompt || typeof ctx.systemPrompt.section !== 'function') return { registered: false, chars: body.length, enabled: true, error: 'systemPrompt.section 不可用' };
|
|
452
|
+
try {
|
|
453
|
+
extraDispose = ctx.systemPrompt.section({
|
|
454
|
+
name: EXTRA_PROMPT_SECTION,
|
|
455
|
+
order: EXTRA_PROMPT_ORDER,
|
|
456
|
+
text: body,
|
|
457
|
+
interpolate: false,
|
|
458
|
+
});
|
|
459
|
+
return { registered: true, chars: body.length, enabled: true };
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return { registered: false, chars: body.length, enabled: true, error: msgOf(e) };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
env.extraPrompt = () => { try { return readExtraPrompt().text; } catch { return ''; } };
|
|
465
|
+
env.extraPromptEnabled = () => { try { return readExtraPrompt().enabled; } catch { return false; } };
|
|
466
|
+
env.setExtraPrompt = (text, enabled) => {
|
|
467
|
+
const saved = writeExtraPrompt(text, enabled);
|
|
468
|
+
const r = applyExtraPrompt(saved);
|
|
469
|
+
return { ok: true, chars: saved.text.length, enabled: saved.enabled, registered: r.registered, error: r.error || '' };
|
|
470
|
+
};
|
|
471
|
+
try { applyExtraPrompt(readExtraPrompt()); } catch (e) { initErrors.push('全局前置提示词:' + msgOf(e)); }
|
|
472
|
+
|
|
473
|
+
// ---------------- 工具注册(16 个 ho_* + 6 个 peer_*)
|
|
474
|
+
// 0.2.2 起工具按「步」解析:可用工具集合每一步重算。子会话排除与角色过滤都在这里一次做完,
|
|
475
|
+
// 不再另用 agent 作用域 restrict —— 两套机制并行时,换角色要么等到重启、要么互相覆盖。
|
|
476
|
+
const roleToolsets = new Map(ROLES.map((r) => [r, new Set(toolsetFor(r).allow)]));
|
|
477
|
+
/** 每步过滤:子会话一律不可用;角色按当前角色表算;「无 agent」的路径(命令行、演练)放行。 */
|
|
478
|
+
function visibleTo(name) {
|
|
479
|
+
return (c) => {
|
|
480
|
+
const agent = c && c.agent ? c.agent : null;
|
|
481
|
+
if (isChildSession(agent)) return false;
|
|
482
|
+
if (!perStep || cfg.roleGating === 'off' || !agent) return true;
|
|
483
|
+
const h = agent.session && agent.session.header ? agent.session.header : null;
|
|
484
|
+
const sid = String((h && h.id) || agent.id || '');
|
|
485
|
+
if (!sid) return true;
|
|
486
|
+
let info = null;
|
|
487
|
+
const hit = roleCache.get(sid);
|
|
488
|
+
if (hit && Date.now() - hit.at < 2000) info = hit.info;
|
|
489
|
+
else {
|
|
490
|
+
try { info = infoFor(sid, scopedMetaOf(h, sid)); } catch { info = null; }
|
|
491
|
+
if (info) roleCache.set(sid, { at: Date.now(), info });
|
|
492
|
+
}
|
|
493
|
+
if (!info) return true;
|
|
494
|
+
const allow = roleToolsets.get(info.role);
|
|
495
|
+
return allow ? allow.has(name) : true;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const tools = [
|
|
499
|
+
pmBriefTool(env),
|
|
500
|
+
pmGateTool(env),
|
|
501
|
+
pmSessionsTool(env),
|
|
502
|
+
pmPhaseTool(env),
|
|
503
|
+
pmCharterTool(env),
|
|
504
|
+
pmHandoverTool(env),
|
|
505
|
+
pmCheckpointTool(env),
|
|
506
|
+
pmSnapshotTool(env),
|
|
507
|
+
pmDiagTool(env, { schedulerReady: () => scheduler !== null, schedulerError: () => schedulerError }),
|
|
508
|
+
pmDriftTool(env),
|
|
509
|
+
pmChangelogTool(env),
|
|
510
|
+
pmTokensTool(env),
|
|
511
|
+
pmPackTool(env),
|
|
512
|
+
pmWorkspaceTool(env),
|
|
513
|
+
pmInitTool(env),
|
|
514
|
+
pmProfileTool(env),
|
|
515
|
+
];
|
|
516
|
+
const keys = ['brief', 'gate', 'sessions', 'phase', 'charter', 'handover', 'checkpoint', 'snapshot', 'diag', 'drift', 'changelog', 'tokens', 'pack', 'workspace', 'init', 'profile'];
|
|
517
|
+
for (let i = 0; i < tools.length; i++) {
|
|
518
|
+
const key = keys[i];
|
|
519
|
+
try {
|
|
520
|
+
// 名字以工厂自带的为准;工厂没写才用位置表补,且顺序错位时报出来(不再静默错名)。
|
|
521
|
+
if (!tools[i].name) tools[i].name = NAMES[key];
|
|
522
|
+
else if (tools[i].name !== NAMES[key]) initErrors.push('工具名错位:数组第 ' + i + ' 项是 ' + tools[i].name + ',位置表期望 ' + NAMES[key]);
|
|
523
|
+
ctx.tools.register(tools[i], { when: visibleTo(tools[i].name) });
|
|
524
|
+
registered.add(tools[i].name);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
initErrors.push(key + ':' + msgOf(e));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const peerTools = [
|
|
531
|
+
pmPeerListTool(env),
|
|
532
|
+
pmPeerStatusTool(env),
|
|
533
|
+
pmPeerSendTool(env),
|
|
534
|
+
pmPeerNewTool(env),
|
|
535
|
+
pmPeerRenameTool(env),
|
|
536
|
+
pmPeerPeekTool(env),
|
|
537
|
+
];
|
|
538
|
+
for (const t of peerTools) {
|
|
539
|
+
try {
|
|
540
|
+
ctx.tools.register(t, { when: visibleTo(t && t.name ? String(t.name) : '') });
|
|
541
|
+
if (t && t.name) registered.add(t.name);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
initErrors.push((t && t.name ? String(t.name) : 'peer_?') + ':' + msgOf(e));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------- 会话守卫(Charter §7.2.4):新建会话与设定角色仅属全局;
|
|
548
|
+
// 会话之间允许直接 peer_send(不必经全局转述),directPeer=off 时退回「只能向全局报告」的旧口径。
|
|
549
|
+
if (cfg.wakeAuthority !== 'off' && typeof ctx.tools.guard === 'function') {
|
|
550
|
+
try {
|
|
551
|
+
ctx.tools.guard((exec) => {
|
|
552
|
+
const nm = exec && exec.name;
|
|
553
|
+
if (nm !== 'peer_send' && nm !== 'peer_new') return undefined;
|
|
554
|
+
const sid = env.selfId(exec) || '';
|
|
555
|
+
const args = (exec && exec.arguments) || {};
|
|
556
|
+
let info;
|
|
557
|
+
try { info = env.roleOf(exec); } catch { info = { role: 'plain', source: 'error' }; }
|
|
558
|
+
const mk = (allow, why) => {
|
|
559
|
+
guardLast = { sid, allow, source: why, title: titleOf(sid) };
|
|
560
|
+
if (allow) return undefined;
|
|
561
|
+
if (nm === 'peer_new') return '会话创建仅属全局:非全局会话不得新建平级会话(杂项自建 Misc 换任除外)。';
|
|
562
|
+
return '会话间通信受限(directPeer=off):非全局会话只能向现任全局报告,不得唤醒其他会话。';
|
|
563
|
+
};
|
|
564
|
+
if (info.role === 'global') return mk(true, 'global/' + info.source);
|
|
565
|
+
if (nm === 'peer_send') {
|
|
566
|
+
// 会话之间可直接对话;向全局报告也在此路。directPeer=off 才退回旧口径。
|
|
567
|
+
const target = String(args.session_id || '');
|
|
568
|
+
let t = null;
|
|
569
|
+
try { t = target ? infoFor(target, {}) : null; } catch { t = null; }
|
|
570
|
+
if (t && t.role === 'global') return mk(true, 'report-to-global/' + (t.source || ''));
|
|
571
|
+
if (cfg.directPeer !== 'off') return mk(true, 'direct/' + info.role + ' → ' + (t ? t.role : 'unknown'));
|
|
572
|
+
return mk(false, info.source + '/' + info.role + ' → ' + (t ? t.role : 'unknown'));
|
|
573
|
+
}
|
|
574
|
+
// peer_new:只有杂项可以自建 Misc 换任
|
|
575
|
+
const target = String(args.workspace || args.cwd || '').toLowerCase();
|
|
576
|
+
if (info.role === 'misc' && target.indexOf('misc') !== -1) return mk(true, 'misc/' + info.source);
|
|
577
|
+
return mk(false, info.source + '/' + info.role);
|
|
578
|
+
});
|
|
579
|
+
} catch { /* 老版本没有 guard:退化为规则约束 */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------- 会话生命周期:创建时装配提示段;开轮前按角色表核对一次
|
|
583
|
+
// 工具可见性不在这里处理:0.2.2 起每步条件已按当前角色算好(见 visibleTo),换角色下一轮即生效。
|
|
584
|
+
function scopedMetaOf(h, sid) {
|
|
585
|
+
return { title: (h && h.title) || titleOf(sid), cwd: (h && h.cwd) || '' };
|
|
586
|
+
}
|
|
587
|
+
if (typeof ctx.on === 'function') {
|
|
588
|
+
try {
|
|
589
|
+
// 事件作用域:宿主以 agent 作为事件的作用域载体,插件在根作用域裸注册收不到别的会话的事件。
|
|
590
|
+
// 传 { global: true } 才能收到全部会话的事件(0.2.2 支持;旧内核忽略选项,退回裸注册)。
|
|
591
|
+
const onAgentEvent = (name, fn) => {
|
|
592
|
+
try { ctx.on(name, fn, { global: true }); return true; }
|
|
593
|
+
catch { try { ctx.on(name, fn); return true; } catch (e) { env.log('会话事件监听失败(' + name + '):' + msgOf(e)); return false; } }
|
|
594
|
+
};
|
|
595
|
+
onAgentEvent('agent/created', (payload) => {
|
|
596
|
+
try {
|
|
597
|
+
const agent = payload && payload.agent ? payload.agent : null;
|
|
598
|
+
if (agent) applyRoleTo(agent);
|
|
599
|
+
} catch (e) { env.log('角色装配失败:' + msgOf(e)); }
|
|
600
|
+
});
|
|
601
|
+
onAgentEvent('agent/disposed', (payload) => {
|
|
602
|
+
try {
|
|
603
|
+
const agent = payload && payload.agent ? payload.agent : null;
|
|
604
|
+
const h = agent && agent.session ? agent.session.header : null;
|
|
605
|
+
const sid = String((h && h.id) || (agent && agent.id) || '');
|
|
606
|
+
if (!sid) return;
|
|
607
|
+
const d = sectionDispose.get(sid);
|
|
608
|
+
if (typeof d === 'function') { try { d(); } catch { /* ignore */ } }
|
|
609
|
+
sectionDispose.delete(sid);
|
|
610
|
+
const ld = legacyRestrict.get(sid);
|
|
611
|
+
if (typeof ld === 'function') { try { ld(); } catch { /* ignore */ } }
|
|
612
|
+
legacyRestrict.delete(sid);
|
|
613
|
+
appliedRole.delete(sid);
|
|
614
|
+
roleCache.delete(sid);
|
|
615
|
+
} catch { /* ignore */ }
|
|
616
|
+
});
|
|
617
|
+
// 开轮前核对:休眠期间角色可能被改过,而宿主恢复同一活体时不保证再发创建事件。
|
|
618
|
+
// 状态切到 running 时核一次,提示段与旧内核的限制同角色表对齐。
|
|
619
|
+
onAgentEvent('agent/status', (payload) => {
|
|
620
|
+
try {
|
|
621
|
+
if (!payload || payload.status !== 'running') return;
|
|
622
|
+
const agent = payload.agent || null;
|
|
623
|
+
if (!agent) return;
|
|
624
|
+
const h = agent.session && agent.session.header ? agent.session.header : null;
|
|
625
|
+
const sid = String((h && h.id) || agent.id || '');
|
|
626
|
+
if (!sid) return;
|
|
627
|
+
const info = infoFor(sid, scopedMetaOf(h, sid));
|
|
628
|
+
if (appliedRole.get(sid) === info.role) return;
|
|
629
|
+
env.log('开轮前换装:' + sid + ' · 已装 ' + (appliedRole.get(sid) || '-') + ' · 目标 ' + info.role);
|
|
630
|
+
applyRoleTo(agent, info);
|
|
631
|
+
} catch (e) { env.log('开轮前装配失败:' + msgOf(e)); }
|
|
632
|
+
});
|
|
633
|
+
} catch (e) {
|
|
634
|
+
initErrors.push('会话事件监听:' + msgOf(e));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 插件重新加载(停用再启用、或启动时活体会话早于插件)时,已存在的会话不会再收到
|
|
638
|
+
// 创建事件;这里补一次装配,否则它们的提示段会随旧实例一起消失。
|
|
639
|
+
// agents 未在 inject 里声明,激活瞬间可能还取不到,所以取不到时延迟再试一次。
|
|
640
|
+
const reapplyLive = () => {
|
|
641
|
+
let agents = null;
|
|
642
|
+
try { agents = ctx.agents; } catch { agents = null; }
|
|
643
|
+
if (!agents || typeof agents.list !== 'function') {
|
|
644
|
+
try { agents = typeof ctx.get === 'function' ? ctx.get('agents') : null; } catch { agents = null; }
|
|
645
|
+
}
|
|
646
|
+
if (!agents || typeof agents.list !== 'function') return 0;
|
|
647
|
+
let n = 0;
|
|
648
|
+
for (const a of agents.list()) {
|
|
649
|
+
try { applyRoleTo(a); n++; } catch { /* 单个会话装配失败不影响插件激活 */ }
|
|
650
|
+
}
|
|
651
|
+
if (n > 0) env.log('补装配 ' + n + ' 个活体会话');
|
|
652
|
+
return n;
|
|
653
|
+
};
|
|
654
|
+
try {
|
|
655
|
+
if (reapplyLive() === 0) {
|
|
656
|
+
const t = setTimeout(() => { try { reapplyLive(); } catch { /* ignore */ } }, 1200);
|
|
657
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
658
|
+
}
|
|
659
|
+
} catch { /* 拿不到 agents 服务就跳过补装配 */ }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ---------------- 设置页数据接口:主通道=DSH 认证的 /api 载体;旧通道=/dsh-handover HTTP 路由
|
|
663
|
+
try {
|
|
664
|
+
const info = pkgInfo();
|
|
665
|
+
const pageInfo = {
|
|
666
|
+
name: info.name,
|
|
667
|
+
version: info.version,
|
|
668
|
+
tools: tools.length + peerTools.length,
|
|
669
|
+
scheduler: scheduler ? '就绪' : '未就绪' + (schedulerError ? ':' + schedulerError : ''),
|
|
670
|
+
};
|
|
671
|
+
const api = createApi(env, pageInfo);
|
|
672
|
+
env.api = api;
|
|
673
|
+
registerStatusRoute(ctx, env, pageInfo, api);
|
|
674
|
+
registerRpcCarrier(ctx, api, (m) => env.log(m));
|
|
675
|
+
// 预热状态快照:设置页直接取缓存,避免请求等待现算。
|
|
676
|
+
try {
|
|
677
|
+
// 只在「最近 5 分钟有人看过设置页」时预热:没人看就不算,避免空转占宿主主线程。
|
|
678
|
+
const warm = () => {
|
|
679
|
+
if (Date.now() - (env.lastStatusCallAt || 0) > 5 * 60 * 1000) return;
|
|
680
|
+
try { statusSnapshot(env, pageInfo, 'warm'); } catch { /* ignore */ }
|
|
681
|
+
};
|
|
682
|
+
const t0 = setTimeout(warm, 1500);
|
|
683
|
+
if (t0 && typeof t0.unref === 'function') t0.unref();
|
|
684
|
+
const iv = setInterval(warm, 30000);
|
|
685
|
+
if (iv && typeof iv.unref === 'function') iv.unref();
|
|
686
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => { clearInterval(iv); }, 'dsh-handover: 状态预热');
|
|
687
|
+
} catch { /* ignore */ }
|
|
688
|
+
} catch (e) {
|
|
689
|
+
initErrors.push('设置页路由:' + msgOf(e));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------- 聊天页命令:用户在自己的输入框敲 /allow-compact 即可授权压缩
|
|
693
|
+
// 为什么要有它:设置页要切页面、还要填会话 id,太绕。斜杠命令是「用户亲手敲的一行」,
|
|
694
|
+
// 天然满足「只有用户能产生同意」这条判据,且不产生模型消息、不花 token。
|
|
695
|
+
try {
|
|
696
|
+
const registerAllowCompact = (service) => {
|
|
697
|
+
if (!service || typeof service.register !== 'function') return;
|
|
698
|
+
const d = service.register({
|
|
699
|
+
name: 'allow-compact',
|
|
700
|
+
description: '允许本会话压缩一次(用户授权,5 分钟有效;可带会话短号授权别的会话)',
|
|
701
|
+
input: { hint: '可留空=本会话;填 8 位短号或 session-id 则授权那个会话' },
|
|
702
|
+
handler: ({ agent, rawInput }) => {
|
|
703
|
+
const arg = String(rawInput || '').trim();
|
|
704
|
+
const sid = arg ? resolveShortSid(env, arg) : (agent && agent.id ? String(agent.id) : '');
|
|
705
|
+
if (!sid) return { kind: 'error', text: '拿不到会话 id:请写成 /allow-compact session-xxxx' };
|
|
706
|
+
const g = grantConsent(sid, '聊天页命令 /allow-compact');
|
|
707
|
+
try { env.log('压缩授权(命令):' + sid); } catch { /* ignore */ }
|
|
708
|
+
return { kind: 'success', text: '已允许 ' + sid + ' 在 ' + g.minutes + ' 分钟内压缩一次(用掉即失效)。' };
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
if (typeof d === 'function' && typeof ctx.effect === 'function') {
|
|
712
|
+
ctx.effect(() => d, 'dsh-handover: /allow-compact');
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
// 可选服务:能用 ctx.inject 就走注入(老宿主没有 commands 时插件照常激活),否则退回直取。
|
|
716
|
+
if (typeof ctx.inject === 'function') {
|
|
717
|
+
ctx.inject(['commands'], (scope) => {
|
|
718
|
+
try { registerAllowCompact(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
|
|
719
|
+
});
|
|
720
|
+
} else {
|
|
721
|
+
registerAllowCompact(ctx.commands);
|
|
722
|
+
}
|
|
723
|
+
} catch (e) {
|
|
724
|
+
initErrors.push('命令注册:' + msgOf(e));
|
|
725
|
+
}
|
|
726
|
+
}
|