@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.
- package/README.md +53 -17
- package/docs/migrate.md +1 -1
- package/docs/releases/0.9.12.md +28 -0
- package/docs/releases/0.9.13.md +30 -0
- package/docs/troubleshoot.md +43 -6
- package/package.json +70 -57
- package/scripts/blank-dsh.mjs +6 -5
- package/scripts/blank-smoke.mjs +268 -58
- package/scripts/build.mjs +3 -0
- package/scripts/check-docs.mjs +23 -2
- package/scripts/office.mjs +81 -0
- package/src/client.js +92 -23
- package/src/index.mjs +264 -145
- package/src/lib/api.mjs +67 -9
- package/src/lib/config.mjs +4 -3
- package/src/lib/consent.mjs +59 -20
- package/src/lib/facts.mjs +5 -4
- package/src/lib/guide.mjs +14 -7
- package/src/lib/handover-io.mjs +20 -0
- package/src/lib/manual.mjs +23 -12
- package/src/lib/names.mjs +2 -2
- package/src/lib/office.mjs +272 -0
- package/src/lib/pack-ops.mjs +3 -0
- package/src/lib/phase.mjs +6 -4
- package/src/lib/prompt.mjs +18 -0
- package/src/lib/role.mjs +52 -18
- package/src/lib/schedule.mjs +10 -14
- package/src/lib/session-create.mjs +42 -0
- package/src/lib/session-ops.mjs +183 -79
- package/src/lib/sessions.mjs +9 -8
- package/src/lib/status.mjs +8 -2
- package/src/lib/succession.mjs +244 -0
- package/src/lib/text.mjs +27 -0
- package/src/lib/webroute.mjs +1 -1
- package/src/lib/workspace.mjs +1 -1
- package/src/selftest.mjs +15 -0
- package/src/templates/Charter.skeleton.md +1 -1
- package/src/templates/Prompt_Templates.md +9 -17
- package/src/templates/Runtime_Manual.md +17 -14
- package/src/templates/rules/Handover_Rules.md +3 -2
- package/src/templates/rules/Misc_Task_Standard.md +3 -3
- package/src/tools/ho_checkpoint.mjs +11 -4
- package/src/tools/ho_diag.mjs +5 -3
- package/src/tools/ho_handover.mjs +7 -6
- package/src/tools/ho_office.mjs +59 -0
- package/src/tools/ho_profile.mjs +10 -2
- package/src/tools/ho_sessions.mjs +3 -2
- package/src/tools/ho_snapshot.mjs +2 -1
- package/src/tools/ho_succeed.mjs +24 -0
- 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
|
-
* 按会话角色载入工具与提示段:全局
|
|
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,
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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 = () => ({
|
|
282
|
+
env.roleState = () => ({ mode: roleMode(), last: roleState.last, count: appliedRole.size });
|
|
243
283
|
env.rolesSnapshot = () => readRoles(cfg).sessions;
|
|
244
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
const old =
|
|
282
|
-
if (typeof old === 'function') { try { old(); } catch { /*
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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 (
|
|
319
|
-
|
|
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
|
-
|
|
354
|
+
}
|
|
322
355
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
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;
|
|
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
|
|
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
|
-
// ---------------- 工具注册(
|
|
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
|
-
// ----------------
|
|
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
|
-
|
|
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
|
|
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
|
-
// ----------------
|
|
498
|
-
//
|
|
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
|
-
|
|
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 (
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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('
|
|
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
|
|
589
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 {
|
|
742
|
+
try { registerAllow(scope && scope.commands); } catch (e) { initErrors.push('命令注册:' + msgOf(e)); }
|
|
624
743
|
});
|
|
625
744
|
} else {
|
|
626
|
-
|
|
745
|
+
registerAllow(ctx.commands);
|
|
627
746
|
}
|
|
628
747
|
} catch (e) {
|
|
629
748
|
initErrors.push('命令注册:' + msgOf(e));
|