dsh-subagent-profile 0.2.0 → 0.3.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.
- package/README.md +20 -8
- package/README.zh.md +20 -8
- package/index.mjs +160 -1386
- package/lib/client.js +776 -695
- package/lib/core/catalog.mjs +83 -0
- package/lib/core/cost-guard.mjs +108 -0
- package/lib/core/delegation.mjs +61 -0
- package/lib/core/dispatch-tool.mjs +371 -0
- package/lib/core/http-routes.mjs +328 -0
- package/lib/core/intersection.mjs +27 -0
- package/lib/core/presets-sync.mjs +136 -0
- package/lib/core/profile-provider.mjs +241 -0
- package/lib/core/profiles-store.mjs +226 -0
- package/lib/{pure.mjs → core/pure.mjs} +114 -109
- package/lib/{shims.mjs → core/shims.mjs} +9 -9
- package/lib/core/whitelist.mjs +22 -0
- package/package.json +9 -5
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// lib/core/profile-provider.mjs — `profile` 子 Agent provider
|
|
2
|
+
// (setup/start/prepareContinuable),从 index.mjs 的
|
|
3
|
+
// `ctx.subagents.registerProvider({...})` 块逐字拆出。仅引用 lib + shims;
|
|
4
|
+
// 无 @deepseek-ai 依赖(shims 是唯一入口)。
|
|
5
|
+
//
|
|
6
|
+
// Injection: every apply-closure / ctx dependency is an explicit parameter —
|
|
7
|
+
// subagents the registry this provider is registered into
|
|
8
|
+
// (subagents.registerProvider is called by the factory),
|
|
9
|
+
// store the profile store (getAllowFailOpen for the cost guard),
|
|
10
|
+
// getEnabled reads the apply-closure `enabled` flag (start fails loud when
|
|
11
|
+
// off),
|
|
12
|
+
// logger ctx.logger (decision-level child log).
|
|
13
|
+
// The factory returns whatever registerProvider returns (the caller keeps the
|
|
14
|
+
// original `if (typeof disposeProvider === 'function') ctx.effect(...)` shape).
|
|
15
|
+
//
|
|
16
|
+
// start 按预检段(runStartPreflight)/ 结算+取消接线段(wireChildLifecycle)
|
|
17
|
+
// 拆为模块级私有函数;start 内嵌 setup 按 ①-⑦ 步拆 setupChild +
|
|
18
|
+
// restrictChildTools。行为逐字不变。
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import {
|
|
22
|
+
appendDelegatedPolicyOverrides,
|
|
23
|
+
assertSubagentMaxDepth,
|
|
24
|
+
captureDelegatedPolicyOverrides,
|
|
25
|
+
createUserMessage,
|
|
26
|
+
readResult,
|
|
27
|
+
resolveChildAgentOptions,
|
|
28
|
+
resolveChildDepth,
|
|
29
|
+
} from './shims.mjs';
|
|
30
|
+
import { GUIDANCE_PREFIX } from './pure.mjs';
|
|
31
|
+
import { computeEffectiveAllow } from './intersection.mjs';
|
|
32
|
+
import { resolveWhitelist } from './whitelist.mjs';
|
|
33
|
+
import { assertCostGuard } from './cost-guard.mjs';
|
|
34
|
+
import { DELEGATION_CONTEXT, buildDispatchMeta } from './delegation.mjs';
|
|
35
|
+
|
|
36
|
+
// --- start 预检段(从 start 拆出)-------------------------------------------------
|
|
37
|
+
|
|
38
|
+
// start 前置校验:enabled / profile 存在 / 策略捕获 / whitelist /
|
|
39
|
+
// cost guard / 深度断言与 childDepth / childId / swapPreset / meta /
|
|
40
|
+
// agentOptions,一次性返回 start 后续段所需的全部状态。
|
|
41
|
+
async function runStartPreflight(request, deps) {
|
|
42
|
+
if (!deps.getEnabled()) {
|
|
43
|
+
throw new Error('dispatch: the subagent-profile plugin is disabled (re-enable it in 设置 → 子 Agent 方案)');
|
|
44
|
+
}
|
|
45
|
+
const profile = request.profile;
|
|
46
|
+
if (profile === undefined) {
|
|
47
|
+
throw new Error('dispatch: request.profile is missing (the dispatch tool must resolve a profile before starting)');
|
|
48
|
+
}
|
|
49
|
+
const parent = request.parent;
|
|
50
|
+
// 同步捕获委派策略,在首个 await 之前——之后的父会话切换属于父的未来,
|
|
51
|
+
// 不属于本 child(shipped captureDelegatedPolicyOverrides)。
|
|
52
|
+
const delegated = captureDelegatedPolicyOverrides(parent);
|
|
53
|
+
// 权威 preset whitelist 检查(对照运行时名册)。
|
|
54
|
+
const whitelist = new Set(await resolveWhitelist(parent.ctx.get('agentPresets')));
|
|
55
|
+
if (typeof profile.preset === 'string' && profile.preset !== 'inherit' && !whitelist.has(profile.preset)) {
|
|
56
|
+
throw new Error(`dispatch: preset "${profile.preset}" is not in the target-preset whitelist`);
|
|
57
|
+
}
|
|
58
|
+
// 权威 cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控)。
|
|
59
|
+
await assertCostGuard(parent, profile, deps.store.getAllowFailOpen(), deps.logger);
|
|
60
|
+
// Delegation depth: shipped helpers — assert the cap value, then resolve
|
|
61
|
+
// the child depth (parent floor + 1) and enforce the cap.
|
|
62
|
+
assertSubagentMaxDepth(profile.maxDepth);
|
|
63
|
+
const childDepth = resolveChildDepth(parent, profile.maxDepth);
|
|
64
|
+
const childId = randomUUID();
|
|
65
|
+
const parentAgentPresets = parent.ctx.get('agentPresets');
|
|
66
|
+
const parentComposed = parentAgentPresets !== undefined ? parentAgentPresets.composedPreset(parent.ctx) : undefined;
|
|
67
|
+
const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
|
|
68
|
+
const meta = buildProviderMeta(parent, profile, swapPreset, childDepth);
|
|
69
|
+
const agentOptions = buildProviderAgentOptions(parent, profile, childDepth);
|
|
70
|
+
return { parent, profile, delegated, childId, swapPreset, meta, agentOptions };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// agentPreset 仅在存在 preset 名册时记录(否则整体省略)。该 meta 是自定义的
|
|
74
|
+
// ——非 shipped childSessionMeta——因为 swap 记录的是 profile.preset 而非父的
|
|
75
|
+
// composedPreset。纯组装在 lib/core/delegation.mjs:buildDispatchMeta。
|
|
76
|
+
function buildProviderMeta(parent, profile, swapPreset, childDepth) {
|
|
77
|
+
const parentAgentPresets = parent.ctx.get('agentPresets');
|
|
78
|
+
const parentComposed = parentAgentPresets !== undefined ? parentAgentPresets.composedPreset(parent.ctx) : undefined;
|
|
79
|
+
return buildDispatchMeta({
|
|
80
|
+
cwd: parent.session.header.cwd,
|
|
81
|
+
hasPresets: parentAgentPresets !== undefined,
|
|
82
|
+
swapPreset,
|
|
83
|
+
preset: profile.preset,
|
|
84
|
+
parentComposed,
|
|
85
|
+
parentSession: parent.session.header.id,
|
|
86
|
+
childDepth
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// agentOptions: shipped resolveChildAgentOptions — parent route inherited
|
|
91
|
+
// unless the profile overrides provider/model/maxTokens, stamped with the
|
|
92
|
+
// child's own delegation depth.
|
|
93
|
+
function buildProviderAgentOptions(parent, profile, childDepth) {
|
|
94
|
+
return resolveChildAgentOptions(parent, {
|
|
95
|
+
...(profile.provider !== undefined ? { provider: profile.provider } : {}),
|
|
96
|
+
...(profile.model !== undefined ? { model: profile.model } : {}),
|
|
97
|
+
...(profile.maxTokens !== undefined ? { maxTokens: profile.maxTokens } : {})
|
|
98
|
+
}, childDepth);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// start 主体:预检 → 建 child(setup 装配)→ 结算/取消接线,行为逐字不变。
|
|
102
|
+
async function startProvider(request, deps) {
|
|
103
|
+
const { parent, profile, delegated, childId, swapPreset, meta, agentOptions } = await runStartPreflight(request, deps);
|
|
104
|
+
if (request.signal !== undefined && request.signal.aborted) {
|
|
105
|
+
throw new Error('dispatch: subagent request was aborted before child publication');
|
|
106
|
+
}
|
|
107
|
+
const handle = await parent.ctx.agents.create({
|
|
108
|
+
sessionId: childId,
|
|
109
|
+
meta,
|
|
110
|
+
agentOptions,
|
|
111
|
+
signal: request.signal,
|
|
112
|
+
setup: (childCtx) => setupChild(childCtx, { parent, profile, swapPreset, delegated, request }),
|
|
113
|
+
});
|
|
114
|
+
return wireChildLifecycle(handle, request, childId, swapPreset, profile, deps.logger);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- start 内嵌 setup(childCtx):按 ①-⑦ 步装配子 Agent 会话(从 start 拆出)-------
|
|
118
|
+
|
|
119
|
+
async function setupChild(childCtx, { parent, profile, swapPreset, delegated, request }) {
|
|
120
|
+
// ① Preset composition: explicit swap mounts the target preset; otherwise
|
|
121
|
+
// compose from the parent. Rosterless + explicit swap fails loud.
|
|
122
|
+
const childPresets = childCtx.get('agentPresets');
|
|
123
|
+
if (swapPreset) {
|
|
124
|
+
if (childPresets === undefined) {
|
|
125
|
+
throw new Error('dispatch: cannot swap preset in a rosterless deployment');
|
|
126
|
+
}
|
|
127
|
+
await childPresets.mount(childCtx, profile.preset);
|
|
128
|
+
} else if (childPresets !== undefined) {
|
|
129
|
+
childPresets.composeFrom(childCtx, parent.ctx);
|
|
130
|
+
}
|
|
131
|
+
// ② Tool intersection(safety gate 1,实现见 restrictChildTools)。
|
|
132
|
+
restrictChildTools(childCtx, parent, profile);
|
|
133
|
+
// ③ Delegation scope declaration (when systemPrompt is available).
|
|
134
|
+
const systemPrompt = childCtx.get('systemPrompt');
|
|
135
|
+
if (systemPrompt !== undefined) {
|
|
136
|
+
systemPrompt.context({ name: 'subagent:delegation', order: 120, text: DELEGATION_CONTEXT });
|
|
137
|
+
}
|
|
138
|
+
// ④ Persona shadow (overrides deployment:persona at order 0). 双防线 prefix.
|
|
139
|
+
if (profile.persona !== undefined && systemPrompt !== undefined) {
|
|
140
|
+
systemPrompt.section({
|
|
141
|
+
name: 'deployment:persona',
|
|
142
|
+
order: 0,
|
|
143
|
+
text: profile.persona.length > 0 ? `${GUIDANCE_PREFIX}${profile.persona}` : profile.persona,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// ⑤ Reasoning-effort injection into every child request.
|
|
147
|
+
if (profile.reasoningEffort !== undefined) {
|
|
148
|
+
childCtx.on('agent/request', async (_payload, next) => {
|
|
149
|
+
const resolved = await next();
|
|
150
|
+
return { ...resolved, reasoningEffort: profile.reasoningEffort };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ⑥ Descriptor append inside the child's first turn.
|
|
154
|
+
let appended = false;
|
|
155
|
+
childCtx.on('agent/pre-step', async ({ agent }, next) => {
|
|
156
|
+
const decision = await next();
|
|
157
|
+
if (!appended && decision.kind === 'enter') {
|
|
158
|
+
appended = true;
|
|
159
|
+
agent.session.append('subagent/descriptor', request.descriptor);
|
|
160
|
+
}
|
|
161
|
+
return decision;
|
|
162
|
+
});
|
|
163
|
+
// ⑦ Delegation policy appends (shipped helper).
|
|
164
|
+
appendDelegatedPolicyOverrides(childCtx.agent.session, delegated);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ② Tool intersection (safety gate 1): parent set ∩ child set, minus
|
|
168
|
+
// run_code, minus deny, then narrowed by allow when present (pure core in
|
|
169
|
+
// lib/core/intersection.mjs: computeEffectiveAllow).
|
|
170
|
+
function restrictChildTools(childCtx, parent, profile) {
|
|
171
|
+
const parentNames = new Set(parent.ctx.tools.schemas(parent).map((schema) => schema.name));
|
|
172
|
+
const childNames = childCtx.tools.schemas(childCtx.agent).map((schema) => schema.name);
|
|
173
|
+
const effective = computeEffectiveAllow(parentNames, childNames, profile.toolFilter);
|
|
174
|
+
// shipped restrict 不对 allow:[] throw——在此 fail-loud,让空交集显式化
|
|
175
|
+
// (throw ⇒ setupAndPublish 回滚创建)。
|
|
176
|
+
if (effective.length === 0) {
|
|
177
|
+
throw new Error('dispatch: child tool intersection is empty (zero tools)');
|
|
178
|
+
}
|
|
179
|
+
// restrict throws on unknown/scope-local/reserved allow sets: wrap
|
|
180
|
+
// in a clean error and rethrow to trigger creation rollback.
|
|
181
|
+
try {
|
|
182
|
+
childCtx.tools.restrict({ allow: effective });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new Error(`dispatch: child tool restriction failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- 结算 + 取消接线段(从 start 拆出)--------------------------------------------
|
|
189
|
+
|
|
190
|
+
// 发布后取消接线(drivePublishedRun):调用方 signal 取消 child,
|
|
191
|
+
// 已取消时 result 闭包跳过 followup。
|
|
192
|
+
function wireChildLifecycle(handle, request, childId, swapPreset, profile, logger) {
|
|
193
|
+
const child = handle.agent;
|
|
194
|
+
const boundary = child.session.events.length;
|
|
195
|
+
const flags = { cancelled: false };
|
|
196
|
+
const onAbort = () => {
|
|
197
|
+
flags.cancelled = true;
|
|
198
|
+
child.cancel({ kind: 'parent' });
|
|
199
|
+
};
|
|
200
|
+
const signal = request.signal;
|
|
201
|
+
if (signal !== undefined) {
|
|
202
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
203
|
+
if (signal.aborted) onAbort();
|
|
204
|
+
}
|
|
205
|
+
const result = (async () => {
|
|
206
|
+
try {
|
|
207
|
+
if (!flags.cancelled) {
|
|
208
|
+
child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }));
|
|
209
|
+
await child.whenIdle();
|
|
210
|
+
}
|
|
211
|
+
const settled = readResult(child, boundary, flags.cancelled);
|
|
212
|
+
// Decision-level log at result settlement (readResult, before return).
|
|
213
|
+
logger.info('[dsh-subagent-profile] child:', JSON.stringify({ childId, preset: profile.preset ?? 'inherit', swapPreset, stopReason: settled.stopReason }));
|
|
214
|
+
return settled;
|
|
215
|
+
} finally {
|
|
216
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
return {
|
|
220
|
+
id: childId,
|
|
221
|
+
localAgent: child,
|
|
222
|
+
result,
|
|
223
|
+
async dispose() {
|
|
224
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
|
|
225
|
+
flags.cancelled = true;
|
|
226
|
+
const settled = await Promise.allSettled([handle.dispose(), result]);
|
|
227
|
+
if (settled[0].status === 'rejected') throw settled[0].reason;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function createProfileProvider({ subagents, store, getEnabled, logger }) {
|
|
233
|
+
const deps = { store, getEnabled, logger };
|
|
234
|
+
return subagents.registerProvider({
|
|
235
|
+
name: 'profile',
|
|
236
|
+
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
|
|
237
|
+
inheritsParentContext: false,
|
|
238
|
+
start: (request) => startProvider(request, deps),
|
|
239
|
+
prepareContinuable: async () => ({})
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// lib/core/profiles-store.mjs — profile registry store and enable/disable switch
|
|
2
|
+
// (moved verbatim from index.mjs and refactored into a factory; import-free —
|
|
3
|
+
// node builtins + lib/core/pure.mjs only, no @deepseek-ai dependency).
|
|
4
|
+
//
|
|
5
|
+
// `dshHome()` and `BUILTIN_SEEDS` are module-level exports shared by the
|
|
6
|
+
// factory and by lib/core/http-routes.mjs (the settings HTTP handlers look up seeds). The
|
|
7
|
+
// per-apply store is constructed by `createProfileStore({ dshHome, logger })`,
|
|
8
|
+
// replacing the apply-closure singleton: every apply() call gets its own
|
|
9
|
+
// profiles Map / deletedBuiltins Set / allowFailOpen flag, exactly like the
|
|
10
|
+
// original closure state.
|
|
11
|
+
//
|
|
12
|
+
// 工厂内嵌的 loadProfiles/persistProfiles/loadEnabled/persistEnabled/
|
|
13
|
+
// resolveProfile 按行门抽为模块级函数,per-apply 状态收集进 `state` 对象
|
|
14
|
+
// 注入;返回面与拆分前逐字一致。
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { sanitizeProfile } from './pure.mjs';
|
|
20
|
+
|
|
21
|
+
// Resolve the DSH home directory (env override wins, platform fallback) — the
|
|
22
|
+
// same policy as the dsh-persona-ref bundle: user profiles persist to
|
|
23
|
+
// ~/.dsh/subagent-profiles.json, stable across harness working directories.
|
|
24
|
+
export function dshHome() {
|
|
25
|
+
const raw = process.env.DSH_HOME;
|
|
26
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (trimmed === '~') return homedir();
|
|
29
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) return join(homedir(), trimmed.slice(2));
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
return join(homedir(), '.dsh');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 1. Profile registry (per-instance state; a bundle row is process-level, so
|
|
36
|
+
// this Map is the singleton store, exactly like the dynamic plugin's).
|
|
37
|
+
// Builtin seeds carry `builtin: true` so reset/remove can identify them and
|
|
38
|
+
// a modified builtin stays distinguishable from a pure user profile.
|
|
39
|
+
// Descriptions are semantic: one-line positioning + when to use, to help
|
|
40
|
+
// the model choose. reasoningEffort levels verified against the
|
|
41
|
+
// llm-deepseek adapter (off/high/max; see resolveModel gating on
|
|
42
|
+
// connection.defaults.thinking — this deployment leaves thinking unset,
|
|
43
|
+
// so the full set is advertised for deepseek-v4-flash).
|
|
44
|
+
export const BUILTIN_SEEDS = [
|
|
45
|
+
{ id: 'swap-standard', name: '标准编码', description: '切换到 standard 预设的完整编码工具集。当父会话不是 standard、但子任务需要完整编码能力时用。', preset: 'standard', builtin: true },
|
|
46
|
+
{ id: 'researcher', name: '调研检索', description: '关闭深度推理省 token,继承父工具。适合查资料、汇总、背景调研,不适合改代码。', reasoningEffort: 'off', persona: 'You are a research subagent: search, read, and summarize only. Do not modify code or files.', builtin: true }
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// --- 模块级 store 函数(从工厂拆出;per-apply 状态经 `state` 注入)---------------
|
|
50
|
+
|
|
51
|
+
function resolveProfile(profiles, id) {
|
|
52
|
+
const found = profiles.get(id);
|
|
53
|
+
if (found === undefined) throw new Error(`dispatch: unknown profile "${id}"`);
|
|
54
|
+
if (found.enabled === false) throw new Error(`dispatch: profile "${id}" is disabled`);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Two file shapes: v1 = a bare array; v2 = { version:2,
|
|
59
|
+
// profiles:[...], allowFailOpen:<bool> }. Returns { entries, version } or
|
|
60
|
+
// { entries: undefined } for an unrecognized shape.
|
|
61
|
+
function classifyProfileFile(parsed) {
|
|
62
|
+
if (Array.isArray(parsed)) return { entries: parsed, version: 1 };
|
|
63
|
+
if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.profiles)) {
|
|
64
|
+
return { entries: parsed.profiles, version: parsed.version ?? 2 };
|
|
65
|
+
}
|
|
66
|
+
return { entries: undefined, version: undefined };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Per-entry sanitize (strict=false): over-limit fields are dropped (field
|
|
70
|
+
// removed) + warned; an over-length persona is KEPT + warned (never silently
|
|
71
|
+
// truncated). A bad entry is skipped (fail-soft). Returns
|
|
72
|
+
// 'loaded' | 'skipped' | 'deleted' so loadProfiles can keep its counters.
|
|
73
|
+
function applyProfileEntry(raw, profiles, deletedBuiltins, logger) {
|
|
74
|
+
if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || raw.id.length === 0) {
|
|
75
|
+
logger.warn('[dsh-subagent-profile] skipping malformed profile entry:', raw === null ? String(raw) : typeof raw);
|
|
76
|
+
return 'skipped';
|
|
77
|
+
}
|
|
78
|
+
const { clean, warnings } = sanitizeProfile(raw, { strict: false });
|
|
79
|
+
for (const warning of warnings) {
|
|
80
|
+
logger.warn(`[dsh-subagent-profile] profile "${raw.id}" ${warning.field} 被跳过或提示:${warning.reason}`);
|
|
81
|
+
}
|
|
82
|
+
if (clean.deleted === true) {
|
|
83
|
+
if (clean.builtin === true) {
|
|
84
|
+
profiles.delete(clean.id);
|
|
85
|
+
deletedBuiltins.add(clean.id);
|
|
86
|
+
}
|
|
87
|
+
return 'deleted';
|
|
88
|
+
}
|
|
89
|
+
const existing = profiles.get(clean.id);
|
|
90
|
+
if (existing !== undefined && existing.builtin === true) {
|
|
91
|
+
profiles.set(clean.id, { ...clean, builtin: true, persisted: true });
|
|
92
|
+
} else {
|
|
93
|
+
profiles.set(clean.id, { ...clean, persisted: true });
|
|
94
|
+
}
|
|
95
|
+
return 'loaded';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 1b. User profile persistence. The settings service cannot serve this
|
|
99
|
+
// plugin (its write path hard-requires register(ns, schema) + a schemastery
|
|
100
|
+
// schema), so user profiles are persisted to ~/.dsh/subagent-profiles.json
|
|
101
|
+
// through node:fs — a bundle has node globals, unlike the dynamic-plugin
|
|
102
|
+
// sandbox that needed the optional `fs` service. Loaded once at startup; the
|
|
103
|
+
// add/remove HTTP routes rewrite the file. Persistence is an enhancement, not
|
|
104
|
+
// a hard dependency: any failure only warns and the builtin seeds work.
|
|
105
|
+
function loadProfiles(state, logger) {
|
|
106
|
+
if (!existsSync(state.profilesFile)) return;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(readFileSync(state.profilesFile, 'utf8'));
|
|
109
|
+
const { entries, version } = classifyProfileFile(parsed);
|
|
110
|
+
if (entries === undefined) {
|
|
111
|
+
logger.warn('[dsh-subagent-profile] persisted profile file has an unrecognized shape; ignoring');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (version !== 2) {
|
|
115
|
+
// v1 (or an unversioned array): migrate in memory, keep fail-open compat.
|
|
116
|
+
state.allowFailOpen = true;
|
|
117
|
+
logger.warn('[dsh-subagent-profile] v1 数据:fail-open 兼容模式');
|
|
118
|
+
} else {
|
|
119
|
+
// v2 缺 allowFailOpen 字段时按 fail-open 兼容(显式 false 才关闭);
|
|
120
|
+
// 全新部署默认已定:fail-loud(初始 allowFailOpen=false)。
|
|
121
|
+
state.allowFailOpen = parsed.allowFailOpen !== false;
|
|
122
|
+
}
|
|
123
|
+
let loaded = 0;
|
|
124
|
+
let skipped = 0;
|
|
125
|
+
for (const raw of entries) {
|
|
126
|
+
const status = applyProfileEntry(raw, state.profiles, state.deletedBuiltins, logger);
|
|
127
|
+
if (status === 'loaded') loaded++;
|
|
128
|
+
else if (status === 'skipped') skipped++;
|
|
129
|
+
}
|
|
130
|
+
// Silent success: report how many persisted profiles came in (skipped
|
|
131
|
+
// when none — a missing/empty file is the normal first boot).
|
|
132
|
+
if (loaded > 0) logger.info(`[dsh-subagent-profile] loaded ${loaded} persisted profile(s)`);
|
|
133
|
+
if (skipped > 0) logger.warn(`[dsh-subagent-profile] skipped ${skipped} malformed profile entry(ies)`);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.warn('[dsh-subagent-profile] persisted profile load failed:', error instanceof Error ? error.message : String(error));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Persist every `persisted: true` profile plus builtin-delete tombstones.
|
|
141
|
+
* Atomic write: write `<profilesFile>.tmp` in the same directory,
|
|
142
|
+
* then `renameSync` over the target (a crash leaves the old file intact, never
|
|
143
|
+
* a truncated one). Fail-visible: a failure does NOT throw and does NOT
|
|
144
|
+
* roll back the in-memory `profiles` Map — it returns `{ persisted: false }` so
|
|
145
|
+
* the caller can signal "已保存但未持久化" while the in-memory state keeps
|
|
146
|
+
* driving this process. Always writes the v2 envelope shape.
|
|
147
|
+
*/
|
|
148
|
+
function persistProfiles(state, logger) {
|
|
149
|
+
const entries = [];
|
|
150
|
+
for (const profile of state.profiles.values()) {
|
|
151
|
+
if (profile.persisted !== true) continue;
|
|
152
|
+
const clean = {};
|
|
153
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
154
|
+
if (value === undefined || key === 'persisted') continue;
|
|
155
|
+
clean[key] = value;
|
|
156
|
+
}
|
|
157
|
+
entries.push(clean);
|
|
158
|
+
}
|
|
159
|
+
for (const id of state.deletedBuiltins) {
|
|
160
|
+
entries.push({ id, builtin: true, deleted: true });
|
|
161
|
+
}
|
|
162
|
+
const payload = { version: 2, profiles: entries, allowFailOpen: state.allowFailOpen };
|
|
163
|
+
const tmp = `${state.profilesFile}.tmp`;
|
|
164
|
+
try {
|
|
165
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
166
|
+
renameSync(tmp, state.profilesFile);
|
|
167
|
+
return { persisted: true };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Best-effort cleanup of the partial tmp file (rename never ran).
|
|
170
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
171
|
+
logger.warn('[dsh-subagent-profile] persisted profile write failed:', error instanceof Error ? error.message : String(error));
|
|
172
|
+
return { persisted: false, error: error instanceof Error ? error.message : String(error) };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The plugin's enable/disable switch. Persisted beside the profile list so a
|
|
177
|
+
// user can turn the dispatch tool off without uninstalling the bundle. Default
|
|
178
|
+
// is enabled; a missing/unreadable file falls back to enabled.
|
|
179
|
+
function loadEnabled(state) {
|
|
180
|
+
try {
|
|
181
|
+
if (existsSync(state.stateFile)) {
|
|
182
|
+
const parsed = JSON.parse(readFileSync(state.stateFile, 'utf8'));
|
|
183
|
+
return parsed && parsed.enabled !== false;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// fall through to enabled
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function persistEnabled(state, enabled) {
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(state.stateFile, JSON.stringify({ enabled }, null, 2), 'utf8');
|
|
194
|
+
} catch {
|
|
195
|
+
// best effort — the in-memory state still drives this process
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build one store instance. `dshHome` is the already-resolved home directory
|
|
200
|
+
// string (from the exported dshHome()); `logger` is the apply-time ctx.logger.
|
|
201
|
+
// Per-apply state (profiles / deletedBuiltins / paths / allowFailOpen) collects
|
|
202
|
+
// into `state` for the moved module-level functions. The returned surface is
|
|
203
|
+
// identical to the pre-split factory.
|
|
204
|
+
export function createProfileStore({ dshHome, logger }) {
|
|
205
|
+
const profiles = new Map(BUILTIN_SEEDS.map((p) => [p.id, { ...p }]));
|
|
206
|
+
const deletedBuiltins = new Set();
|
|
207
|
+
const state = {
|
|
208
|
+
profiles,
|
|
209
|
+
deletedBuiltins,
|
|
210
|
+
profilesFile: join(dshHome, 'subagent-profiles.json'),
|
|
211
|
+
stateFile: join(dshHome, 'subagent-profiles.state.json'),
|
|
212
|
+
allowFailOpen: false,
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
profiles,
|
|
216
|
+
deletedBuiltins,
|
|
217
|
+
resolveProfile: (id) => resolveProfile(profiles, id),
|
|
218
|
+
loadProfiles: () => loadProfiles(state, logger),
|
|
219
|
+
persistProfiles: () => persistProfiles(state, logger),
|
|
220
|
+
loadEnabled: () => loadEnabled(state),
|
|
221
|
+
persistEnabled: (enabled) => persistEnabled(state, enabled),
|
|
222
|
+
// getAllowFailOpen: the cost guard must read the migration flag at dispatch
|
|
223
|
+
// time; loadProfiles/persistProfiles own the mutation.
|
|
224
|
+
getAllowFailOpen: () => state.allowFailOpen,
|
|
225
|
+
};
|
|
226
|
+
}
|