@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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话管理的实现(原 ho_session_new / ho_prompt / ho_session_archive / ho_session_unarchive 的代码搬到这里)。
|
|
3
|
+
* 对外只经 ho_sessions(action=…) 暴露,少 4 份 schema 的固定开销。
|
|
4
|
+
*/
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { NL, readUtf8, isDir, isFile } from './text.mjs';
|
|
8
|
+
import { handoverPathFor, assertProjectName, assertTaskName } from './role.mjs';
|
|
9
|
+
import { readHandover } from './handover-io.mjs';
|
|
10
|
+
import { scanSessions } from './sessions.mjs';
|
|
11
|
+
import { workspaceRows } from './registry.mjs';
|
|
12
|
+
import { checkConsent } from './consent.mjs';
|
|
13
|
+
import { watermark } from './facts.mjs';
|
|
14
|
+
import { sendPrompt } from './prompt.mjs';
|
|
15
|
+
import { newSessionIn } from './session-create.mjs';
|
|
16
|
+
|
|
17
|
+
const FENCE = String.fromCharCode(96, 96, 96);
|
|
18
|
+
|
|
19
|
+
function normPath(p) {
|
|
20
|
+
return String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 在 ~/.dsh/storages/workspace.json 里按 id、标题、路径找 workspaceId。 */
|
|
24
|
+
function findWorkspaceId(key) {
|
|
25
|
+
const file = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
|
|
26
|
+
const data = JSON.parse(readUtf8(file));
|
|
27
|
+
const tables = (data && data.tables && data.tables.workspaces) || {};
|
|
28
|
+
const want = normPath(key);
|
|
29
|
+
for (const id of Object.keys(tables)) {
|
|
30
|
+
const w = tables[id] || {};
|
|
31
|
+
if (id === key) return id;
|
|
32
|
+
if (w.title === key) return id;
|
|
33
|
+
if (normPath(w.path) === want) return id;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 已注册工作区清单(插件服务优先,退化读 workspace.json):新建会话时用来列出可选项。 */
|
|
39
|
+
function listWorkspaces(env) {
|
|
40
|
+
try {
|
|
41
|
+
const reg = env && typeof env.registry === 'function' ? env.registry() : null;
|
|
42
|
+
const list = reg && typeof reg.list === 'function' ? (reg.list() || []) : [];
|
|
43
|
+
if (list.length) {
|
|
44
|
+
return list.map((w) => ({ id: String((w && w.id) || ''), title: String((w && w.title) || ''), path: String((w && w.path) || '') }));
|
|
45
|
+
}
|
|
46
|
+
} catch { /* 退回读文件 */ }
|
|
47
|
+
try {
|
|
48
|
+
return workspaceRows().map((r) => ({ id: r.id, title: r.title, path: r.path }));
|
|
49
|
+
} catch { return []; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/** 取某个 ## 小节之后第一个代码块的内容(找不到返回 null)。 */
|
|
54
|
+
function blockAfter(text, heading) {
|
|
55
|
+
const lines = text.split(NL);
|
|
56
|
+
let start = -1;
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
if (lines[i].indexOf('## ') === 0 && lines[i].indexOf(heading) !== -1) { start = i; break; }
|
|
59
|
+
}
|
|
60
|
+
if (start === -1) return null;
|
|
61
|
+
let opened = false;
|
|
62
|
+
const buf = [];
|
|
63
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
64
|
+
const t = lines[i].trim();
|
|
65
|
+
if (!opened && lines[i].indexOf('## ') === 0) break;
|
|
66
|
+
if (t === FENCE + 'text' || t === FENCE) {
|
|
67
|
+
if (!opened) { opened = true; continue; }
|
|
68
|
+
return buf.join(NL);
|
|
69
|
+
}
|
|
70
|
+
if (opened) buf.push(lines[i]);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fill(tpl, map) {
|
|
76
|
+
let t = tpl;
|
|
77
|
+
for (const k of Object.keys(map)) t = t.split(k).join(String(map[k]));
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- action: new
|
|
82
|
+
|
|
83
|
+
export async function sessionNew(env, args, exec) {
|
|
84
|
+
const ws = env.resolveWs(exec);
|
|
85
|
+
const controller = env.controller();
|
|
86
|
+
// 换任(新建接班会话)先问用户。
|
|
87
|
+
if (args.confirm !== true) throw new Error('新建会话(换任)需要用户确认:先向用户说明要建接班会话并得到同意,再传 confirm=true');
|
|
88
|
+
if (!controller) throw new Error('ctx.sessionController 不可用');
|
|
89
|
+
// 名字要落成目录:先校验,别等会话建完、写角色表时才失败(那样会留下一个没有角色的会话)。
|
|
90
|
+
if (args.project) assertProjectName(String(args.project));
|
|
91
|
+
if (args.task) assertTaskName(String(args.task));
|
|
92
|
+
const out = [];
|
|
93
|
+
const title = String(args.title || '');
|
|
94
|
+
if (args.project && title.indexOf('全局') === -1 && args.force !== true && env.cfg.reuseCheck !== false) {
|
|
95
|
+
const scan = scanSessions({
|
|
96
|
+
dir: env.cacheDir(),
|
|
97
|
+
meId: env.selfId(exec),
|
|
98
|
+
warn: watermark(env.cfg).warnValue,
|
|
99
|
+
silentHours: env.cfg.sessionScanHours,
|
|
100
|
+
});
|
|
101
|
+
const live = scan.rows.filter((r) => r.project === args.project && r.kind === '平级' && r.pct !== null);
|
|
102
|
+
if (live.length) {
|
|
103
|
+
const best = live.slice().sort((a, b) => b.mtime - a.mtime)[0];
|
|
104
|
+
if (best.pct < watermark(env.cfg).warnValue) {
|
|
105
|
+
throw new Error('拒绝新建:项目「' + args.project + '」在任会话 ' + best.sid + ' 水位 ' + best.pct.toFixed(1) + '%(<' + watermark(env.cfg).warn + '),按 Charter §7.2.1 应复用现有会话;确实要建请加 force=true');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 角色先算好:新建会话按它登记并当场装配。
|
|
110
|
+
let role = args.role || (args.project ? 'project' : args.kind || '');
|
|
111
|
+
if (role === 'session') role = '';
|
|
112
|
+
const project = args.project || '';
|
|
113
|
+
const task = args.task || '';
|
|
114
|
+
const finalRole = title.indexOf('全局') !== -1 ? 'global'
|
|
115
|
+
: (role === 'project' || role === 'misc' ? role : (project ? 'project' : ''));
|
|
116
|
+
|
|
117
|
+
// ---- 工作区解析:新建会话必须落到明确的工作区,不允许静默落进调用者所在的工作区。
|
|
118
|
+
const rows = listWorkspaces(env);
|
|
119
|
+
const choices = rows.length
|
|
120
|
+
? rows.map((r) => ' ' + r.id + ' ' + (r.title || '-') + ' ' + (r.path || '-')).join(NL)
|
|
121
|
+
: ' (还没有注册任何工作区)';
|
|
122
|
+
const pickWorkspace = (key) => {
|
|
123
|
+
const wid = findWorkspaceId(key);
|
|
124
|
+
if (!wid) {
|
|
125
|
+
throw new Error('找不到工作区:' + key + NL + '已注册的工作区:' + NL + choices + NL + '传 id、标题或路径都行;要先建工作区,用 ho_workspace(action="create", path="<目录>")。');
|
|
126
|
+
}
|
|
127
|
+
return wid;
|
|
128
|
+
};
|
|
129
|
+
const wantGlobalWs = title.indexOf('全局') !== -1 || args.role === 'global' || args.kind === 'global';
|
|
130
|
+
let workspaceId = '';
|
|
131
|
+
if (args.workspace) {
|
|
132
|
+
workspaceId = pickWorkspace(args.workspace);
|
|
133
|
+
} else if (args.cwd) {
|
|
134
|
+
if (!isDir(args.cwd)) throw new Error('cwd 不存在:' + args.cwd);
|
|
135
|
+
// cwd 若正好是已注册工作区的根,必须按 workspaceId 建;否则会话会落进「未分组」。
|
|
136
|
+
let wid = null;
|
|
137
|
+
try { wid = findWorkspaceId(args.cwd); } catch { wid = null; }
|
|
138
|
+
if (!wid) {
|
|
139
|
+
throw new Error('cwd 不在任何已注册工作区里:' + args.cwd + NL + '已注册的工作区:' + NL + choices
|
|
140
|
+
+ NL + '要么把 cwd 换成某个工作区的根,要么先注册它:ho_workspace(action="create", path="' + args.cwd + '")。');
|
|
141
|
+
}
|
|
142
|
+
workspaceId = wid;
|
|
143
|
+
} else if (args.project) {
|
|
144
|
+
// 项目会话:按项目名找同名工作区,或路径落在 Projects/<项目>、_Project_Meta/<项目> 下的工作区。
|
|
145
|
+
const want = String(args.project).toLowerCase();
|
|
146
|
+
const hit = rows.find((r) => {
|
|
147
|
+
if (String(r.title || '') === String(args.project)) return true;
|
|
148
|
+
const p = normPath(r.path);
|
|
149
|
+
return p.indexOf('/projects/' + want) !== -1 || p.indexOf('/_project_meta/' + want) !== -1;
|
|
150
|
+
});
|
|
151
|
+
if (!hit) {
|
|
152
|
+
throw new Error('项目「' + args.project + '」还没有对应的工作区。' + NL + '已注册的工作区:' + NL + choices
|
|
153
|
+
+ NL + '做法一:ho_workspace(action="create", path="<该项目目录>", title="' + args.project + '") 后再建会话;'
|
|
154
|
+
+ NL + '做法二:把 workspace 传成上面某个工作区。');
|
|
155
|
+
}
|
|
156
|
+
workspaceId = String(hit.id);
|
|
157
|
+
} else if (wantGlobalWs) {
|
|
158
|
+
workspaceId = pickWorkspace(ws);
|
|
159
|
+
} else {
|
|
160
|
+
throw new Error('新建会话必须指明工作区:传 workspace(id、标题或路径),或 cwd(某个已注册工作区的根)。' + NL
|
|
161
|
+
+ '已注册的工作区:' + NL + choices + NL
|
|
162
|
+
+ '全局会话落在标题含「全局」的工作区;项目会话同时传 project 与该项目的工作区;杂项会话传该项目外的独立工作区。');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const made = await newSessionIn(env, { workspaceId, title, role: finalRole, project, task });
|
|
166
|
+
const sessionId = made.sessionId || '';
|
|
167
|
+
if (!sessionId) throw new Error(made.error || 'create 没有返回 sessionId');
|
|
168
|
+
out.push('已建会话 ' + sessionId + (finalRole ? ',角色 ' + finalRole + (project ? '(' + project + ')' : '') : ''));
|
|
169
|
+
const meId = env.selfId(exec) || '(未知)';
|
|
170
|
+
// 交接路径按**接任者自身的角色**取用:项目换任读取项目交接,不再退回全局交接。
|
|
171
|
+
const hp = handoverPathFor(ws, finalRole === 'project' && project ? { role: 'project', project } : { role: 'global' });
|
|
172
|
+
const handover = env.cfg.handoverPath || (hp.path || '');
|
|
173
|
+
const text = args.message || [
|
|
174
|
+
'读一下 ' + handover + ' 。',
|
|
175
|
+
'读毕先向用户报告,不得自行开展作业:其一,当前停在哪一步;其二,交接列有哪些待办;其三,你建议先做哪一条。待用户指派后再动手;交接中无待办者,如实说明即可,不得自行编造任务。',
|
|
176
|
+
'本会话 id:' + sessionId,
|
|
177
|
+
(finalRole === 'project' ? '现任全局 id:' : '上任全局 id:') + meId,
|
|
178
|
+
'默认沉默:仅在三种时机各发一条 peer_send 至现任全局——≥' + watermark(env.cfg).warn + ' 且已写交接、需用户决定、发现全局口径有误;任务完成不向全局报告(任务属本会话内部事务)。会话之间可直接 peer_send,不必经全局转述;不得每轮回执,亦不得发送「收到」「已完成」一类确认(全局只看文件末态)。',
|
|
179
|
+
].join(NL);
|
|
180
|
+
const r = await sendPrompt(env.controller(), sessionId, text, 'queue');
|
|
181
|
+
out.push('已投启动提示词 accepted=' + String(r && r.accepted) + '(' + text.length + ' 字)');
|
|
182
|
+
return out.join(NL);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------- action: prompt
|
|
186
|
+
|
|
187
|
+
export function sessionPrompt(env, args, exec) {
|
|
188
|
+
const ws = env.resolveWs(exec);
|
|
189
|
+
const file = path.join(ws, '_Control_Center', 'templates', 'Prompt_Templates.md');
|
|
190
|
+
// 工作区没有模板就退回插件自带那份(dist/templates/Prompt_Templates.md),功能不打折。
|
|
191
|
+
let tpl = '';
|
|
192
|
+
let fromBundle = false;
|
|
193
|
+
try {
|
|
194
|
+
if (!isFile(file)) throw new Error('missing');
|
|
195
|
+
tpl = readUtf8(file);
|
|
196
|
+
} catch {
|
|
197
|
+
tpl = readUtf8(new URL('../templates/Prompt_Templates.md', import.meta.url));
|
|
198
|
+
fromBundle = true;
|
|
199
|
+
}
|
|
200
|
+
if (fromBundle) {
|
|
201
|
+
tpl = '> (插件内置模板 · 要本地化就写到 ' + file + ')' + NL + NL + tpl;
|
|
202
|
+
}
|
|
203
|
+
const skeleton = blockAfter(tpl, '1 共用骨架');
|
|
204
|
+
if (!skeleton) throw new Error('模板缺 §1 共用骨架,拒绝渲染:' + file);
|
|
205
|
+
const kind = args.kind || 'project';
|
|
206
|
+
const project = args.project || '';
|
|
207
|
+
const task = args.task || '';
|
|
208
|
+
const target = handoverPathFor(ws, kind === 'project'
|
|
209
|
+
? { role: 'project', project }
|
|
210
|
+
: kind === 'misc' && task ? { role: 'misc', task } : { role: 'global' });
|
|
211
|
+
const firstPath = kind === 'global'
|
|
212
|
+
? (env.cfg.handoverPath || target.path)
|
|
213
|
+
: kind === 'project' ? target.path
|
|
214
|
+
: kind === 'misc' ? (target.path || path.join(ws, '_Control_Center', 'rules', 'Misc_Task_Standard.md'))
|
|
215
|
+
: path.join(ws, '_Control_Center', 'rules', 'Migration_Rules.md');
|
|
216
|
+
if (!firstPath) throw new Error('kind=' + kind + ' 需要 project、task 参数才能算交接路径');
|
|
217
|
+
const numMatch = String(args.title || '').match(/([0-9]+)/);
|
|
218
|
+
const ordinal = numMatch ? numMatch[1] : 'N';
|
|
219
|
+
const roleText = kind === 'global'
|
|
220
|
+
? '第 ' + ordinal + ' 任全局(管理会话)'
|
|
221
|
+
: kind === 'project'
|
|
222
|
+
? '「' + project + '」项目的执行对话'
|
|
223
|
+
: kind === 'misc' ? '杂项任务会话' : '新机器接手会话';
|
|
224
|
+
const scopeText = kind === 'project'
|
|
225
|
+
? '库根 Projects/' + project + ' 与 _Project_Meta/' + project
|
|
226
|
+
: '工作区根 ' + ws;
|
|
227
|
+
const filled = fill(skeleton, {
|
|
228
|
+
'<角色>': roleText,
|
|
229
|
+
'<绝对路径>': firstPath,
|
|
230
|
+
'<本会话 id>': args.sessionId || '(建完后填)',
|
|
231
|
+
'<全局 id>': args.globalSessionId || '(现任全局 id)',
|
|
232
|
+
'<范围>': scopeText,
|
|
233
|
+
});
|
|
234
|
+
const stripHint = (l) => {
|
|
235
|
+
let i = l.indexOf('|');
|
|
236
|
+
while (i !== -1) {
|
|
237
|
+
if (l[i + 1] === ' ' || l[i + 1] === String.fromCharCode(0x3000)) return l.slice(0, i).replace(/\s+$/, '');
|
|
238
|
+
i = l.indexOf('|', i + 1);
|
|
239
|
+
}
|
|
240
|
+
return l;
|
|
241
|
+
};
|
|
242
|
+
const body = filled.split(NL).map(stripHint).join(NL);
|
|
243
|
+
const extraLines = [];
|
|
244
|
+
if (kind === 'project' && project) {
|
|
245
|
+
extraLines.push('库根:Projects/' + project + '/ ;元数据:_Project_Meta/' + project + '/ ;快照:_Temp/' + project + '/Snapshots/');
|
|
246
|
+
extraLines.push('开始作业前应先读:_Project_Meta/' + project + '/Handover.md,然后读库根 SKILL.md;修改文档时再读 _Control_Center/rules/ 对应规范。');
|
|
247
|
+
extraLines.push('换任由全局执行:进入预警区后只写交接、通知全局并提醒用户,不得自行建立会话。');
|
|
248
|
+
}
|
|
249
|
+
if (kind === 'misc') extraLines.push('标准见 rules/Misc_Task_Standard.md;换任可自行以 peer_new 落到 Misc。');
|
|
250
|
+
if (kind === 'migration') extraLines.push('迁移口径见 rules/Migration_Rules.md;应先通过顶层闸门再开始作业。');
|
|
251
|
+
if (args.extra) extraLines.push(String(args.extra));
|
|
252
|
+
return [body].concat(extraLines.length ? ['', ...extraLines] : []).join(NL);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------- action: archive / unarchive
|
|
256
|
+
|
|
257
|
+
export async function sessionArchive(env, args, exec) {
|
|
258
|
+
const reg = env.registry();
|
|
259
|
+
if (!reg || typeof reg.archiveSession !== 'function') {
|
|
260
|
+
throw new Error('workspaceRegistry.archiveSession 不可用(先跑 ho_diag 看服务可见性)');
|
|
261
|
+
}
|
|
262
|
+
const sid = String(args.session_id || '').trim();
|
|
263
|
+
if (!sid) throw new Error('必须给 session_id');
|
|
264
|
+
const me = env.selfId(exec);
|
|
265
|
+
if (me && sid === me) throw new Error('不能归档当前会话(会把自己一起停掉)');
|
|
266
|
+
const out = [];
|
|
267
|
+
try {
|
|
268
|
+
await reg.archiveSession(sid, args.force === true ? { stopActivity: true } : {});
|
|
269
|
+
} catch (e) {
|
|
270
|
+
const msg = String(e && e.message ? e.message : e);
|
|
271
|
+
if (/active/i.test(msg)) throw new Error('该会话仍在运行:' + msg + '——应先令其停止,或加 force=true(将请求终止其工作)');
|
|
272
|
+
throw new Error('归档失败:' + msg);
|
|
273
|
+
}
|
|
274
|
+
out.push('已归档 ' + sid + (args.force === true ? '(已请求停止其工作)' : ''));
|
|
275
|
+
try {
|
|
276
|
+
const archived = reg.archivedSessionIds;
|
|
277
|
+
if (archived && typeof archived.length === 'number') out.push('归档集合现有 ' + archived.length + ' 个');
|
|
278
|
+
} catch { /* ignore */ }
|
|
279
|
+
out.push('提醒:归档会话会被 archived-session-gate 拦住、无法唤醒;要用它请先 action="unarchive"。');
|
|
280
|
+
return out.join(NL);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function sessionUnarchive(env, args) {
|
|
284
|
+
const reg = env.registry();
|
|
285
|
+
if (!reg || typeof reg.unarchiveSession !== 'function') {
|
|
286
|
+
throw new Error('workspaceRegistry.unarchiveSession 不可用(先跑 ho_diag 看服务可见性)');
|
|
287
|
+
}
|
|
288
|
+
const sid = String(args.session_id || '').trim();
|
|
289
|
+
if (!sid) throw new Error('必须给 session_id');
|
|
290
|
+
try { await reg.unarchiveSession(sid); } catch (e) {
|
|
291
|
+
throw new Error('解归档失败:' + String(e && e.message ? e.message : e));
|
|
292
|
+
}
|
|
293
|
+
return '已解归档 ' + sid + NL + '它回到原工作区位置,可以再次唤醒了。';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------- action: order
|
|
297
|
+
|
|
298
|
+
/** 交接流程的标准指令(全局下给某个会话;路径按它自己的角色解析,不借调用者上下文)。 */
|
|
299
|
+
function orderHandoverText(env, sid, extra) {
|
|
300
|
+
const hp = env.handoverForSid(sid);
|
|
301
|
+
const info = env.infoOfSid(sid);
|
|
302
|
+
const lines = [];
|
|
303
|
+
lines.push("【全局指令:执行交接流程】将当前工作写成交接,写毕即停止操作,等待换任。本轮不再进行项目作业。");
|
|
304
|
+
lines.push("");
|
|
305
|
+
lines.push("1) 目标文件:" + (hp && hp.path ? hp.path : "(角色未定,须先由全局设定角色)"));
|
|
306
|
+
lines.push(" 直接 ho_handover(action=\"write\", text=...)——不要传 path,路径已按你的角色(" + (info && info.role ? info.role : "unknown") + ")解析好。");
|
|
307
|
+
lines.push("2) 必写:① 现在在做什么、到哪一步(版本、产物、是否通过)② 环境与路径(版本号、安装位置、用的下载源)③ 下一步清单:按顺序、每条可直接执行 ④ 已完成待验收、要求了没落地 ⑤ 已知坑与踩过的错。");
|
|
308
|
+
lines.push("3) 机密只写位置不写值(密码、token、密钥一律写「在哪个文件」)。");
|
|
309
|
+
lines.push("4) 写完只回一条 peer_send 给全局:`交接已写 <绝对路径>` + 一句话状态;不要复述全文。");
|
|
310
|
+
lines.push("5) 不要压缩上下文、不要建会话(建会话是全局的权限)。");
|
|
311
|
+
if (extra) lines.push("6) 全局附加:" + extra);
|
|
312
|
+
return lines.join(NL);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function sessionOrder(env, args) {
|
|
316
|
+
const sid = String(args.session_id || "").trim();
|
|
317
|
+
const order = String(args.order || "").trim();
|
|
318
|
+
if (!sid) throw new Error("order 需要 session_id");
|
|
319
|
+
if (order !== "handover" && order !== "compact") throw new Error("order 只能是 handover 或 compact");
|
|
320
|
+
// 给别的会话下「交接、压缩」指令,也要先问用户。
|
|
321
|
+
if (args.confirm !== true) throw new Error("下交接、压缩指令需要用户确认:先说明会唤醒目标会话并让它交接或压缩,得到同意后传 confirm=true");
|
|
322
|
+
const extra = typeof args.extra === "string" ? args.extra.trim() : "";
|
|
323
|
+
const hp = env.handoverForSid(sid);
|
|
324
|
+
const info = env.infoOfSid(sid);
|
|
325
|
+
const out = [];
|
|
326
|
+
out.push("目标 " + sid + " · 角色 " + ((info && info.role) || "unknown") + " · 交接 " + ((hp && hp.path) || "(未解析)"));
|
|
327
|
+
if (!hp || !hp.path) {
|
|
328
|
+
out.push("没有交接路径:先给它设置角色(ho_sessions(action=\"role\")),否则它会退回全局交接。");
|
|
329
|
+
return out.join(NL);
|
|
330
|
+
}
|
|
331
|
+
if (order === "handover") {
|
|
332
|
+
const text = orderHandoverText(env, sid, extra);
|
|
333
|
+
const r = await sendPrompt(env.controller(), sid, text, "queue");
|
|
334
|
+
out.push("已投交接指令 accepted=" + String(r && r.accepted) + "(会唤醒它一轮)");
|
|
335
|
+
out.push("等它回一条 `交接已写 <路径>` 之后,再用 ho_sessions(action=\"new\") 建接任会话。");
|
|
336
|
+
return out.join(NL);
|
|
337
|
+
}
|
|
338
|
+
// order === compact
|
|
339
|
+
const agent = typeof env.agentOfSid === "function" ? env.agentOfSid(sid) : null;
|
|
340
|
+
if (!agent) {
|
|
341
|
+
out.push("拒绝排压缩:目标会话不在内存(已休眠)。先让它开一轮(ho_sessions(action=\"order\", order=\"handover\"))再压。");
|
|
342
|
+
return out.join(NL);
|
|
343
|
+
}
|
|
344
|
+
const st = typeof env.compactionStateOfSid === "function" ? env.compactionStateOfSid(sid) : null;
|
|
345
|
+
if (st && st.lock === "open") {
|
|
346
|
+
out.push("拒绝排压缩:它的日志里有未闭合的压缩锁(compaction/start 无配对 end)。先等它结束后复查。");
|
|
347
|
+
return out.join(NL);
|
|
348
|
+
}
|
|
349
|
+
const cur = readHandover(hp.path);
|
|
350
|
+
if (cur.text === null || String(cur.text).trim() === "") {
|
|
351
|
+
out.push("拒绝排压缩:磁盘上没有合格交接 " + hp.path + "。压缩会丢历史 —— 先 order=handover。");
|
|
352
|
+
return out.join(NL);
|
|
353
|
+
}
|
|
354
|
+
const used = st && typeof st.used === "number" ? st.used : 0;
|
|
355
|
+
const max = (env.cfg && env.cfg.maxCompactions) || 3;
|
|
356
|
+
const n = used + 1;
|
|
357
|
+
if (n > max) {
|
|
358
|
+
out.push("已压满 " + used + "/" + max + ":不再压缩。应执行 ho_sessions(action=\"new\") 换任;新会话读毕交接会先向你报告,等待指派。");
|
|
359
|
+
return out.join(NL);
|
|
360
|
+
}
|
|
361
|
+
const recentSec = st && st.lastSummaryMs ? (Date.now() - st.lastSummaryMs) / 1000 : null;
|
|
362
|
+
if (recentSec !== null && recentSec < 120 && args.force !== true) {
|
|
363
|
+
out.push("拒绝排压缩:" + Math.round(recentSec) + " 秒前它刚压过一次。要再压加 force=true。");
|
|
364
|
+
return out.join(NL);
|
|
365
|
+
}
|
|
366
|
+
// 严格闸门:代排也要用户点过授权,AI 不能自行批准。
|
|
367
|
+
const cs = checkConsent(sid);
|
|
368
|
+
if (!cs.ok) {
|
|
369
|
+
out.push("拒绝排压缩:" + cs.reason + "。");
|
|
370
|
+
out.push("请用户到设置页的 dsh-handover 中,点「压缩授权」的「允许该会话压缩一次」(5 分钟内有效),再重下这条指令。");
|
|
371
|
+
return out.join(NL);
|
|
372
|
+
}
|
|
373
|
+
const resume = args.resume === "continue" ? "continue" : "wait";
|
|
374
|
+
env.schedule(sid, n, { resume });
|
|
375
|
+
out.push("用户已授权(" + (cs.atText || '') + " 点击,剩 " + Math.round((cs.leftMs || 0) / 1000) + " 秒)。");
|
|
376
|
+
out.push("已登记压缩(它自己第 " + n + "/" + max + " 次):等它空闲后执行。");
|
|
377
|
+
if (resume === "wait") {
|
|
378
|
+
out.push("去向:wait(缺省)—— 压缩后不再唤醒它,等用户派新任务,或由新会话接手。");
|
|
379
|
+
} else {
|
|
380
|
+
out.push("去向:continue —— 压缩后它会自动收到**它自己的**续跑消息(带它自己的交接路径)。");
|
|
381
|
+
}
|
|
382
|
+
out.push("压缩走的是插件调度器(compactNow 只允许空闲时执行),不是给它发消息让它自己压。");
|
|
383
|
+
out.push("若它正在跑,调度器会等它空闲(上限 " + Math.round(((env.cfg && env.cfg.waitIdleMs) || 180000) / 1000) + " 秒)。");
|
|
384
|
+
return out.join(NL);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------- action: place
|
|
388
|
+
|
|
389
|
+
/** 把已存在的会话移进某个工作区(等价图形界面的「移动到工作区」)。
|
|
390
|
+
* 前提:会话 header 里的 cwd 必须正是该工作区根,工作区服务会自己校验。
|
|
391
|
+
*/
|
|
392
|
+
export async function sessionPlace(env, args) {
|
|
393
|
+
const sid = String(args.session_id || "").trim();
|
|
394
|
+
const key = String(args.workspace || "").trim();
|
|
395
|
+
if (!sid) throw new Error("place 需要 session_id");
|
|
396
|
+
if (!key) throw new Error("place 需要 workspace(id、标题、路径都行)");
|
|
397
|
+
const reg = env.registry();
|
|
398
|
+
if (!reg || typeof reg.get !== "function") throw new Error("workspaceRegistry 不可用(先跑 ho_diag)");
|
|
399
|
+
const wid = findWorkspaceId(key);
|
|
400
|
+
if (!wid) throw new Error("找不到工作区:" + key);
|
|
401
|
+
const w = reg.get(wid);
|
|
402
|
+
if (!w || typeof w.attachSession !== "function") throw new Error("工作区实体不可用:" + wid);
|
|
403
|
+
await w.attachSession(sid);
|
|
404
|
+
const out = [];
|
|
405
|
+
out.push("已把 " + sid + " 移入工作区「" + (w.title || key) + "」" + (w.path ? "(" + w.path + ")" : ""));
|
|
406
|
+
out.push("若报 cwd 不匹配:它的 cwd 与该工作区根不一致 —— 用 ho_sessions(action=list) 看 cwd。");
|
|
407
|
+
return out.join(NL);
|
|
408
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** 会话清点:只读投影缓存,不激活任何会话、不消耗 token。 */
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readJsonDir, pick, isSafeName } from './text.mjs';
|
|
5
|
+
|
|
6
|
+
export function projCacheDir() {
|
|
7
|
+
return path.join(os.homedir(), '.dsh', 'storages', 'session_projcache', 'sessions');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function pressurePct(data) {
|
|
11
|
+
const used = pick(data, 'record', 'rows', 'contextPressure', 'val', 'pressureTokens');
|
|
12
|
+
const win = pick(data, 'record', 'rows', 'contextPressure', 'val', 'contextWindow');
|
|
13
|
+
if (!used || !win) return null;
|
|
14
|
+
return (used / win) * 100;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isSubagent(data) {
|
|
18
|
+
return pick(data, 'record', 'rows', 'subagent', 'val', 'identity') !== null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isBlank(data) {
|
|
22
|
+
return pick(data, 'record', 'rows', 'sessionListMetadata', 'val', 'blank') === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isBusy(data) {
|
|
26
|
+
return pick(data, 'record', 'rows', 'sessionStats', 'val', 'openStep') !== null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cwdOf(data) {
|
|
30
|
+
return pick(data, 'record', 'identity', 'cwd');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 名字要当目录名用:统一走 text.mjs 的跨平台校验。 */
|
|
34
|
+
function isPlausible(name) {
|
|
35
|
+
return isSafeName(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 返回 { project, real }:优先看 cwd 里的 Projects/<名>/,退回首条提示里的 Projects/<名>/ 或 「<名>」项目。 */
|
|
39
|
+
export function projectOf(data) {
|
|
40
|
+
// 先认路径:首条提示是自然语言的会话解析不出项目名,会被整行过滤,在任判定就可能落到前任身上。
|
|
41
|
+
// cwd 由宿主写入,最可靠。
|
|
42
|
+
const raw = String(cwdOf(data) || '').replace(/\\/g, '/');
|
|
43
|
+
const ci = raw.toLowerCase().indexOf('/projects/');
|
|
44
|
+
if (ci !== -1) {
|
|
45
|
+
const name = raw.slice(ci + '/projects/'.length).split('/')[0];
|
|
46
|
+
if (isPlausible(name)) return { project: name, real: true };
|
|
47
|
+
}
|
|
48
|
+
const text = pick(data, 'record', 'rows', 'titleInput', 'val', 'first', 'text') || '';
|
|
49
|
+
const i = text.indexOf('Projects/');
|
|
50
|
+
if (i !== -1) {
|
|
51
|
+
const rest = text.slice(i + 'Projects/'.length);
|
|
52
|
+
let name = "";
|
|
53
|
+
let bad = false;
|
|
54
|
+
for (const ch of rest) {
|
|
55
|
+
if (ch === '/') break;
|
|
56
|
+
if (ch === " " || ch === String.fromCharCode(9) || ch === String.fromCharCode(10) || ch === String.fromCharCode(13)) { bad = true; break; }
|
|
57
|
+
name += ch;
|
|
58
|
+
}
|
|
59
|
+
// 首条提示常带 Markdown 通配写法(`Projects/**`),解析出的名字必须过名称校验才算项目。
|
|
60
|
+
if (!bad && isPlausible(name)) return { project: name, real: true };
|
|
61
|
+
}
|
|
62
|
+
const q = text.indexOf(String.fromCharCode(12300));
|
|
63
|
+
if (q !== -1) {
|
|
64
|
+
const rest = text.slice(q + 1);
|
|
65
|
+
const j = rest.indexOf(String.fromCharCode(12301) + '项目');
|
|
66
|
+
if (j > 0) {
|
|
67
|
+
const name = rest.slice(0, j);
|
|
68
|
+
if (isPlausible(name)) return { project: name, real: true };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const title = pick(data, 'record', 'rows', 'title', 'val');
|
|
72
|
+
return { project: (typeof title === 'string' && title.trim() !== '') ? title : '-', real: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 读本会话的 pressureTokens / contextWindow:返回 { used, win }(读不到为 null)。 */
|
|
76
|
+
export function readSelfPressure(dir, meId) {
|
|
77
|
+
if (!meId) return { used: null, win: null };
|
|
78
|
+
const target = meId.indexOf('session-') === 0 ? meId : 'session-' + meId;
|
|
79
|
+
for (const f of readJsonDir(dir || projCacheDir())) {
|
|
80
|
+
const stem = f.name.replace('.json', '');
|
|
81
|
+
if (stem !== target && stem !== meId) continue;
|
|
82
|
+
const used = pick(f.data, 'record', 'rows', 'contextPressure', 'val', 'pressureTokens');
|
|
83
|
+
const win = pick(f.data, 'record', 'rows', 'contextPressure', 'val', 'contextWindow');
|
|
84
|
+
return { used: used === null ? null : used, win: win === null ? null : win };
|
|
85
|
+
}
|
|
86
|
+
return { used: null, win: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 扫描:返回 { rows, cutoff, hot, unknown, notify, skip }。 */
|
|
90
|
+
export function scanSessions(opts) {
|
|
91
|
+
const dir = opts.dir || projCacheDir();
|
|
92
|
+
const warn = opts.warn === undefined ? 32 : opts.warn;
|
|
93
|
+
const silentHours = opts.silentHours === undefined ? 12 : opts.silentHours;
|
|
94
|
+
const nowMs = opts.nowMs || Date.now();
|
|
95
|
+
const meId = opts.meId || null;
|
|
96
|
+
const meCwd = opts.meCwd || null;
|
|
97
|
+
const projectsOnly = opts.projectsOnly !== false;
|
|
98
|
+
const cutoffMs = nowMs - silentHours * 3600 * 1000;
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const f of readJsonDir(dir)) {
|
|
101
|
+
const sid = f.name.replace('.json', '');
|
|
102
|
+
if (meId && (sid === meId || (sid.indexOf("session-") === 0 && sid.slice(8) === meId))) continue;
|
|
103
|
+
if (!f.data || isBlank(f.data)) continue;
|
|
104
|
+
const cwd = cwdOf(f.data);
|
|
105
|
+
const pj = projectOf(f.data);
|
|
106
|
+
const isProject = pj.real && isPlausible(pj.project);
|
|
107
|
+
if (projectsOnly && !isProject) continue;
|
|
108
|
+
if (meCwd && cwd && cwd !== meCwd && !isProject) continue;
|
|
109
|
+
rows.push({
|
|
110
|
+
sid,
|
|
111
|
+
kind: isSubagent(f.data) ? '子会话' : '平级',
|
|
112
|
+
project: pj.project,
|
|
113
|
+
pct: pressurePct(f.data),
|
|
114
|
+
state: isBusy(f.data) ? 'running' : 'idle',
|
|
115
|
+
mtime: f.mtimeMs,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
rows.sort((a, b) => {
|
|
119
|
+
if ((a.pct === null) !== (b.pct === null)) return a.pct === null ? 1 : -1;
|
|
120
|
+
if ((b.pct || 0) !== (a.pct || 0)) return (b.pct || 0) - (a.pct || 0);
|
|
121
|
+
return b.mtime - a.mtime;
|
|
122
|
+
});
|
|
123
|
+
const hot = [];
|
|
124
|
+
const unknown = [];
|
|
125
|
+
for (const r of rows) {
|
|
126
|
+
if (r.pct === null) unknown.push(r.sid);
|
|
127
|
+
else if (r.pct >= warn) hot.push(r.sid);
|
|
128
|
+
}
|
|
129
|
+
const pool = rows.filter((r) => r.kind === "平级" && r.project !== null && r.project !== "-");
|
|
130
|
+
const byProject = new Map();
|
|
131
|
+
for (const r of pool) {
|
|
132
|
+
if (!byProject.has(r.project)) byProject.set(r.project, []);
|
|
133
|
+
byProject.get(r.project).push(r);
|
|
134
|
+
}
|
|
135
|
+
const notify = [];
|
|
136
|
+
const skip = [];
|
|
137
|
+
for (const name of Array.from(byProject.keys()).sort()) {
|
|
138
|
+
const rs = byProject.get(name).slice().sort((a, b) => b.mtime - a.mtime);
|
|
139
|
+
if (rs[0].mtime >= cutoffMs) { notify.push(rs[0]); skip.push(...rs.slice(1)); }
|
|
140
|
+
else skip.push(...rs);
|
|
141
|
+
}
|
|
142
|
+
return { rows, cutoffMs, hot, unknown, notify, skip, warn, silentHours, nowMs, dir };
|
|
143
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** 7-Zip 封装:路径探测 + a/t/x 三个动作(参数用数组,不拼命令行字符串)。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const CANDIDATES = [
|
|
7
|
+
'C:\\Program Files\\7-Zip\\7z.exe',
|
|
8
|
+
'C:\\Program Files (x86)\\7-Zip\\7z.exe',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function run7z(bin, args, opts) {
|
|
12
|
+
const res = spawnSync(bin, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024, ...(opts || {}) });
|
|
13
|
+
const out = (res.stdout || '') + (res.stderr || '');
|
|
14
|
+
return { code: res.status === null ? -1 : res.status, out: out, error: res.error ? String(res.error.message) : null };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pack(bin, archive, sourceDir) {
|
|
18
|
+
return run7z(bin, ['a', '-t7z', '-mx=5', '-y', archive, sourceDir + path.sep + '*']);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function test(bin, archive) {
|
|
22
|
+
return run7z(bin, ['t', archive]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function extract(bin, archive, destDir) {
|
|
26
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
27
|
+
return run7z(bin, ['x', '-y', '-o' + destDir, archive]);
|
|
28
|
+
}
|
|
29
|
+
let probed = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 探测可用的 7-Zip:配置路径 → 常见安装位置 → PATH 里的 7z。
|
|
33
|
+
* 返回空串表示本机没有;调用方要能退回内置归档器(lib/archive.mjs),不能因此罢工。
|
|
34
|
+
*/
|
|
35
|
+
export function sevenZipBin(configured) {
|
|
36
|
+
if (probed !== null) return probed;
|
|
37
|
+
const cands = [];
|
|
38
|
+
if (configured) cands.push(configured);
|
|
39
|
+
for (const c of CANDIDATES) cands.push(c);
|
|
40
|
+
cands.push('7z');
|
|
41
|
+
for (const c of cands) {
|
|
42
|
+
if (c !== '7z' && !fs.existsSync(c)) continue;
|
|
43
|
+
const r = run7z(c, ['i']);
|
|
44
|
+
if (r.code === 0) { probed = c; return c; }
|
|
45
|
+
}
|
|
46
|
+
probed = '';
|
|
47
|
+
return '';
|
|
48
|
+
}
|