@mingmaple/dsh-handover 0.9.14 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +123 -92
  2. package/docs/migrate.md +52 -41
  3. package/docs/releases/0.9.12.md +2 -3
  4. package/docs/releases/0.9.14.md +1 -5
  5. package/docs/releases/0.9.22.md +34 -0
  6. package/docs/releases/1.0.0.md +33 -0
  7. package/docs/troubleshoot.md +230 -46
  8. package/package.json +74 -71
  9. package/scripts/behavior.mjs +398 -0
  10. package/scripts/blank-dsh.mjs +41 -25
  11. package/scripts/blank-smoke.mjs +278 -109
  12. package/scripts/check-docs.mjs +7 -0
  13. package/scripts/e2e-migration.mjs +2 -2
  14. package/scripts/office.mjs +3 -3
  15. package/scripts/portability.mjs +71 -0
  16. package/scripts/style-gate.mjs +571 -0
  17. package/src/client.js +1028 -955
  18. package/src/index.mjs +313 -142
  19. package/src/lib/absrefs.mjs +1 -1
  20. package/src/lib/api.mjs +18 -10
  21. package/src/lib/archive.mjs +1 -1
  22. package/src/lib/brief.mjs +8 -6
  23. package/src/lib/config.mjs +23 -8
  24. package/src/lib/consent.mjs +54 -1
  25. package/src/lib/docmap.mjs +140 -0
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/events.mjs +1 -1
  28. package/src/lib/facts.mjs +19 -12
  29. package/src/lib/gates.mjs +30 -6
  30. package/src/lib/global-task.mjs +69 -0
  31. package/src/lib/guide.mjs +81 -85
  32. package/src/lib/handover-io.mjs +69 -6
  33. package/src/lib/identity.mjs +330 -0
  34. package/src/lib/legacy.mjs +89 -0
  35. package/src/lib/lint.mjs +17 -13
  36. package/src/lib/manual.mjs +135 -59
  37. package/src/lib/mover.mjs +23 -19
  38. package/src/lib/names.mjs +14 -19
  39. package/src/lib/office.mjs +63 -15
  40. package/src/lib/pack-ops.mjs +11 -11
  41. package/src/lib/pathkey.mjs +24 -0
  42. package/src/lib/peer.mjs +4 -4
  43. package/src/lib/phase.mjs +8 -13
  44. package/src/lib/profile.mjs +1 -1
  45. package/src/lib/reminder.mjs +110 -0
  46. package/src/lib/role.mjs +9 -6
  47. package/src/lib/scaffold.mjs +68 -26
  48. package/src/lib/schedule.mjs +5 -6
  49. package/src/lib/session-create.mjs +3 -3
  50. package/src/lib/session-ops.mjs +80 -51
  51. package/src/lib/sessions.mjs +41 -3
  52. package/src/lib/sevenzip.mjs +10 -1
  53. package/src/lib/status.mjs +337 -176
  54. package/src/lib/succession.mjs +16 -16
  55. package/src/lib/system.mjs +364 -0
  56. package/src/lib/zstd-log.mjs +1 -1
  57. package/src/selftest.mjs +6 -6
  58. package/src/templates/Changelog.skeleton.md +3 -3
  59. package/src/templates/Charter.skeleton.md +13 -9
  60. package/src/templates/Handover.global.skeleton.md +20 -13
  61. package/src/templates/Handover.template.md +89 -0
  62. package/src/templates/Plan.skeleton.md +34 -0
  63. package/src/templates/Prompt_Templates.md +101 -20
  64. package/src/templates/Runtime_Manual.md +263 -122
  65. package/src/templates/System.skeleton.md +31 -0
  66. package/src/templates/Systems.global.skeleton.md +13 -0
  67. package/src/templates/rules/Delegation_Rules.md +45 -0
  68. package/src/templates/rules/Handover_Rules.md +46 -23
  69. package/src/templates/rules/Migration_Rules.md +7 -7
  70. package/src/templates/rules/Misc_Task_Standard.md +17 -10
  71. package/src/templates/rules/Writing_Rules.md +33 -16
  72. package/src/templates/standards/README.md +37 -0
  73. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203/README.md +120 -0
  74. package/src/templates/standards//345/206/231/344/275/234/350/247/204/350/214/203//344/276/235/346/215/256_/350/256/272/346/226/207.md +389 -0
  75. package/src/tools/ho_backup.mjs +135 -0
  76. package/src/tools/ho_changelog.mjs +4 -4
  77. package/src/tools/ho_charter.mjs +7 -6
  78. package/src/tools/ho_checkpoint.mjs +36 -32
  79. package/src/tools/ho_docmap.mjs +239 -0
  80. package/src/tools/ho_gate.mjs +11 -5
  81. package/src/tools/ho_handover.mjs +61 -35
  82. package/src/tools/ho_office.mjs +46 -5
  83. package/src/tools/ho_profile.mjs +3 -3
  84. package/src/tools/ho_sessions.mjs +46 -26
  85. package/src/tools/ho_status.mjs +251 -0
  86. package/src/tools/ho_succeed.mjs +3 -3
  87. package/src/tools/ho_workspace.mjs +146 -42
  88. package/src/tools/peer.mjs +416 -0
  89. package/src/tools/ho_brief.mjs +0 -57
  90. package/src/tools/ho_diag.mjs +0 -87
  91. package/src/tools/ho_drift.mjs +0 -41
  92. package/src/tools/ho_init.mjs +0 -82
  93. package/src/tools/ho_pack.mjs +0 -38
  94. package/src/tools/ho_phase.mjs +0 -37
  95. package/src/tools/ho_snapshot.mjs +0 -104
  96. package/src/tools/ho_tokens.mjs +0 -57
  97. package/src/tools/peer_list.mjs +0 -37
  98. package/src/tools/peer_new.mjs +0 -75
  99. package/src/tools/peer_peek.mjs +0 -41
  100. package/src/tools/peer_rename.mjs +0 -31
  101. package/src/tools/peer_send.mjs +0 -52
  102. package/src/tools/peer_status.mjs +0 -46
@@ -0,0 +1,416 @@
1
+ /**
2
+ * peer —— 平级会话:一个入口,按 action 分流。
3
+ * list 会话一览(只读缓存,不激活任何会话)
4
+ * status 单会话详情
5
+ * send 投一条消息(唯一会消耗对方 token 的动作)
6
+ * peek 读取目标会话近期会话历史(内容进本会话上下文)
7
+ * rename 只改显示标题
8
+ * new 新建平级会话
9
+ * 原 peer_list、peer_status、peer_send、peer_peek、peer_rename、peer_new 合并于此。
10
+ * 动作级权限在这里再判一次:角色矩阵只决定「这个工具可不可见」,不细分动作。
11
+ */
12
+ import { randomUUID } from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { textOutput } from '../lib/output.mjs';
17
+ import { PEER_NAME } from '../lib/names.mjs';
18
+ import { NL } from '../lib/text.mjs';
19
+ import {
20
+ readProjCache, rowLine, sessionRows, stamp, pressurePct, isSubagent, isBusy,
21
+ projectName, pick, peerMarker, resolveWorkspaceId, findSessionLog, decodeSessionLog, conversation,
22
+ } from '../lib/peer.mjs';
23
+
24
+ export const ACTIONS = ['list', 'status', 'send', 'wait', 'collect', 'collab', 'peek', 'rename', 'new'];
25
+ const GLOBAL_ONLY = ['list', 'peek', 'rename'];
26
+
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+ const COLLECT_POLL_MS = 30000;
29
+ const WAIT_MIN_MS = 120000;
30
+
31
+ /** 协作游标:交办者 → 目标 → { since }。只存时间戳,不存消息正文。 */
32
+ function collectFile() {
33
+ const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
34
+ return path.join(home, '.dsh', 'storages', 'dsh-handover.collect.json');
35
+ }
36
+ function loadCollect() {
37
+ try {
38
+ const data = JSON.parse(fs.readFileSync(collectFile(), 'utf8'));
39
+ if (data && typeof data === 'object' && data.cursors) {
40
+ if (!Array.isArray(data.groups)) data.groups = [];
41
+ return data;
42
+ }
43
+ } catch (error) { void error; }
44
+ return { version: 1, cursors: {}, groups: [] };
45
+ }
46
+ function saveCollect(data) {
47
+ const file = collectFile();
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ const tmp = file + '.tmp-' + process.pid;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
51
+ fs.renameSync(tmp, file);
52
+ }
53
+ function callerOf(exec) {
54
+ return String(exec?.agent?.session?.header?.id ?? 'unknown');
55
+ }
56
+ function sinceOf(data, caller, target) {
57
+ const row = data.cursors[caller] && data.cursors[caller][target];
58
+ return row && Number.isFinite(row.since) ? row.since : null;
59
+ }
60
+ function setSince(data, caller, target, since) {
61
+ if (!data.cursors[caller]) data.cursors[caller] = {};
62
+ data.cursors[caller][target] = { since };
63
+ }
64
+
65
+ /** 协作注册:被交办方在协作期间不得向交办者或同组成员发明文消息;收集完自动解除。 */
66
+ function groupsOf(state) {
67
+ return Array.isArray(state.groups) ? state.groups : [];
68
+ }
69
+ function groupOf(state, sid) {
70
+ return groupsOf(state).find((g) => String(sid) === String(g.coordinator) || (Array.isArray(g.members) && g.members.indexOf(String(sid)) !== -1)) || null;
71
+ }
72
+ function assertNotReplyingInCollab(state, sender, target) {
73
+ const g = groupOf(state, sender);
74
+ if (!g) return;
75
+ if (String(sender) === String(g.coordinator)) return;
76
+ const inGroup = String(target) === String(g.coordinator)
77
+ || (Array.isArray(g.members) && g.members.indexOf(String(target)) !== -1);
78
+ if (!inGroup) return;
79
+ throw new Error(
80
+ '协作注册期间,被交办方不得向交办者或同组成员发送明文消息(target=' + target + '):'
81
+ + '完成即结束回合,最终回复即为结果,协调者用 peer(action="collect") 取回。'
82
+ + '如需请示,将问题写入最终回复并结束回合;普通对话不受此限制。',
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 供宿主工具守卫使用:某会话是协作成员(非协调者)时返回拒绝理由,否则空串。
88
+ * 用于拦住 DSH 自带的 send_message 等明文投递通道。
89
+ */
90
+ export function collabMemberReason(sid) {
91
+ try {
92
+ const state = loadCollect();
93
+ const g = groupOf(state, sid);
94
+ if (!g) return '';
95
+ if (String(sid) === String(g.coordinator)) return '';
96
+ return '协作注册期间,被交办方不得发送明文消息:完成即结束回合,最终回复由协调者用 peer(action="collect") 取回。';
97
+ } catch (error) { void error; }
98
+ return '';
99
+ }
100
+
101
+ /** 协作注册:开始或结束一组协作;结束即解除明文交流限制。 */
102
+ function collabText(args, exec) {
103
+ const caller = callerOf(exec);
104
+ const state = loadCollect();
105
+ // 结束模式取 collabMode(schema 参数名);兼容早期把 mode 当结束开关的调用。
106
+ const ending = args.collabMode === 'end' || args.mode === 'end';
107
+ if (ending) {
108
+ const before = groupsOf(state).length;
109
+ state.groups = groupsOf(state).filter((g) => String(g.coordinator) !== String(caller));
110
+ saveCollect(state);
111
+ return '已结束协作注册(' + String(before - state.groups.length) + ' 组),明文交流限制已解除。';
112
+ }
113
+ const members = (Array.isArray(args.sessions) ? args.sessions : [])
114
+ .map((x) => String(x || '').trim()).filter(Boolean).filter((s) => s !== caller);
115
+ if (!members.length) throw new Error('peer(action="collab"):sessions 至少列一个协作会话');
116
+ state.groups = groupsOf(state).filter((g) => String(g.coordinator) !== String(caller));
117
+ state.groups.push({ coordinator: caller, members, at: Date.now() });
118
+ saveCollect(state);
119
+ return '已注册协作:协调者 ' + caller + ',成员 ' + members.join('、')
120
+ + '。协作期间成员不得向协调者或同组成员发送明文消息;collect 取回全部结果后自动解除。';
121
+ }
122
+
123
+ /** 助手文本:读目标日志里 since 之后的助手回复,不定长截断。 */
124
+ function assistantReplies(file, sinceMs) {
125
+ const events = decodeSessionLog(file);
126
+ if (events === null) return null;
127
+ const items = conversation(events, 500).filter((it) => it.who === 'assistant' && Number(it.t) > sinceMs);
128
+ return items.map((it) => it.text);
129
+ }
130
+
131
+ /** 单个目标的完成判定:这一轮已结束,且 since 之后有助手文本。 */
132
+ function inspectTarget(sid, since) {
133
+ const file = findSessionLog(sid);
134
+ if (file === null) return { sid, state: 'missing', text: '' };
135
+ let replies = null;
136
+ try { replies = assistantReplies(file, since); } catch (error) { void error; }
137
+ if (replies === null) return { sid, state: 'nozstd', text: '' };
138
+ const cache = readProjCache(sid);
139
+ const busy = cache === null ? null : isBusy(cache);
140
+ if (busy === false && replies.length) {
141
+ return { sid, state: 'done', text: replies[replies.length - 1], at: Date.now() };
142
+ }
143
+ return { sid, state: busy === null ? 'unknown' : busy ? 'running' : 'ended-no-text', text: '' };
144
+ }
145
+
146
+ /** 一对一:等单个目标的回复。 */
147
+ async function waitText(args) {
148
+ const sid = String(args.session_id || '').trim();
149
+ if (!sid) throw new Error('peer(action="wait"):session_id 不能为空');
150
+ const timeout = Math.max(WAIT_MIN_MS, Math.min(3600000, Number(args.timeout_ms) || WAIT_MIN_MS));
151
+ const since = Number(args.since_ms) || Date.now();
152
+ const deadline = Date.now() + timeout;
153
+ let lastError = '';
154
+ for (;;) {
155
+ const one = inspectTarget(sid, since);
156
+ if (one.state === 'done') return '回复 ' + sid + ':' + NL + one.text;
157
+ if (one.state === 'missing') throw new Error('peer(action="wait"):找不到目标会话日志:' + sid);
158
+ if (one.state === 'nozstd') throw new Error('peer(action="wait"):本 Node 构建没有 zstd 支持,读不了会话日志');
159
+ lastError = one.state;
160
+ if (Date.now() >= deadline) break;
161
+ await sleep(2000);
162
+ }
163
+ return '等待超时(' + String(Math.round(timeout / 1000)) + ' 秒):' + sid + ' 状态 ' + lastError
164
+ + '。可先用 peer(action="status") 查看状态,稍后重试 wait。';
165
+ }
166
+
167
+ /**
168
+ * 多会话收集:列一串会话,谁先完成先返回;同时完成多个就一起返回。
169
+ * 一次调用最多等 timeout_ms(最低 120 秒,由助手按任务预估设置);到点一个都没完成则返回仍在运行的名单。
170
+ */
171
+ async function collectText(args, exec) {
172
+ const caller = callerOf(exec);
173
+ const state = loadCollect();
174
+ const list = Array.isArray(args.sessions)
175
+ ? args.sessions.map((x) => String(x || '').trim()).filter(Boolean)
176
+ : [];
177
+ const targets = list.length ? list : Object.keys(state.cursors[caller] || {});
178
+ if (!targets.length) throw new Error('peer(action="collect"):sessions 为空,且没有已交办的目标');
179
+ const timeout = Math.max(WAIT_MIN_MS, Math.min(3600000, Number(args.timeout_ms) || WAIT_MIN_MS));
180
+ const deadline = Date.now() + timeout;
181
+ const explicitSince = Number(args.since_ms) || null;
182
+ const since = {};
183
+ for (const sid of targets) since[sid] = sinceOf(state, caller, sid) ?? explicitSince ?? Date.now();
184
+ const done = [];
185
+ const running = new Map(targets.map((sid) => [sid, 'running']));
186
+ for (;;) {
187
+ for (const sid of targets) {
188
+ if (done.some((d) => d.sid === sid)) continue;
189
+ const one = inspectTarget(sid, since[sid]);
190
+ running.set(sid, one.state);
191
+ if (one.state === 'done') done.push(one);
192
+ }
193
+ if (done.length) {
194
+ for (const one of done) setSince(state, caller, one.sid, one.at);
195
+ const groups = groupsOf(state);
196
+ const g = groups.find((x) => String(x.coordinator) === caller);
197
+ if (g) {
198
+ g.members = (Array.isArray(g.members) ? g.members : []).filter((m) => !done.some((d) => d.sid === m));
199
+ if (!g.members.length) state.groups = groups.filter((x) => x !== g);
200
+ }
201
+ saveCollect(state);
202
+ const out = ['已完成 ' + String(done.length) + ' 个:'];
203
+ for (const one of done) out.push('[' + one.sid + ']' + NL + one.text);
204
+ return out.join(NL);
205
+ }
206
+ if (Date.now() >= deadline) break;
207
+ await sleep(COLLECT_POLL_MS);
208
+ }
209
+ const lines = ['本轮没有完成的会话(已等待 ' + String(Math.round(timeout / 1000)) + ' 秒):'];
210
+ for (const sid of targets) lines.push(' ' + sid + ' ' + (running.get(sid) || 'running'));
211
+ lines.push('可稍后重试 collect;已完成的会立即返回。');
212
+ return lines.join(NL);
213
+ }
214
+
215
+ function roleOf(env, exec) {
216
+ try { return env.roleOf(exec) || { role: 'plain' }; } catch { return { role: 'plain' }; }
217
+ }
218
+
219
+ function denyUnless(ok, action, why) {
220
+ if (!ok) throw new Error('peer(action="' + action + '"):' + why);
221
+ }
222
+
223
+ async function listText(env, args) {
224
+ const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, Math.trunc(args.limit))) : 40;
225
+ const controller = env.controller();
226
+ if (!controller || typeof controller.list !== 'function') {
227
+ throw new Error('peer(action="list"):sessionController 不可用(插件未注入该服务)');
228
+ }
229
+ const rows = await sessionRows(controller, args.include_subagents === true);
230
+ if (rows.length === 0) return 'no sessions';
231
+ return rows.slice(0, limit).map((row) => {
232
+ const cache = readProjCache(row.sessionId);
233
+ const state = row.running ? 'running' : row.agentAvailable ? 'idle' : 'cold';
234
+ return rowLine(row.sessionId, cache, state) + ' | ' + stamp(row.updatedAt);
235
+ }).join(NL);
236
+ }
237
+
238
+ async function statusText(env, args) {
239
+ const controller = env.controller();
240
+ if (!controller || typeof controller.list !== 'function') {
241
+ throw new Error('peer(action="status"):sessionController 不可用(插件未注入该服务)');
242
+ }
243
+ const sid = String(args.session_id || '').trim();
244
+ if (!sid) throw new Error('peer(action="status"):session_id 不能为空');
245
+ const cache = readProjCache(sid);
246
+ const rows = await sessionRows(controller, true);
247
+ const row = rows.find((r) => r.sessionId === sid) ?? null;
248
+ if (cache === null && row === null) return 'unknown session: ' + sid;
249
+ const stats = pick(cache, 'record', 'rows', 'sessionStats', 'val') ?? {};
250
+ const pct = cache === null ? null : pressurePct(cache);
251
+ return [
252
+ 'session ' + sid,
253
+ 'kind ' + (cache === null ? '?' : isSubagent(cache) ? '子会话(subagent)' : '平级会话(peer)'),
254
+ 'project ' + (cache === null ? '-' : projectName(cache)),
255
+ 'pressure ' + (pct === null ? '?' : pct.toFixed(1) + '%') + (pct !== null && pct >= 32 ? ' 已进入预警区' : ''),
256
+ 'state ' + (isBusy(cache) === true ? 'running' : row === null ? 'cold' : row.agentAvailable ? 'idle' : 'cold'),
257
+ 'turns ' + String(stats.turns ?? '?') + ' steps ' + String(stats.steps ?? '?'),
258
+ 'cwd ' + String(row?.cwd ?? pick(cache, 'record', 'identity', 'cwd') ?? '-'),
259
+ 'preset ' + String(pick(cache, 'record', 'rows', 'agentPreset', 'val') ?? '-'),
260
+ 'updated ' + stamp(row?.updatedAt ?? pick(cache, 'record', 'identity', 'createdAt')),
261
+ ].join(NL);
262
+ }
263
+
264
+ async function sendText(env, args, exec, info) {
265
+ const controller = env.controller();
266
+ if (!controller || typeof controller.prompt !== 'function') {
267
+ throw new Error('peer(action="send"):sessionController.prompt 不可用(插件未注入该服务)');
268
+ }
269
+ const target = String(args.session_id || '').trim();
270
+ if (!target) throw new Error('peer(action="send"):session_id 不能为空');
271
+ const message = typeof args.message === 'string' ? args.message : '';
272
+ if (message.trim() === '') throw new Error('peer(action="send"):message 不能为空');
273
+ const mode = args.mode === 'steer' ? 'steer' : 'queue';
274
+ // 来源标记:dsh 把宿主投递的消息记成 source.kind === "user",不加标记的话,
275
+ // 平级会话的消息在日志和界面上与用户消息完全无法区分。
276
+ const sender = exec?.agent?.session?.header?.id ?? 'unknown';
277
+ const marker = peerMarker(sender);
278
+ const body = message.startsWith(marker) ? message : marker + NL + message;
279
+ const before = loadCollect();
280
+ assertNotReplyingInCollab(before, sender, target);
281
+ const since = Date.now();
282
+ const receipt = await controller.prompt(
283
+ { requestId: randomUUID(), sessionId: target, mode, content: [{ type: 'text', text: body }] },
284
+ new AbortController().signal,
285
+ );
286
+ try {
287
+ const state = loadCollect();
288
+ setSince(state, sender, target, since);
289
+ saveCollect(state);
290
+ } catch (error) { void error; }
291
+ const head = 'accepted=' + String(receipt.accepted) + ' mode=' + mode + ' target=' + target + ' from=' + sender;
292
+ if (args.wait === true) {
293
+ return head + NL + await waitText({ session_id: target, since_ms: since, timeout_ms: args.timeout_ms });
294
+ }
295
+ return head;
296
+ }
297
+
298
+ async function peekText(args) {
299
+ const sid = String(args.session_id || '').trim();
300
+ if (!sid) throw new Error('peer(action="peek"):session_id 不能为空');
301
+ const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(40, Math.trunc(args.limit))) : 8;
302
+ const file = findSessionLog(sid);
303
+ if (file === null) return 'no session log found for ' + sid;
304
+ const events = decodeSessionLog(file);
305
+ if (events === null) return 'this Node build has no zstd support; cannot read the session log';
306
+ const items = conversation(events, limit);
307
+ const head = 'session ' + sid + ' | 最近 ' + String(items.length) + ' 条 | ' + file;
308
+ if (items.length === 0) return head + NL + '(没有可读的会话历史)';
309
+ const body = items.map((it) => {
310
+ const one = it.text.replace(/\s+/g, ' ').trim();
311
+ const cut = one.length > 400 ? one.slice(0, 400) + ' …' : one;
312
+ return '[' + stamp(it.t) + ' ' + it.who + '] ' + cut;
313
+ }).join(NL);
314
+ return head + NL + body;
315
+ }
316
+
317
+ async function renameText(env, args) {
318
+ const controller = env.controller();
319
+ if (!controller || typeof controller.rename !== 'function') {
320
+ throw new Error('peer(action="rename"):sessionController.rename 不可用(插件未注入该服务)');
321
+ }
322
+ const sid = String(args.session_id || '').trim();
323
+ if (!sid) throw new Error('peer(action="rename"):session_id 不能为空');
324
+ const title = String(args.title ?? '').trim();
325
+ if (title === '') throw new Error('peer(action="rename"):title 不能为空');
326
+ await controller.rename({ sessionId: sid, title });
327
+ return 'renamed ' + sid + ' -> ' + title;
328
+ }
329
+
330
+ async function newText(env, args, info) {
331
+ const controller = env.controller();
332
+ if (!controller || typeof controller.create !== 'function') {
333
+ throw new Error('peer(action="new"):sessionController.create 不可用(插件未注入该服务)');
334
+ }
335
+ // 会话创建仅属全局;杂项可以把会话建在 Misc 下(落到本任务体系内)。
336
+ const wantsWorkspace = typeof args.workspace === 'string' && args.workspace.trim() !== '';
337
+ if (wantsWorkspace && args.cwd !== undefined) {
338
+ throw new Error('peer(action="new"):传 workspace 时不得再传 cwd,session.create 只接受其中之一(cwd 会自动取该工作区的 path)');
339
+ }
340
+ const targetText = String(args.workspace || args.cwd || '').toLowerCase();
341
+ const allowed = info.role === 'global' || (info.role === 'misc' && targetText.indexOf('misc') !== -1);
342
+ denyUnless(allowed, 'new', '会话创建仅属全局(杂项可建到 Misc 下)');
343
+ let request = {};
344
+ if (wantsWorkspace) {
345
+ const workspaceId = resolveWorkspaceId(args.workspace);
346
+ if (workspaceId === null || workspaceId === undefined) {
347
+ throw new Error('peer(action="new"):找不到工作区「' + args.workspace + '」(可用 id / 标题 / 路径;数据源 storages/workspace.json)');
348
+ }
349
+ request = { workspaceId };
350
+ } else if (args.cwd !== undefined) {
351
+ request = { cwd: args.cwd };
352
+ }
353
+ const created = await controller.create(request);
354
+ const id = created.sessionId;
355
+ if (typeof args.title === 'string' && args.title.trim() !== '') {
356
+ try {
357
+ await controller.rename({ sessionId: id, title: args.title.trim() });
358
+ } catch (error) {
359
+ if (typeof env.log === 'function') env.log('peer(action="new"):改标题失败:' + String(error));
360
+ }
361
+ }
362
+ let tail = '';
363
+ if (typeof args.message === 'string' && args.message.trim() !== '') {
364
+ await controller.prompt(
365
+ { requestId: randomUUID(), sessionId: id, mode: 'queue', content: [{ type: 'text', text: args.message }] },
366
+ new AbortController().signal,
367
+ );
368
+ tail = ' (opening message delivered)';
369
+ }
370
+ return 'created ' + id + tail;
371
+ }
372
+
373
+ export function pmPeerTool(env) {
374
+ return {
375
+ name: PEER_NAME,
376
+ description:
377
+ '平级会话:action=list 会话一览、status 单会话详情、send 投递消息(wait=true 时同轮等待回复)、wait 等待单个回复、collect 收集多个会话中先完成的最终回复、collab 注册或结束协作、peek 读取历史、rename 修改标题、new 新建会话。list、peek、rename 与 new 仅全局会话可用。',
378
+ parameters: {
379
+ type: 'object',
380
+ additionalProperties: false,
381
+ required: ['action'],
382
+ properties: {
383
+ action: { type: 'string', enum: ACTIONS, description: '要执行的动作' },
384
+ session_id: { type: 'string', description: 'status、send、wait、peek、rename:目标会话 id' },
385
+ sessions: { type: 'array', items: { type: 'string' }, description: 'collect:要等待的会话 id 列表;留空则取已交办的目标' },
386
+ message: { type: 'string', description: 'send:要投递的正文' },
387
+ mode: { type: 'string', enum: ['queue', 'steer'], description: 'send:queue(缺省)或 steer' },
388
+ wait: { type: 'boolean', description: 'send:投递后在同一轮等待回复(一对一对话用)' },
389
+ collabMode: { type: 'string', enum: ['start', 'end'], description: 'collab:start 注册协作(缺省)、end 结束并解除限制' },
390
+ timeout_ms: { type: 'number', description: 'wait、collect:单次等待上限(毫秒,最低 120000,按任务预估设置)' },
391
+ since_ms: { type: 'number', description: 'wait、collect:从哪一时刻之后查找回复(缺省取交办时刻)' },
392
+ limit: { type: 'number', description: 'list:最多多少行(缺省 40);peek:最近多少条(缺省 8,上限 40)' },
393
+ include_subagents: { type: 'boolean', description: 'list:同时列出子会话' },
394
+ title: { type: 'string', description: 'new、rename:显示标题' },
395
+ workspace: { type: 'string', description: 'new:工作区 id、标题或路径(与 cwd 互斥)' },
396
+ cwd: { type: 'string', description: 'new:新会话的工作目录' },
397
+ },
398
+ },
399
+ output: textOutput(),
400
+ async execute(args, exec) {
401
+ const action = String(args.action || '');
402
+ if (ACTIONS.indexOf(action) === -1) throw new Error('peer:action 只能是 ' + ACTIONS.join(' | ') + '(收到 ' + JSON.stringify(action) + ')');
403
+ const info = roleOf(env, exec);
404
+ if (GLOBAL_ONLY.indexOf(action) !== -1) denyUnless(info.role === 'global', action, '该动作仅全局会话可用');
405
+ if (action === 'list') return { text: await listText(env, args) };
406
+ if (action === 'status') return { text: await statusText(env, args) };
407
+ if (action === 'send') return { text: await sendText(env, args, exec, info) };
408
+ if (action === 'wait') return { text: await waitText(args) };
409
+ if (action === 'collect') return { text: await collectText(args, exec) };
410
+ if (action === 'collab') return { text: collabText(args, exec) };
411
+ if (action === 'peek') return { text: await peekText(args) };
412
+ if (action === 'rename') return { text: await renameText(env, args) };
413
+ return { text: await newText(env, args, info) };
414
+ },
415
+ };
416
+ }
@@ -1,57 +0,0 @@
1
- /** ho_brief —— 一条命令汇总:水位 + 闸门 + 会话清点 + 阶段建议。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { NL } from '../lib/text.mjs';
5
- import { syncCheck } from '../lib/gates.mjs';
6
- import { scanSessions, readSelfPressure } from '../lib/sessions.mjs';
7
- import { composeBrief } from '../lib/brief.mjs';
8
- import { decide } from '../lib/phase.mjs';
9
- import { computeDrift } from '../lib/drift.mjs';
10
- import { watermark } from '../lib/facts.mjs';
11
-
12
- export function pmBriefTool(env) {
13
- const w = watermark(env.cfg);
14
- return {
15
- name: nameOf('brief'),
16
- description:
17
- '起始与收束的单条命令:水位、闸门、会话清点、阶段建议。只读。',
18
- parameters: {
19
- type: 'object',
20
- additionalProperties: false,
21
- properties: {
22
- threshold: { type: 'number', description: '预警阈值百分比(默认 ' + w.warnValue + ')' },
23
- },
24
- },
25
- output: textOutput(),
26
- async execute(args, exec) {
27
- const ws = env.resolveWs(exec);
28
- const warn = typeof args.threshold === "number" ? args.threshold : w.warnValue;
29
- const gate = syncCheck(ws);
30
- const meId = env.selfId(exec);
31
- const self = readSelfPressure(env.cacheDir(), meId);
32
- const scan = scanSessions({
33
- dir: env.cacheDir(),
34
- meId,
35
- meCwd: env.selfCwd(exec),
36
- warn,
37
- silentHours: env.cfg.sessionScanHours,
38
- });
39
- const drift = computeDrift({ ws, cacheDir: env.cacheDir(), slack: env.cfg.driftSlack, all: false });
40
- const ratio = self.used === null || self.win === null ? null : self.used / self.win;
41
- const adv = decide({ ratio, compactionsUsed: 0, cfg: env.cfg, lockState: "unknown" });
42
- const selfPct = self.used === null || self.win === null ? null : (self.used / self.win) * 100;
43
- const ok = gate.ok && (selfPct === null || selfPct < warn);
44
- const lines = composeBrief({
45
- cfg: env.cfg,
46
- selfUsed: self.used,
47
- selfWin: self.win,
48
- gate,
49
- scan,
50
- drift,
51
- advice: { text: adv.action + " —— " + adv.reason },
52
- ok,
53
- });
54
- return { text: lines.join(NL) };
55
- },
56
- };
57
- }
@@ -1,87 +0,0 @@
1
- /** ho_diag —— 诊断:服务是否可解析、调度器是否就绪、配置是否如预期。不写任何东西。 */
2
- import fs from 'node:fs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import path from 'node:path';
5
- import { textOutput } from '../lib/output.mjs';
6
- import { NL } from '../lib/text.mjs';
7
- import { eventsOf, countCompactions } from '../lib/events.mjs';
8
-
9
- export function pmDiagTool(env, state) {
10
- return {
11
- name: nameOf('diag'),
12
- description:
13
- '插件自检:服务、调度器、压缩计数、工作区是否接入。只读。',
14
- parameters: { type: "object", additionalProperties: false, properties: {} },
15
- output: textOutput(),
16
- async execute(args, exec) {
17
- const out = [];
18
- const meId = env.selfId(exec);
19
- out.push("会话 " + (meId || "?"));
20
- if (typeof env.version === "function") out.push("版本 " + env.version());
21
- out.push("调度器 " + (state.schedulerReady() ? "就绪" : "未就绪" + (state.schedulerError() ? ":" + state.schedulerError() : "(稍后重试或重启)")));
22
- if (typeof env.injectList === 'function') out.push('inject ' + env.injectList().join(' / '));
23
- if (typeof env.initErrors === 'function') {
24
- const errs = env.initErrors();
25
- out.push('初始化 ' + (errs.length ? '有 ' + errs.length + ' 处失败:' + errs.join(';') : '无错误'));
26
- }
27
- if (typeof env.probeServices === "function") {
28
- const svc = env.probeServices();
29
- out.push("服务 " + Object.keys(svc).map((k) => k + "=" + (svc[k] ? "可用" : "—")).join(" "));
30
- }
31
- if (typeof env.guardLast === "function") {
32
- const g = env.guardLast();
33
- out.push("唤醒判定 " + (g
34
- ? (g.allow ? "放行" : "拦截") + " · 依据 " + g.source + (g.title ? " · 标题 " + g.title : "") + " · 会话 " + g.sid
35
- : "本进程还没有 peer_send、peer_new 调用"));
36
- }
37
- if (typeof env.probeAgentScope === "function") {
38
- const a = env.probeAgentScope(exec);
39
- const show = ["compaction", "compactNow", "tokenMeter", "sessions", "llm", "agents"]
40
- .map((k) => k + "=" + (a[k] ? "可用" : "—")).join(" ");
41
- out.push("会话作用域 " + (a.ctx ? show : "无法获取 exec.agent.ctx"));
42
- }
43
- const st = typeof env.compactionState === "function"
44
- ? env.compactionState(exec)
45
- : (() => { const c = countCompactions(eventsOf(exec)); return { used: c.used, lock: c.lock, source: 'events' }; })();
46
- out.push("压缩 " + st.used + "/" + env.cfg.maxCompactions + " 次;锁 " + st.lock + ";来源 " + st.source);
47
- const pct = env.selfPct(meId);
48
- out.push("水位 " + (pct === null ? "读不到" : pct.toFixed(1) + "%") + (typeof env.staleNote === "function" ? env.staleNote(exec) : ""));
49
- const ws = env.resolveWs(exec);
50
- let wsOk = false;
51
- try { wsOk = fs.existsSync(path.join(ws, '_Control_Center')); } catch { wsOk = false; }
52
- if (typeof env.roleOf === "function") {
53
- try {
54
- const info = env.roleOf(exec);
55
- const ts = typeof env.toolsetOf === 'function' ? env.toolsetOf(info.role) : null;
56
- out.push("角色 " + info.role + (info.project ? "(项目 " + info.project + ")" : info.task ? "(任务 " + info.task + ")" : "") + " · 依据 " + info.source + (ts ? " · 可见工具 " + ts.allow.length + "/" + (ts.allow.length + ts.deny.length) : ""));
57
- if (typeof env.roleState === "function") {
58
- const rs = env.roleState();
59
- const cnt = typeof rs.count === "number" ? " · 已装配 " + rs.count + " 个会话" : "";
60
- out.push("角色装配 " + (rs.mode || "未知") + cnt + (rs.last ? " · 最近:" + rs.last.sid + " → " + rs.last.role + "(" + rs.last.at + ")" : ""));
61
- }
62
- } catch (e) {
63
- out.push("角色 判定失败:" + (e && e.message ? e.message : e));
64
- }
65
- }
66
- out.push("工作区 " + ws + (wsOk
67
- ? " 已接入"
68
- : " — 未接入(未找到 _Control_Center):让用户走设置页「首次安装」,或本会话直接 ho_init(parent=\"<目录>\", register=true)"));
69
- try {
70
- const mv = typeof env.moveInfo === "function" ? env.moveInfo() : null;
71
- if (mv) out.push("迁移 " + mv.phase + " · " + mv.src + (mv.dst ? " → " + mv.dst : ""));
72
- } catch { /* ignore */ }
73
- out.push("配置 warn=" + env.cfg.warnRatio + " handover=" + env.cfg.handoverRatio + " hard=" + env.cfg.hardRatio + " max=" + env.cfg.maxCompactions);
74
- if (typeof env.handoverFor === "function") {
75
- try {
76
- const hp = env.handoverFor(exec);
77
- out.push("交接路径 " + (hp && hp.path ? hp.path + "(" + hp.kind + ")" : "无 —— " + ((hp && hp.reason) || "角色未定")));
78
- } catch (e) {
79
- out.push("交接路径 解析失败:" + (e && e.message ? e.message : e));
80
- }
81
- } else {
82
- out.push("交接路径 " + (env.cfg.handoverPath || "(按角色解析)"));
83
- }
84
- return { text: out.join(NL) };
85
- },
86
- };
87
- }
@@ -1,41 +0,0 @@
1
- /** ho_drift —— 交接漂移体检(替代 handover_check.mjs)。 */
2
- import { textOutput } from '../lib/output.mjs';
3
- import { nameOf } from '../lib/names.mjs';
4
- import { NL } from '../lib/text.mjs';
5
- import { computeDrift, driftVerdict } from '../lib/drift.mjs';
6
-
7
- export function pmDriftTool(env) {
8
- return {
9
- name: nameOf('drift'),
10
- description:
11
- '交接漂移体检:Δ=当前轮−交接轮,超过容忍轮数(默认 3)需刷新交接。只读。',
12
- parameters: {
13
- type: 'object',
14
- additionalProperties: false,
15
- properties: {
16
- slack: { type: 'number', description: '容忍轮数(默认 3)' },
17
- all: { type: 'boolean', description: '列全部会话,而不只是每个项目的在任会话' },
18
- },
19
- },
20
- output: textOutput(),
21
- async execute(args, exec) {
22
- const ws = env.resolveWs(exec);
23
- const res = computeDrift({
24
- ws,
25
- cacheDir: env.cacheDir(),
26
- slack: typeof args.slack === 'number' ? args.slack : env.cfg.driftSlack,
27
- all: args.all === true,
28
- });
29
- const out = [];
30
- out.push('交接匹配检查(跟着交接走;冗余 ' + res.slack + ' 轮) 工作区根 ' + ws);
31
- out.push(' 项目 会话类型 会话 当前轮 交接轮 Δ 判定');
32
- for (const r of res.rows) {
33
- const cells = [r.project, r.kind, r.sid.slice(-8), String(r.turns), r.handoverTurn === null ? '?' : String(r.handoverTurn), r.delta === null ? '?' : String(r.delta), r.verdict];
34
- out.push(' ' + cells.join(' '));
35
- }
36
- if (!res.rows.length) out.push(' 读不到任何项目会话(Projects/ 下对应库不存在时属正常)');
37
- out.push(driftVerdict(res));
38
- return { text: out.join(NL) };
39
- },
40
- };
41
- }