@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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH 认证的 /api 载体(客户端设置页取数/下指令的**主通道**)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用裸 fetch:本机 GUI 服务器对非浏览器策略的请求直接 403「Browser access is disabled」,
|
|
5
|
+
* 而 DSH 的 /api 载体自带浏览器鉴权与 Host/Origin 信任,dsh-im 的设置页正是走这条。
|
|
6
|
+
* 契约(与 DSH 内置管理 RPC 一致):
|
|
7
|
+
* 入:POST /api/dsh-handover body={type:'client-request', rpcId, method:'dsh-handover', payload:{method, payload}}
|
|
8
|
+
* 出:{type:'server-response', rpcId, result:{ok:true,value} | {ok:false,error:{code,message,details}}}
|
|
9
|
+
* 旧宿主没有 connection 服务时静默跳过,HTTP 路由继续兜底。
|
|
10
|
+
*/
|
|
11
|
+
const CARRIER_PATH = '/api/dsh-handover';
|
|
12
|
+
const CARRIER_METHOD = 'dsh-handover';
|
|
13
|
+
|
|
14
|
+
export function registerRpcCarrier(ctx, api, log) {
|
|
15
|
+
const say = typeof log === 'function' ? log : () => {};
|
|
16
|
+
let inner;
|
|
17
|
+
try {
|
|
18
|
+
inner = (cc) => {
|
|
19
|
+
const conn = cc && cc.connection ? cc.connection : null;
|
|
20
|
+
const reg = conn && conn.fetch && typeof conn.fetch.register === 'function' ? conn.fetch : null;
|
|
21
|
+
if (!reg) { say('载体:connection.fetch.register 不可用,退回 HTTP 路由'); return; }
|
|
22
|
+
let dispose = null;
|
|
23
|
+
try {
|
|
24
|
+
dispose = reg.register.call(reg, {
|
|
25
|
+
path: CARRIER_PATH,
|
|
26
|
+
methods: ['POST'],
|
|
27
|
+
requestBody: 'buffered',
|
|
28
|
+
async fetch(request) {
|
|
29
|
+
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
|
|
30
|
+
let msg = null;
|
|
31
|
+
try { msg = await request.json(); } catch { return new Response('body is not JSON', { status: 400 }); }
|
|
32
|
+
const rpcId = msg && typeof msg.rpcId === 'string' ? msg.rpcId : 'invalid-request';
|
|
33
|
+
const call = msg && msg.payload;
|
|
34
|
+
const sane = msg && msg.type === 'client-request' && msg.method === CARRIER_METHOD
|
|
35
|
+
&& call && typeof call.method === 'string' && Object.prototype.hasOwnProperty.call(call, 'payload');
|
|
36
|
+
if (!sane) {
|
|
37
|
+
return Response.json({ type: 'server-response', rpcId, result: { ok: false, error: { code: 'bad-request', message: '信封不合法', details: {} } } });
|
|
38
|
+
}
|
|
39
|
+
let r;
|
|
40
|
+
try { r = await api.call(call.method, call.payload); }
|
|
41
|
+
catch (e) { r = { ok: false, error: { code: 'failed', message: String(e && e.message ? e.message : e), details: {} } }; }
|
|
42
|
+
say('载体 ' + call.method + (r.ok ? ' ok' : (' 失败:' + String((r.error && r.error.message) || ''))));
|
|
43
|
+
return Response.json({ type: 'server-response', rpcId, result: r });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
say('载体:已注册 ' + CARRIER_PATH);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
say('载体注册失败:' + String(e && e.message ? e.message : e));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
if (typeof cc.effect === 'function') cc.effect(() => dispose, 'dsh-handover: /api 载体');
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
};
|
|
55
|
+
} catch (e) {
|
|
56
|
+
say('载体装配失败:' + String(e && e.message ? e.message : e));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (typeof ctx.inject === 'function') ctx.inject(['connection'], inner);
|
|
61
|
+
else say('载体:宿主不支持 ctx.inject,退回 HTTP 路由');
|
|
62
|
+
} catch (e) {
|
|
63
|
+
say('载体装配失败:' + String(e && e.message ? e.message : e));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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,223 @@
|
|
|
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
|
+
import { sendPrompt } from './prompt.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 压缩调度:compactNow 只允许 idle,所以 ho_checkpoint 只登记,轮次结束后由这里排程。
|
|
9
|
+
* 做法:短间隔轮询 ctx.agents.get(sessionId).status,直到非 running 再压缩;成功后投续跑消息。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 填续跑模板:{n}/{max}/{handover}/{role}。没有交接路径时用「未设置角色」模板。 */
|
|
13
|
+
export function fillResumePrompt(cfg, input) {
|
|
14
|
+
const i = input || {};
|
|
15
|
+
const hasPath = !!i.handover;
|
|
16
|
+
const tpl = hasPath ? String(cfg.resumePrompt || '') : String(cfg.resumePromptUnknown || cfg.resumePrompt || '');
|
|
17
|
+
return tpl
|
|
18
|
+
.split('{n}').join(String(i.n === undefined ? 0 : i.n))
|
|
19
|
+
.split('{max}').join(String(cfg.maxCompactions))
|
|
20
|
+
.split('{handover}').join(hasPath ? i.handover : '(未解析)')
|
|
21
|
+
.split('{role}').join(i.role || 'plain');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createScheduler(deps) {
|
|
25
|
+
const { ctx, cfg } = deps;
|
|
26
|
+
const logger = deps.logger || null;
|
|
27
|
+
const pending = new Map();
|
|
28
|
+
const timers = new Map();
|
|
29
|
+
let disposed = false;
|
|
30
|
+
|
|
31
|
+
// 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放
|
|
32
|
+
// 用全局定时器:cordis 的 ctx.setTimeout 绑定调用方 fiber,工具调用返回后可能被一并释放。
|
|
33
|
+
function later(fn, ms) {
|
|
34
|
+
return setTimeout(fn, ms);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function note(msg) {
|
|
38
|
+
try { if (logger && logger.info) logger.info("dsh-handover: " + msg); } catch { /* ignore */ }
|
|
39
|
+
try {
|
|
40
|
+
const f = pathx.join(osx.homedir(), ".dsh", "storages", "dsh-handover", "logs", "schedule.log");
|
|
41
|
+
fsx.mkdirSync(pathx.dirname(f), { recursive: true });
|
|
42
|
+
const line = new Date().toISOString() + " " + msg + String.fromCharCode(10);
|
|
43
|
+
const bom = fsx.existsSync(f) ? "" : String.fromCharCode(0xfeff);
|
|
44
|
+
fsx.appendFileSync(f, bom + line);
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 服务解析:优先 ctx 直取,其次 ctx.get(inject 未声明的服务只能用后者)。 */
|
|
49
|
+
function service(name) {
|
|
50
|
+
try { const v = ctx[name]; if (v) return v; } catch { /* ignore */ }
|
|
51
|
+
try { return typeof ctx.get === 'function' ? ctx.get(name) : null; } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentOf(sessionId) {
|
|
55
|
+
const agents = service('agents');
|
|
56
|
+
try { return agents && agents.get ? agents.get(sessionId) : null; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** compaction 可能是「每个 agent 提供」的会话作用域服务:先 agent.ctx,再退回 profile 级 ctx。 */
|
|
60
|
+
function compactionOf(agent) {
|
|
61
|
+
try {
|
|
62
|
+
const c = agent && agent.ctx;
|
|
63
|
+
if (c) {
|
|
64
|
+
let v = null;
|
|
65
|
+
try { v = c.compaction; } catch { /* ignore */ }
|
|
66
|
+
if (!v) { try { v = typeof c.get === 'function' ? c.get('compaction') : null; } catch { /* ignore */ } }
|
|
67
|
+
if (v) return v;
|
|
68
|
+
}
|
|
69
|
+
} catch { /* ignore */ }
|
|
70
|
+
return service('compaction');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function compactOnce(sessionId) {
|
|
74
|
+
const agent = agentOf(sessionId);
|
|
75
|
+
if (!agent) throw new Error("agent 不在内存中(会话可能已休眠)");
|
|
76
|
+
const compaction = compactionOf(agent);
|
|
77
|
+
if (!compaction || typeof compaction.compactNow !== "function") throw new Error("compaction.compactNow 不可用(profile 级与会话作用域都没取到)");
|
|
78
|
+
const ac = new AbortController();
|
|
79
|
+
const timer = later(() => ac.abort(new Error("timeout")), cfg.compactTimeoutMs || 120000);
|
|
80
|
+
try {
|
|
81
|
+
return await compaction.compactNow(agent, ac.signal);
|
|
82
|
+
} finally {
|
|
83
|
+
if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 续跑文案:按该会话的角色取模板,并把 {handover} 填成它自己的交接绝对路径。 */
|
|
88
|
+
function resumeText(sessionId, n) {
|
|
89
|
+
let info = null;
|
|
90
|
+
let hp = null;
|
|
91
|
+
try { info = typeof deps.roleOf === 'function' ? deps.roleOf(sessionId) : null; } catch { info = null; }
|
|
92
|
+
try { hp = typeof deps.handoverFor === 'function' ? deps.handoverFor(sessionId) : null; } catch { hp = null; }
|
|
93
|
+
return fillResumePrompt(cfg, { n, handover: hp && hp.path ? hp.path : '', role: info && info.role ? info.role : 'plain' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function promptSelf(sessionId, text) {
|
|
97
|
+
return sendPrompt(service('sessionController'), sessionId, text, 'queue');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 等会话空闲。官方插件规范明确「不要轮询 agent.status」,所以优先用 agent.whenIdle();
|
|
102
|
+
* 老宿主没有这个方法时,退回 1 秒一次的低频轮询(只在等待期生效,不是常驻轮询)。
|
|
103
|
+
*/
|
|
104
|
+
async function waitIdle(agent, ms) {
|
|
105
|
+
if (typeof agent.whenIdle === "function") {
|
|
106
|
+
let timer = null;
|
|
107
|
+
const timedOut = new Promise((resolve) => { timer = later(() => resolve("timeout"), ms); });
|
|
108
|
+
try {
|
|
109
|
+
const r = await Promise.race([Promise.resolve(agent.whenIdle()).then(() => "idle"), timedOut]);
|
|
110
|
+
return r === "idle" && agent.status !== "running";
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
} finally {
|
|
114
|
+
if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const deadline = Date.now() + ms;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
if (agent.status !== "running") return true;
|
|
120
|
+
await new Promise((resolve) => later(resolve, 1000));
|
|
121
|
+
}
|
|
122
|
+
return agent.status !== "running";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function attempt(sessionId) {
|
|
126
|
+
const job = pending.get(sessionId);
|
|
127
|
+
if (!job || disposed) return;
|
|
128
|
+
const agent = agentOf(sessionId);
|
|
129
|
+
if (!agent) { fail(sessionId, "会话不在内存,无法压缩"); return; }
|
|
130
|
+
if (agent.status === "running") {
|
|
131
|
+
const idle = await waitIdle(agent, cfg.waitIdleMs || 180000);
|
|
132
|
+
if (!idle) { fail(sessionId, "等了 " + Math.round((cfg.waitIdleMs || 180000) / 1000) + " 秒会话仍未空闲"); return; }
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const result = await compactOnce(sessionId);
|
|
136
|
+
if (!result) {
|
|
137
|
+
job.done("no-span");
|
|
138
|
+
pending.delete(sessionId);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
note("compacted " + sessionId + " shadowed=" + (result.shadowedTokenCount || "?"));
|
|
142
|
+
// resume=wait:任务已收尾,压缩后不唤醒,等用户派新任务。
|
|
143
|
+
// resume=continue(默认):照旧投续跑消息,按交接 §2 继续。
|
|
144
|
+
if (cfg.autoResume !== false && job.resume !== "wait") {
|
|
145
|
+
const text = resumeText(sessionId, job.n);
|
|
146
|
+
try {
|
|
147
|
+
await promptSelf(sessionId, text);
|
|
148
|
+
job.done("ok", result);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
job.done("resume-failed", result);
|
|
151
|
+
note("resume prompt failed: " + e.message);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
if (job.resume === "wait") note("resume skipped (wait): " + sessionId);
|
|
155
|
+
job.done(job.resume === "wait" ? "ok-wait" : "ok", result);
|
|
156
|
+
}
|
|
157
|
+
pending.delete(sessionId);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const code = e && e.code;
|
|
160
|
+
if (code === "busy") {
|
|
161
|
+
if (Date.now() - job.startedAt > (cfg.waitIdleMs || 180000)) { fail(sessionId, "压缩一直 busy(有别的压缩或排队工作)"); return; }
|
|
162
|
+
retryLater(sessionId, 800);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
job.attempts++;
|
|
166
|
+
if (job.attempts >= (cfg.compactAttempts || 3)) { fail(sessionId, "压缩失败:" + (e.message || code)); return; }
|
|
167
|
+
retryLater(sessionId, 800);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function retryLater(sessionId, ms) {
|
|
172
|
+
if (disposed) return;
|
|
173
|
+
const old = timers.get(sessionId);
|
|
174
|
+
if (old) { try { clearTimeout(old); } catch { /* ignore */ } }
|
|
175
|
+
const t = later(() => { timers.delete(sessionId); attempt(sessionId); }, ms);
|
|
176
|
+
timers.set(sessionId, t);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function fail(sessionId, msg) {
|
|
180
|
+
const job = pending.get(sessionId);
|
|
181
|
+
if (job) job.done("failed", null, msg);
|
|
182
|
+
pending.delete(sessionId);
|
|
183
|
+
note("checkpoint failed " + sessionId + ": " + msg);
|
|
184
|
+
try {
|
|
185
|
+
promptSelf(sessionId, "【dsh-handover】压缩未执行:" + msg + "。历史没有被压缩,交接已就绪;请把这条消息原样告诉用户,不要自行重试压缩。").catch(() => {});
|
|
186
|
+
} catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 登记一次压缩请求;返回 Promise,settle 时给出结果(供工具侧只做登记、不等待)。 */
|
|
190
|
+
function request(sessionId, opts) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
let settled = false;
|
|
193
|
+
const done = (status, result, error) => {
|
|
194
|
+
if (settled) return;
|
|
195
|
+
settled = true;
|
|
196
|
+
resolve({ status, result: result || null, error: error || null, at: Date.now() });
|
|
197
|
+
};
|
|
198
|
+
// 双保险只对「没验过授权」的调用方生效。入口工具(ho_checkpoint、全局代排)已校验并消费了授权,
|
|
199
|
+
// 之后再查必然查不到,压缩会被这一步全部挡掉。
|
|
200
|
+
if (opts.consent !== 'checked') {
|
|
201
|
+
const cs = checkConsent(sessionId);
|
|
202
|
+
if (!cs.ok) {
|
|
203
|
+
note("拒绝压缩 " + sessionId + ":" + cs.reason);
|
|
204
|
+
done("no-consent", null, cs.reason);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
pending.set(sessionId, { n: opts.n || 0, resume: opts.resume === "wait" ? "wait" : "continue", attempts: 0, done, startedAt: Date.now() });
|
|
209
|
+
retryLater(sessionId, opts.delayMs === undefined ? 600 : opts.delayMs);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function dispose() {
|
|
214
|
+
disposed = true;
|
|
215
|
+
for (const t of timers.values()) {
|
|
216
|
+
try { clearTimeout(t); } catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
timers.clear();
|
|
219
|
+
pending.clear();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { request, dispose, pending };
|
|
223
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 新建会话的唯一落点:建会话、改名、登记角色、当场装配。
|
|
3
|
+
* ho_sessions(new)、设置页一键部署与补建全局会话共用这一段,避免三处各写一遍。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param env 插件环境(需要 controller、writeRole、applyScopedLive)
|
|
8
|
+
* @param opts { workspaceId?, cwd?, title?, role?, project?, task? }
|
|
9
|
+
* @returns { sessionId, workspaceId, error }
|
|
10
|
+
*/
|
|
11
|
+
export async function newSessionIn(env, opts) {
|
|
12
|
+
const o = opts || {};
|
|
13
|
+
const controller = typeof env.controller === 'function' ? env.controller() : null;
|
|
14
|
+
if (!controller || typeof controller.create !== 'function') {
|
|
15
|
+
return { error: 'sessionController.create 不可用' };
|
|
16
|
+
}
|
|
17
|
+
const workspaceId = String(o.workspaceId || '');
|
|
18
|
+
const cwd = String(o.cwd || '');
|
|
19
|
+
if (!workspaceId && !cwd) return { error: '建会话要么给 workspaceId,要么给 cwd' };
|
|
20
|
+
let created = null;
|
|
21
|
+
try {
|
|
22
|
+
created = workspaceId ? await controller.create({ workspaceId }) : await controller.create({ cwd });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return { error: '建会话失败:' + String(e && e.message ? e.message : e) };
|
|
25
|
+
}
|
|
26
|
+
const sessionId = String((created && created.sessionId) || '');
|
|
27
|
+
if (!sessionId) return { error: 'create 没有返回 sessionId' };
|
|
28
|
+
const title = String(o.title || '');
|
|
29
|
+
if (title) {
|
|
30
|
+
try { if (typeof controller.rename === 'function') await controller.rename({ sessionId, title }); } catch { /* 改名失败可事后 peer_rename */ }
|
|
31
|
+
}
|
|
32
|
+
const role = String(o.role || '');
|
|
33
|
+
if (role && role !== 'plain' && typeof env.writeRole === 'function') {
|
|
34
|
+
try {
|
|
35
|
+
env.writeRole(sessionId, { role, project: String(o.project || ''), task: String(o.task || ''), source: 'create' });
|
|
36
|
+
} catch { /* 角色登记失败不影响建会话 */ }
|
|
37
|
+
}
|
|
38
|
+
if (typeof env.applyScopedLive === 'function') {
|
|
39
|
+
try { env.applyScopedLive(sessionId, { role: role || 'plain', project: String(o.project || ''), task: String(o.task || '') }); } catch { /* 下一轮的开轮核对会补齐 */ }
|
|
40
|
+
}
|
|
41
|
+
return { sessionId, workspaceId };
|
|
42
|
+
}
|