@mingmaple/dsh-handover 0.0.0-stage → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -2
  3. package/docs/migrate.md +61 -0
  4. package/docs/troubleshoot.md +48 -0
  5. package/package.json +55 -3
  6. package/scripts/blank-dsh.mjs +203 -0
  7. package/scripts/blank-smoke.mjs +820 -0
  8. package/scripts/build.mjs +58 -0
  9. package/scripts/check-docs.mjs +74 -0
  10. package/scripts/e2e-migration.mjs +127 -0
  11. package/scripts/lib/register.mjs +103 -0
  12. package/src/client.js +887 -0
  13. package/src/cordis.patch.yml +16 -0
  14. package/src/index.mjs +631 -0
  15. package/src/lib/absrefs.mjs +112 -0
  16. package/src/lib/api.mjs +234 -0
  17. package/src/lib/archive.mjs +198 -0
  18. package/src/lib/brief.mjs +74 -0
  19. package/src/lib/charter.mjs +28 -0
  20. package/src/lib/config.mjs +36 -0
  21. package/src/lib/consent.mjs +84 -0
  22. package/src/lib/drift.mjs +65 -0
  23. package/src/lib/events.mjs +89 -0
  24. package/src/lib/extra-prompt.mjs +41 -0
  25. package/src/lib/facts.mjs +70 -0
  26. package/src/lib/gates.mjs +148 -0
  27. package/src/lib/guide.mjs +99 -0
  28. package/src/lib/handover-io.mjs +49 -0
  29. package/src/lib/lint.mjs +152 -0
  30. package/src/lib/manual.mjs +124 -0
  31. package/src/lib/mover.mjs +663 -0
  32. package/src/lib/names.mjs +33 -0
  33. package/src/lib/output.mjs +11 -0
  34. package/src/lib/pack-ops.mjs +329 -0
  35. package/src/lib/peer.mjs +260 -0
  36. package/src/lib/phase.mjs +34 -0
  37. package/src/lib/profile.mjs +204 -0
  38. package/src/lib/registry.mjs +53 -0
  39. package/src/lib/retarget-helper.tmpl +46 -0
  40. package/src/lib/role.mjs +217 -0
  41. package/src/lib/rpc-carrier.mjs +65 -0
  42. package/src/lib/scaffold.mjs +118 -0
  43. package/src/lib/schedule.mjs +227 -0
  44. package/src/lib/session-ops.mjs +362 -0
  45. package/src/lib/sessions.mjs +142 -0
  46. package/src/lib/sevenzip.mjs +48 -0
  47. package/src/lib/status.mjs +171 -0
  48. package/src/lib/text.mjs +74 -0
  49. package/src/lib/webroute.mjs +130 -0
  50. package/src/lib/workspace.mjs +23 -0
  51. package/src/lib/zstd-log.mjs +65 -0
  52. package/src/selftest.mjs +84 -0
  53. package/src/templates/Changelog.skeleton.md +10 -0
  54. package/src/templates/Charter.skeleton.md +53 -0
  55. package/src/templates/Handover.global.skeleton.md +56 -0
  56. package/src/templates/Prompt_Templates.md +96 -0
  57. package/src/templates/Runtime_Manual.md +166 -0
  58. package/src/templates/rules/Handover_Rules.md +43 -0
  59. package/src/templates/rules/Migration_Rules.md +32 -0
  60. package/src/templates/rules/Misc_Task_Standard.md +23 -0
  61. package/src/templates/rules/Writing_Rules.md +24 -0
  62. package/src/tools/ho_brief.mjs +57 -0
  63. package/src/tools/ho_changelog.mjs +59 -0
  64. package/src/tools/ho_charter.mjs +89 -0
  65. package/src/tools/ho_checkpoint.mjs +125 -0
  66. package/src/tools/ho_diag.mjs +85 -0
  67. package/src/tools/ho_drift.mjs +41 -0
  68. package/src/tools/ho_gate.mjs +69 -0
  69. package/src/tools/ho_handover.mjs +83 -0
  70. package/src/tools/ho_init.mjs +82 -0
  71. package/src/tools/ho_pack.mjs +38 -0
  72. package/src/tools/ho_phase.mjs +37 -0
  73. package/src/tools/ho_profile.mjs +97 -0
  74. package/src/tools/ho_sessions.mjs +114 -0
  75. package/src/tools/ho_snapshot.mjs +103 -0
  76. package/src/tools/ho_tokens.mjs +57 -0
  77. package/src/tools/ho_workspace.mjs +213 -0
  78. package/src/tools/peer_list.mjs +37 -0
  79. package/src/tools/peer_new.mjs +75 -0
  80. package/src/tools/peer_peek.mjs +41 -0
  81. package/src/tools/peer_rename.mjs +31 -0
  82. package/src/tools/peer_send.mjs +52 -0
  83. package/src/tools/peer_status.mjs +46 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 首次安装向导:在用户粘贴的目录里建「全局根」,并把整套文件体系铺好。
3
+ * 只用 node:fs / node:path(跨平台,Windows / macOS / Linux / Android 的 PRoot Ubuntu 都一样)。
4
+ * 安全边界:只新建;目标已存在且非空就一个字都不改。已有文件绝不覆盖。
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ /** 插件自带的模板目录(dist/templates 或 src/templates)。 */
11
+ const TPL = new URL('../templates/', import.meta.url);
12
+
13
+ function template(name) {
14
+ return fs.readFileSync(new URL(name, TPL), 'utf8');
15
+ }
16
+
17
+ const DEFAULT_ROOT_NAME = '_Workspace';
18
+
19
+ /** 目录清单('/' 分隔,落地时换成本机分隔符)。 */
20
+ const DIRS = [
21
+ '_Control_Center/records',
22
+ '_Control_Center/rules',
23
+ '_Control_Center/templates',
24
+ '_Control_Center/tools',
25
+ '_Control_Center/user',
26
+ 'Projects',
27
+ '_Project_Meta',
28
+ '_Misc/tasks',
29
+ '_Archives/_Global/Backups',
30
+ '_Temp',
31
+ ];
32
+
33
+ const FILES = [
34
+ ['_Control_Center/records/Global_Handover.md', 'Handover.global.skeleton.md'],
35
+ ['_Control_Center/records/Global_Changelog.md', 'Changelog.skeleton.md'],
36
+ ['_Control_Center/rules/Workspace_Charter.md', 'Charter.skeleton.md'],
37
+ ['_Control_Center/templates/Prompt_Templates.md', 'Prompt_Templates.md'],
38
+ // 规则类:工作区没有这份时,ho_charter 会退回同名内置版本;这里先铺一份可改的默认口径。
39
+ ['_Control_Center/rules/Handover_Rules.md', 'rules/Handover_Rules.md'],
40
+ ['_Control_Center/rules/Migration_Rules.md', 'rules/Migration_Rules.md'],
41
+ ['_Control_Center/rules/Misc_Task_Standard.md', 'rules/Misc_Task_Standard.md'],
42
+ ['_Control_Center/user/Writing_Rules.md', 'rules/Writing_Rules.md'],
43
+ ];
44
+
45
+ /** 规整用户粘贴的路径:去掉引号/尾分隔符、展开 ~、解析成绝对路径。 */
46
+ function normalizeParent(input) {
47
+ let s = String(input || '').trim();
48
+ if (!s) return { error: '先粘贴一个目录(父目录,全局根会建在它里面)' };
49
+ s = s.replace(/^["']+|["']+$/g, '').trim();
50
+ if (!s) return { error: '先粘贴一个目录' };
51
+ if (s === '~') s = os.homedir();
52
+ else if (s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(2));
53
+ const abs = path.resolve(s);
54
+ let st = null;
55
+ try { st = fs.statSync(abs); } catch { st = null; }
56
+ if (st && !st.isDirectory()) return { error: '这不是一个目录:' + abs, path: abs };
57
+ // 目标父目录不存在就建:允许直接指定尚不存在的路径。
58
+ if (!st) {
59
+ try { fs.mkdirSync(abs, { recursive: true }); }
60
+ catch (e) { return { error: '建不出这个目录:' + abs + '(' + String(e && e.message ? e.message : e) + ')', path: abs }; }
61
+ }
62
+ return { parent: abs, createdParent: !st };
63
+ }
64
+
65
+ /**
66
+ * 建全局根。
67
+ * @param parentInput 用户在设置页粘贴的路径
68
+ * @param opts {{ name?: string }} 根目录名,默认 _Workspace
69
+ * @returns {{ ok: boolean, root?: string, created?: string[], written?: string[], skipped?: string[], error?: string }}
70
+ */
71
+ export function createGlobalWorkspace(parentInput, opts) {
72
+ const t = normalizeParent(parentInput);
73
+ if (t.error) return { ok: false, error: t.error };
74
+
75
+ const name = (opts && typeof opts.name === 'string' && opts.name.trim()) || DEFAULT_ROOT_NAME;
76
+ if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1 || name === '.' || name === '..') {
77
+ return { ok: false, error: '根目录名不能带路径分隔符:' + name };
78
+ }
79
+ const sameName = path.basename(t.parent).toLowerCase() === name.toLowerCase();
80
+ const root = sameName ? t.parent : path.join(t.parent, name);
81
+
82
+ let existed = false;
83
+ if (fs.existsSync(root)) {
84
+ let names = null;
85
+ try { names = fs.readdirSync(root); } catch { /* 读不了当作非空 */ }
86
+ if (names === null || names.length > 0) {
87
+ return { ok: false, root, error: '目标已存在且非空,未做任何改动:' + root };
88
+ }
89
+ existed = true;
90
+ }
91
+
92
+ const created = [];
93
+ try {
94
+ fs.mkdirSync(root, { recursive: true });
95
+ for (const d of DIRS) {
96
+ const p = path.join(root, d.split('/').join(path.sep));
97
+ fs.mkdirSync(p, { recursive: true });
98
+ created.push(d);
99
+ }
100
+ } catch (e) {
101
+ return { ok: false, root, error: '建目录失败:' + (e && e.message ? e.message : e) };
102
+ }
103
+
104
+ const written = [];
105
+ const skipped = [];
106
+ for (const [rel, tpl] of FILES) {
107
+ const dst = path.join(root, rel.split('/').join(path.sep));
108
+ if (fs.existsSync(dst)) { skipped.push(rel); continue; }
109
+ try {
110
+ fs.writeFileSync(dst, template(tpl), 'utf8');
111
+ written.push(rel);
112
+ } catch (e) {
113
+ return { ok: false, root, created, written, error: '写文件失败(' + rel + '):' + (e && e.message ? e.message : e) };
114
+ }
115
+ }
116
+
117
+ return { ok: true, root, existed, created, written, skipped };
118
+ }
@@ -0,0 +1,227 @@
1
+ import fsx from "node:fs";
2
+ import osx from "node:os";
3
+ import pathx from "node:path";
4
+ import { checkConsent } from './consent.mjs';
5
+
6
+ /**
7
+ * 压缩调度:compactNow 只允许 idle,所以 ho_checkpoint 只登记,轮次结束后由这里排程。
8
+ * 做法:短间隔轮询 ctx.agents.get(sessionId).status,直到非 running 再压缩;成功后投续跑消息。
9
+ */
10
+
11
+ /** 填续跑模板:{n}/{max}/{handover}/{role}。没有交接路径时用「未设置角色」模板。 */
12
+ export function fillResumePrompt(cfg, input) {
13
+ const i = input || {};
14
+ const hasPath = !!i.handover;
15
+ const tpl = hasPath ? String(cfg.resumePrompt || '') : String(cfg.resumePromptUnknown || cfg.resumePrompt || '');
16
+ return tpl
17
+ .split('{n}').join(String(i.n === undefined ? 0 : i.n))
18
+ .split('{max}').join(String(cfg.maxCompactions))
19
+ .split('{handover}').join(hasPath ? i.handover : '(未解析)')
20
+ .split('{role}').join(i.role || 'plain');
21
+ }
22
+
23
+ export function createScheduler(deps) {
24
+ const { ctx, cfg } = deps;
25
+ const logger = deps.logger || null;
26
+ const pending = new Map();
27
+ const timers = new Map();
28
+ let disposed = false;
29
+
30
+ // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放
31
+ // 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放。
32
+ function later(fn, ms) {
33
+ return setTimeout(fn, ms);
34
+ }
35
+
36
+ function note(msg) {
37
+ try { if (logger && logger.info) logger.info("dsh-handover: " + msg); } catch { /* ignore */ }
38
+ try {
39
+ const f = pathx.join(osx.homedir(), ".dsh", "storages", "dsh-handover", "logs", "schedule.log");
40
+ fsx.mkdirSync(pathx.dirname(f), { recursive: true });
41
+ const line = new Date().toISOString() + " " + msg + String.fromCharCode(10);
42
+ const bom = fsx.existsSync(f) ? "" : String.fromCharCode(0xfeff);
43
+ fsx.appendFileSync(f, bom + line);
44
+ } catch { /* ignore */ }
45
+ }
46
+
47
+ /** 服务解析:优先 ctx 直取,其次 ctx.get(inject 未声明的服务只能用后者)。 */
48
+ function service(name) {
49
+ try { const v = ctx[name]; if (v) return v; } catch { /* ignore */ }
50
+ try { return typeof ctx.get === 'function' ? ctx.get(name) : null; } catch { return null; }
51
+ }
52
+
53
+ function agentOf(sessionId) {
54
+ const agents = service('agents');
55
+ try { return agents && agents.get ? agents.get(sessionId) : null; } catch { return null; }
56
+ }
57
+
58
+ /** compaction 可能是「每个 agent 提供」的会话作用域服务:先 agent.ctx,再退回 profile 级 ctx。 */
59
+ function compactionOf(agent) {
60
+ try {
61
+ const c = agent && agent.ctx;
62
+ if (c) {
63
+ let v = null;
64
+ try { v = c.compaction; } catch { /* ignore */ }
65
+ if (!v) { try { v = typeof c.get === 'function' ? c.get('compaction') : null; } catch { /* ignore */ } }
66
+ if (v) return v;
67
+ }
68
+ } catch { /* ignore */ }
69
+ return service('compaction');
70
+ }
71
+
72
+ async function compactOnce(sessionId) {
73
+ const agent = agentOf(sessionId);
74
+ if (!agent) throw new Error("agent 不在内存中(会话可能已休眠)");
75
+ const compaction = compactionOf(agent);
76
+ if (!compaction || typeof compaction.compactNow !== "function") throw new Error("compaction.compactNow 不可用(profile 级与会话作用域都没取到)");
77
+ const ac = new AbortController();
78
+ const timer = later(() => ac.abort(new Error("timeout")), cfg.compactTimeoutMs || 120000);
79
+ try {
80
+ return await compaction.compactNow(agent, ac.signal);
81
+ } finally {
82
+ if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
83
+ }
84
+ }
85
+
86
+ /** 续跑文案:按该会话的角色取模板,并把 {handover} 填成它自己的交接绝对路径。 */
87
+ function resumeText(sessionId, n) {
88
+ let info = null;
89
+ let hp = null;
90
+ try { info = typeof deps.roleOf === 'function' ? deps.roleOf(sessionId) : null; } catch { info = null; }
91
+ try { hp = typeof deps.handoverFor === 'function' ? deps.handoverFor(sessionId) : null; } catch { hp = null; }
92
+ return fillResumePrompt(cfg, { n, handover: hp && hp.path ? hp.path : '', role: info && info.role ? info.role : 'plain' });
93
+ }
94
+
95
+ async function promptSelf(sessionId, text) {
96
+ const controller = service('sessionController');
97
+ if (!controller || typeof controller.prompt !== "function") throw new Error("sessionController.prompt 不可用");
98
+ const req = {
99
+ requestId: (globalThis.crypto && globalThis.crypto.randomUUID) ? globalThis.crypto.randomUUID() : String(Date.now()),
100
+ sessionId,
101
+ mode: "queue",
102
+ content: [{ type: "text", text }],
103
+ };
104
+ return controller.prompt(req, new AbortController().signal);
105
+ }
106
+
107
+ /**
108
+ * 等会话空闲。官方插件规范明确「不要轮询 agent.status」,所以优先用 agent.whenIdle();
109
+ * 老宿主没有这个方法时,退回 1 秒一次的低频轮询(只在等待期生效,不是常驻轮询)。
110
+ */
111
+ async function waitIdle(agent, ms) {
112
+ if (typeof agent.whenIdle === "function") {
113
+ let timer = null;
114
+ const timedOut = new Promise((resolve) => { timer = later(() => resolve("timeout"), ms); });
115
+ try {
116
+ const r = await Promise.race([Promise.resolve(agent.whenIdle()).then(() => "idle"), timedOut]);
117
+ return r === "idle" && agent.status !== "running";
118
+ } catch {
119
+ return false;
120
+ } finally {
121
+ if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
122
+ }
123
+ }
124
+ const deadline = Date.now() + ms;
125
+ while (Date.now() < deadline) {
126
+ if (agent.status !== "running") return true;
127
+ await new Promise((resolve) => later(resolve, 1000));
128
+ }
129
+ return agent.status !== "running";
130
+ }
131
+
132
+ async function attempt(sessionId) {
133
+ const job = pending.get(sessionId);
134
+ if (!job || disposed) return;
135
+ const agent = agentOf(sessionId);
136
+ if (!agent) { fail(sessionId, "会话不在内存,无法压缩"); return; }
137
+ if (agent.status === "running") {
138
+ const idle = await waitIdle(agent, cfg.waitIdleMs || 180000);
139
+ if (!idle) { fail(sessionId, "等了 " + Math.round((cfg.waitIdleMs || 180000) / 1000) + " 秒会话仍未空闲"); return; }
140
+ }
141
+ try {
142
+ const result = await compactOnce(sessionId);
143
+ if (!result) {
144
+ job.done("no-span");
145
+ pending.delete(sessionId);
146
+ return;
147
+ }
148
+ note("compacted " + sessionId + " shadowed=" + (result.shadowedTokenCount || "?"));
149
+ if (cfg.autoResume !== false) {
150
+ const text = resumeText(sessionId, job.n);
151
+ try {
152
+ await promptSelf(sessionId, text);
153
+ job.done("ok", result);
154
+ } catch (e) {
155
+ job.done("resume-failed", result);
156
+ note("resume prompt failed: " + e.message);
157
+ }
158
+ } else {
159
+ job.done("ok", result);
160
+ }
161
+ pending.delete(sessionId);
162
+ } catch (e) {
163
+ const code = e && e.code;
164
+ if (code === "busy") {
165
+ if (Date.now() - job.startedAt > (cfg.waitIdleMs || 180000)) { fail(sessionId, "压缩一直 busy(有别的压缩或排队工作)"); return; }
166
+ retryLater(sessionId, 800);
167
+ return;
168
+ }
169
+ job.attempts++;
170
+ if (job.attempts >= (cfg.compactAttempts || 3)) { fail(sessionId, "压缩失败:" + (e.message || code)); return; }
171
+ retryLater(sessionId, 800);
172
+ }
173
+ }
174
+
175
+ function retryLater(sessionId, ms) {
176
+ if (disposed) return;
177
+ const old = timers.get(sessionId);
178
+ if (old) { try { clearTimeout(old); } catch { /* ignore */ } }
179
+ const t = later(() => { timers.delete(sessionId); attempt(sessionId); }, ms);
180
+ timers.set(sessionId, t);
181
+ }
182
+
183
+ function fail(sessionId, msg) {
184
+ const job = pending.get(sessionId);
185
+ if (job) job.done("failed", null, msg);
186
+ pending.delete(sessionId);
187
+ note("checkpoint failed " + sessionId + ": " + msg);
188
+ try {
189
+ promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史没有被压缩,交接已就绪;请把这条消息原样告诉用户,不要自行重试压缩。").catch(() => {});
190
+ } catch { /* ignore */ }
191
+ }
192
+
193
+ /** 登记一次压缩请求;返回 Promise,settle 时给出结果(供工具侧只做登记、不等待)。 */
194
+ function request(sessionId, opts) {
195
+ return new Promise((resolve) => {
196
+ let settled = false;
197
+ const done = (status, result, error) => {
198
+ if (settled) return;
199
+ settled = true;
200
+ resolve({ status, result: result || null, error: error || null, at: Date.now() });
201
+ };
202
+ // 双保险只对「没验过授权」的调用方生效。入口工具(ho_checkpoint、全局代排)已校验并消费了授权,
203
+ // 之后再查必然查不到,压缩会被这一步全部挡掉。
204
+ if (opts.consent !== 'checked') {
205
+ const cs = checkConsent(sessionId);
206
+ if (!cs.ok) {
207
+ note("拒绝压缩 " + sessionId + ":" + cs.reason);
208
+ done("no-consent", null, cs.reason);
209
+ return;
210
+ }
211
+ }
212
+ pending.set(sessionId, { n: opts.n || 0, attempts: 0, done, startedAt: Date.now() });
213
+ retryLater(sessionId, opts.delayMs === undefined ? 600 : opts.delayMs);
214
+ });
215
+ }
216
+
217
+ function dispose() {
218
+ disposed = true;
219
+ for (const t of timers.values()) {
220
+ try { clearTimeout(t); } catch { /* ignore */ }
221
+ }
222
+ timers.clear();
223
+ pending.clear();
224
+ }
225
+
226
+ return { request, dispose, pending };
227
+ }