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.
@@ -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
+ }