dsh-switchman 1.3.1 → 1.6.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/host/dispatch.js CHANGED
@@ -62,10 +62,87 @@ function readBool(field) {
62
62
  return field === true;
63
63
  }
64
64
 
65
+ /** The rank.js model-segment extraction, verbatim (rank.js's
66
+ * normalizeModelKey ~lines 32-44): lowercase/trim, strip a `provider/` or
67
+ * bare-provider `provider:` prefix, drop `(...)`/`[...]` variant segments,
68
+ * fold illegal chars into "-" and trim edge punctuation. */
69
+ function modelSegment(model) {
70
+ let s = typeof model === "string" ? model : "";
71
+ s = s.toLowerCase().trim();
72
+ if (s.includes("/")) s = s.slice(s.lastIndexOf("/") + 1);
73
+ else if (s.includes(":")) {
74
+ const cut = s.lastIndexOf(":");
75
+ if (!s.slice(0, cut).includes("-")) s = s.slice(cut + 1);
76
+ }
77
+ s = s.replace(/\([^)]*\)/g, " ").replace(/\[[^\]]*\]/g, " ");
78
+ s = s.replace(/[^a-z0-9.]+/g, "-");
79
+ return s.replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
80
+ }
81
+
82
+ /** Provider-exact identity of one (provider, model) route for the drift
83
+ * comparison ONLY. normalizeModelKey builds capability-table keys: it
84
+ * folds away the provider and keeps just the model's last segment, so an
85
+ * authorized `b/m` would falsely clear pool entry `a/m` — but DSH
86
+ * authorizes exact (provider, model) pairs. Returns
87
+ * `provider + "\u0000" + modelSegment`, or "" when either side is empty
88
+ * ("" never matches an authorized key). */
89
+ function routeIdentity(provider, model) {
90
+ const p = typeof provider === "string" ? provider.trim() : "";
91
+ const m = modelSegment(model);
92
+ return p === "" || m === "" ? "" : `${p}\u0000${m}`;
93
+ }
94
+
95
+ /** The session's child-model authorization snapshot, read from the DSH
96
+ * host's `subagentModelSelection` service WITHOUT declaring inject (the
97
+ * same fail-open pattern routes.js's modelCatalogOf uses). The persisted
98
+ * shape names the list `allowedModels` while the inspect contract says
99
+ * `routes` — both are accepted. Keys are provider-exact route identities
100
+ * (routeIdentity), NOT capability-table keys: DSH authorizes exact
101
+ * (provider, model) pairs. Any missing service, error, or malformed
102
+ * value degrades to null: unknown means "render nothing", and this file
103
+ * family never throws on degraded capability. When enabled === false no
104
+ * explicit child model selection is allowed at all, so every pool route
105
+ * counts as unauthorized. */
106
+ function authorizedChildRoutes(ctx) {
107
+ let service;
108
+ try {
109
+ service = typeof ctx?.get === "function" ? ctx.get("subagentModelSelection", false) : undefined;
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (service === undefined || service === null || typeof service.current !== "function") return null;
114
+ try {
115
+ const snapshot = service.current();
116
+ if (snapshot === null || typeof snapshot !== "object" || typeof snapshot.enabled !== "boolean") return null;
117
+ const listed = Array.isArray(snapshot.routes)
118
+ ? snapshot.routes
119
+ : Array.isArray(snapshot.allowedModels)
120
+ ? snapshot.allowedModels
121
+ : [];
122
+ const routes = [];
123
+ const keys = new Set();
124
+ for (const entry of listed) {
125
+ if (!isModelRoute(entry)) continue;
126
+ const key = routeIdentity(entry.provider, entry.model);
127
+ if (key === "") continue;
128
+ keys.add(key);
129
+ routes.push({ provider: entry.provider, model: entry.model });
130
+ }
131
+ return { enabled: snapshot.enabled, keys, routes };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
65
137
  /** Live snapshot of the dispatch settings plus the bundled capability table.
66
138
  * Each lane carries its manual-order flag: manual=true lanes dispatch in
67
139
  * stored array order, auto lanes use the modelRank+capability ordering;
68
- * `efforts` holds the lane's manually pinned reasoning efforts. */
140
+ * `efforts` holds the lane's manually pinned reasoning efforts.
141
+ * `authorized` is the DSH-side child-model whitelist snapshot (null when
142
+ * unreadable — drift markers are then omitted). The detection only runs
143
+ * while teamsMode is ON: OFF removes it entirely (authorized stays null, so
144
+ * the ⚠not-authorized markers and their guidance never render), while the
145
+ * dispatchEnforce pool gate below is untouched. */
69
146
  function readDispatchState(ctx, config) {
70
147
  const lanePools = LANES.map((lane) => ({
71
148
  lane,
@@ -78,6 +155,7 @@ function readDispatchState(ctx, config) {
78
155
  modelRank: readList(config.modelRank).filter(isModelRoute),
79
156
  enforce: readField(config.dispatchEnforce),
80
157
  defaults: loadCapabilityDefaults(ctx?.logger),
158
+ authorized: readBool(config.teamsMode) === true ? authorizedChildRoutes(ctx) : null,
81
159
  };
82
160
  }
83
161
 
@@ -100,15 +178,28 @@ function effortMapOf(pool) {
100
178
  }
101
179
 
102
180
  /** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
103
- * then the guidance lines (byte-stable for a given configuration). */
181
+ * then the guidance lines (byte-stable for a given configuration). Routes
182
+ * outside the session's DSH-authorized child models carry a ⚠not-authorized
183
+ * marker (after any @effort suffix) — teams mode only: with teamsMode OFF
184
+ * the whitelist is never read, `state.authorized` stays null, and neither
185
+ * the markers nor the drift guidance lines render. */
104
186
  function renderPoolsBlock(state) {
105
187
  const configured = state.lanePools.filter((pool) => pool.routes.length > 0);
106
188
  const lines = [`[SWITCHMAN:POOLS] configured=${configured.length}/6 enforce=${state.enforce}`];
189
+ let anyUnauthorized = false;
107
190
  for (const pool of state.lanePools) {
108
191
  if (pool.routes.length === 0) continue;
109
192
  const effortOf = effortMapOf(pool);
110
193
  const routes = orderedPool(pool.routes, state.modelRank, state.defaults, pool.manual)
111
- .map((entry) => renderRoute(entry, effortOf.get(normalizeModelKey(entry.provider, entry.model)) ?? null))
194
+ .map((entry) => {
195
+ const rendered = renderRoute(entry, effortOf.get(normalizeModelKey(entry.provider, entry.model)) ?? null);
196
+ if (state.authorized === null) return rendered;
197
+ if (state.authorized.enabled === false || !state.authorized.keys.has(routeIdentity(entry.provider, entry.model))) {
198
+ anyUnauthorized = true;
199
+ return `${rendered} ⚠not-authorized`;
200
+ }
201
+ return rendered;
202
+ })
112
203
  .join(", ");
113
204
  lines.push(`${pool.lane}${pool.manual ? "*" : ""}: ${routes}`);
114
205
  }
@@ -117,10 +208,20 @@ function renderPoolsBlock(state) {
117
208
  `Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
118
209
  `effort: economy=medium mechanical=medium main=medium (high for complex tasks) hard=high vision=multimodal-capable model review=high; an unconfigured lane uses the system default model.`,
119
210
  `A route suffixed @effort has a manually pinned reasoning effort — pass it as reasoning_effort when delegating to that model (it overrides the lane default).`,
120
- `Review delegations: prefer a review-pool model different from the main session's when the pool offers one; reusing the same model is allowed (declare DOWNGRADED in the conclusion).`,
211
+ `Review delegations: anchor independence on the code AUTHOR's model, not the main session's (author = the agent that produced the diff: its explicit dispatch route, or the inherited session model when unspecified). Prefer a reviewer model different from the author's; also avoiding the session model is a bonus (its integration pass already covers that angle). If no pool entry differs from the author, same-model review is allowed — declare DOWNGRADED and state whether it is relative to the author or the session.`,
212
+ `Explicit provider/model/effort on subagent/workflow calls is validated against this session's DSH-authorized child models (DSH Settings: allow agents to choose subagent models) — NOT against this table; when denied, discover allowed routes via list_subagent_models, or dispatch implicitly by omitting all three fields (the child then uses configured defaults or inherits the parent route).`,
213
+ );
214
+ if (anyUnauthorized) {
215
+ lines.push(
216
+ state.authorized.enabled === false
217
+ ? `All routes above are marked ⚠not-authorized because explicit child model selection is currently disabled in DSH Settings (subagents: model selection) — every explicit provider/model/effort dispatch is denied; dispatch implicitly by omitting those fields until it is re-enabled.`
218
+ : `Routes marked ⚠not-authorized are pool entries outside this session's DSH-authorized child models — authorize them in DSH Settings (subagents: model selection), or dispatch them implicitly by omitting provider/model/effort.`,
219
+ );
220
+ }
221
+ lines.push(
121
222
  `This table does not restrict models outside the pools${
122
223
  state.enforce === "enforce"
123
- ? " (enforce mode: subagent calls naming a provider+model outside the pools or modelRank are denied)"
224
+ ? ". In addition, in enforce mode: subagent calls naming a provider+model outside the pools or modelRank are denied"
124
225
  : ""
125
226
  }.`,
126
227
  );
@@ -202,4 +303,4 @@ function applyDispatch(ctx, config) {
202
303
  });
203
304
  }
204
305
 
205
- export { applyDispatch };
306
+ export { applyDispatch, authorizedChildRoutes };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared in-flight handover registry (process-lifetime, never persisted).
3
+ *
4
+ * context-watch.js records phase transitions from the first synchronous
5
+ * line of runHandover (t=0, before the fork even starts); routes.js
6
+ * exposes the read-only snapshot behind GET /api/dsh-switchman/
7
+ * handover-state so the client badge can show a live "handover in
8
+ * progress" cue during the otherwise-silent fork+compact window.
9
+ */
10
+
11
+ /** sessionId -> { sessionId, phase, source?, attempt?, at }. */
12
+ const inflight = new Map();
13
+
14
+ /**
15
+ * Record (or overwrite) the live phase of one session's handover.
16
+ * @param {string} sessionId - the root session being handed over.
17
+ * @param {object} info - { phase: "backup"|"compacting"|"continuation",
18
+ * source?: "manual"|"tool"|"auto", attempt?: number, at: number }.
19
+ */
20
+ export function recordHandover(sessionId, info) {
21
+ if (sessionId === null || sessionId === undefined) return;
22
+ inflight.set(sessionId, { ...info, sessionId });
23
+ }
24
+
25
+ /** Drop the record once the handover settles (success or failure). */
26
+ export function clearHandover(sessionId) {
27
+ inflight.delete(sessionId);
28
+ }
29
+
30
+ /** Live record for one session, or undefined. */
31
+ export function handoverOf(sessionId) {
32
+ return inflight.get(sessionId);
33
+ }
34
+
35
+ /** Read-only array snapshot of every in-flight handover. */
36
+ export function handoverSnapshot() {
37
+ return [...inflight.values()];
38
+ }
package/host/lang.js CHANGED
@@ -1,22 +1,44 @@
1
1
  /** dsh-switchman language-preference layer (Phase 1).
2
2
  *
3
3
  * Ported from opencode-switchman's project language preference
4
- * (oc/src/lang-config.ts, oc/src/index.ts:1594-1604) onto DSH services:
5
- * the on-disk settings.json / AGENTS.md marker pair becomes this plugin's
6
- * volatile settings fields (live references — re-read at every assembly),
7
- * the per-model-request system transform becomes one dynamic systemPrompt
8
- * section (order 10300, after the persona suffix at 10200), and the
4
+ * (oc/src/lang-config.ts, oc/src/index.ts:1594-1604) onto DSH services.
5
+ * Two storage scopes for the three language slots (conversation / comments /
6
+ * docs), selected by the `langScope` setting:
7
+ *
8
+ * - "global" — this plugin's profile-level volatile settings fields (live
9
+ * references, re-read at every assembly; ask-once latch per process);
10
+ * - "project" — a per-project `<cwd>/.switchman/lang.json`, resolved from
11
+ * each session's own working directory (`context.agent.session.header.
12
+ * cwd`, the same source the watermark handover and dsh-agent-loop's
13
+ * "cwd" variable use). A session without a usable file is
14
+ * "unconfigured": it gets the one-shot ask guidance (latched per
15
+ * session, so every new session asks again until the file exists) and
16
+ * the captured answers are written into that file. A session whose
17
+ * header carries no cwd falls back to the global fields (fail-open).
18
+ *
19
+ * The per-model-request system transform becomes one dynamic systemPrompt
20
+ * section (order 10300, after the persona suffix at 10200); the
9
21
  * tool.execute.after capture becomes a `tools/post-execute` waterfall
10
- * listener.
22
+ * listener. The text callback must stay synchronous (dsh-system-prompt
23
+ * calls it without await), so project files are read with readFileSync —
24
+ * a sub-100-byte JSON once per step per session.
11
25
  *
12
- * Model-facing protocol text is English and byte-stable: the
13
- * `[SWITCHMAN:LANG]` anchor line is a function of the settings values only
14
- * and never varies with UI locale, so bundled skills can match it
15
- * textually. All parsing is defensive — any unexpected shape abandons
26
+ * Model-facing protocol text is English and byte-stable: the configured
27
+ * `[SWITCHMAN:LANG]` anchor line is a function of the effective values
28
+ * only and never varies with UI locale, so bundled skills can match it
29
+ * textually. The unconfigured ask guidance additionally names the language
30
+ * to phrase the three questions in — the DSH UI language when known
31
+ * (explicit settings preference, else the client-reported active locale,
32
+ * else English). All parsing is defensive — any unexpected shape abandons
16
33
  * silently (fail-open) and never disturbs the tool result flowing through
17
34
  * the waterfall. Zero new dependencies.
18
35
  */
19
36
 
37
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+
40
+ import { reportedUiLocale } from "./ui-locale.js";
41
+
20
42
  /** Marker embedded in the ask's question texts and matched on capture. */
21
43
  const LANG_ASK_MARKER = "switchman-lang";
22
44
 
@@ -26,6 +48,13 @@ const MAX_LANG_LEN = 48;
26
48
  /** Anchor-line value shown for an empty comments/docs field (= follow conversation). */
27
49
  const SAME = "same";
28
50
 
51
+ /** Project-scope config directory and file name (inside the session cwd). */
52
+ const PROJECT_DIR = ".switchman";
53
+ const PROJECT_FILE = "lang.json";
54
+
55
+ /** How long a cached settings-describe locale stays fresh (ms). */
56
+ const LOCALE_CACHE_MS = 10_000;
57
+
29
58
  /** Normalize one language value: trim and bound the length; anything else → null. */
30
59
  function normalizeLangValue(value) {
31
60
  if (typeof value !== "string") return null;
@@ -47,6 +76,11 @@ function readField(field) {
47
76
  return typeof field === "string" ? field : "";
48
77
  }
49
78
 
79
+ /** Current scope setting: "project" when explicitly chosen, else "global". */
80
+ function readScope(config) {
81
+ return readField(config?.langScope) === "project" ? "project" : "global";
82
+ }
83
+
50
84
  /** Current language-preference snapshot from the live plugin config. */
51
85
  function readLangPref(config) {
52
86
  return {
@@ -56,6 +90,52 @@ function readLangPref(config) {
56
90
  };
57
91
  }
58
92
 
93
+ /** Parse the project file's three slots; null when missing/broken/empty-file.
94
+ * Same semantics as the global fields: comments/docs "" = follow conversation. */
95
+ function readProjectPref(cwd) {
96
+ let raw;
97
+ try {
98
+ raw = readFileSync(join(cwd, PROJECT_DIR, PROJECT_FILE), "utf8");
99
+ } catch {
100
+ return null;
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(raw);
105
+ } catch {
106
+ return null;
107
+ }
108
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
109
+ const conversation = normalizeLangValue(parsed.langConversation);
110
+ if (conversation === null) return null;
111
+ return {
112
+ conversation,
113
+ comments: normalizeLangValue(parsed.langComments) ?? "",
114
+ docs: normalizeLangValue(parsed.langDocs) ?? "",
115
+ };
116
+ }
117
+
118
+ /** Persist a captured preference as the project's `<cwd>/.switchman/lang.json`. */
119
+ function writeProjectPref(cwd, pref) {
120
+ const directory = join(cwd, PROJECT_DIR);
121
+ const path = join(directory, PROJECT_FILE);
122
+ mkdirSync(directory, { recursive: true });
123
+ writeFileSync(
124
+ path,
125
+ `${JSON.stringify(
126
+ {
127
+ langConversation: pref.conversation,
128
+ langComments: pref.comments,
129
+ langDocs: pref.docs,
130
+ },
131
+ null,
132
+ "\t",
133
+ )}\n`,
134
+ "utf8",
135
+ );
136
+ return path;
137
+ }
138
+
59
139
  /** Configured block: byte-stable anchor line + English iron rules. */
60
140
  function renderLangBlock(pref) {
61
141
  const anchor = (value) => (value === "" ? SAME : value);
@@ -71,13 +151,15 @@ function renderLangBlock(pref) {
71
151
  ].join("\n");
72
152
  }
73
153
 
74
- /** Unconfigured block: gentle one-shot ask guidance (English, ≤6 lines, never blocking). */
75
- function renderAskGuidance() {
154
+ /** Unconfigured block: gentle one-shot ask guidance (English, ≤7 lines, never
155
+ * blocking). `askLanguage` names the language to phrase the questions in. */
156
+ function renderAskGuidance(askLanguage) {
76
157
  return [
77
158
  `[SWITCHMAN:LANG] not configured for this project yet.`,
78
159
  `Optionally, ask the user ONCE via a single ask_user_question call with exactly three questions —`,
79
160
  `conversation language, code-comments language, generated-documents language — and include the marker`,
80
161
  `"${LANG_ASK_MARKER}" in each question text so the answers are captured and persisted for you.`,
162
+ `Phrase the three questions and their answer options in ${askLanguage} (the user's UI language).`,
81
163
  `If the user declines or skips, infer the language from their current message and do not ask again.`,
82
164
  `Never block or delay any tool call on this preference; proceed with useful work regardless.`,
83
165
  ].join("\n");
@@ -125,23 +207,94 @@ function parseLangAnswers(questions, value) {
125
207
  return { conversation, comments: follow(rawAnswer(slots[1])), docs: follow(rawAnswer(slots[2])) };
126
208
  }
127
209
 
210
+ /** Session id off an agent (shared agent/session id; null when unusable). */
211
+ function sessionIdOf(agent) {
212
+ const id = agent?.session?.id ?? agent?.id;
213
+ return typeof id === "string" && id !== "" ? id : null;
214
+ }
215
+
216
+ /** True when the agent is a runtime root (owner-less); unknown registries
217
+ * count as root. Subagents cannot reach the user, so only roots may ask. */
218
+ function isRoot(ctx, agent) {
219
+ try {
220
+ const roots = ctx.agents?.roots?.();
221
+ return !Array.isArray(roots) || roots.includes(agent);
222
+ } catch {
223
+ return true;
224
+ }
225
+ }
226
+
227
+ /** Read the explicit DSH UI-language preference (settings ns "locale", field
228
+ * "preference"; "" when unset/unreadable). Cached — describe() serializes
229
+ * every settings schema, and the section text runs once per step. */
230
+ function readLocalePreference(ctx, cache) {
231
+ const now = Date.now();
232
+ if (cache.tag !== null && now - cache.at < LOCALE_CACHE_MS) return cache.tag;
233
+ cache.at = now;
234
+ try {
235
+ const described = ctx.settings?.describe?.();
236
+ if (Array.isArray(described)) {
237
+ const row = described.find((entry) => entry?.ns === "locale");
238
+ const preference = row?.value?.preference;
239
+ cache.tag = typeof preference === "string" ? preference.trim().toLowerCase() : "";
240
+ return cache.tag;
241
+ }
242
+ } catch {
243
+ // fall through
244
+ }
245
+ cache.tag = "";
246
+ return cache.tag;
247
+ }
248
+
249
+ /** Language name the ask guidance tells the agent to phrase questions in. */
250
+ function askLanguageOf(ctx, cache) {
251
+ const tag = readLocalePreference(ctx, cache) || reportedUiLocale();
252
+ return tag.startsWith("zh") ? "Chinese (简体中文)" : "English";
253
+ }
254
+
128
255
  /** Mount the language-preference layer: dynamic prompt section + answer capture.
129
- * `exec` here is the prepared ToolExecution ({ name, arguments, ... }) and
130
- * `result` the ToolExecutionResult ({ isError, value?, content, ... }); the
131
- * waterfall contract requires the listener to call and return next(). */
256
+ * `exec` here is the prepared ToolExecution ({ name, arguments, agent, ... })
257
+ * and `result` the ToolExecutionResult ({ isError, value?, content, ... });
258
+ * the waterfall contract requires the listener to call and return next(). */
132
259
  function applyLanguage(ctx, config) {
133
- /** Latched once any marker-carrying ask executes, so the ask guidance never nags
134
- * (a declined, pending, or unparsable ask still counts — fail-open, no re-ask). */
260
+ /** Global-scope ask latch (one ask per process, declined counts). */
135
261
  let asked = false;
262
+ /** Project-scope ask latches, per session id (each new session may ask). */
263
+ const askedSessions = new Set();
264
+ /** Cached locale-preference read (see readLocalePreference). */
265
+ const localeCache = { at: 0, tag: null };
266
+
267
+ /** Effective preference for one assembly: project scope reads the
268
+ * session's project file only (a missing file counts as unconfigured
269
+ * and asks — global fields never shadow it); a session without a
270
+ * usable cwd degrades to the global fields (fail-open). */
271
+ const effectivePref = (context) => {
272
+ if (readScope(config) !== "project") return readLangPref(config);
273
+ const cwd = context?.agent?.session?.header?.cwd;
274
+ if (typeof cwd !== "string" || cwd === "") return readLangPref(config);
275
+ return readProjectPref(cwd) ?? { conversation: "", comments: "", docs: "" };
276
+ };
136
277
 
137
278
  ctx.systemPrompt.section({
138
279
  name: "switchman:lang",
139
280
  order: 10300,
140
281
  interpolate: false,
141
- text: () => {
142
- const pref = readLangPref(config);
143
- if (pref.conversation !== "") return renderLangBlock(pref);
144
- return asked ? "" : renderAskGuidance();
282
+ text: (context) => {
283
+ try {
284
+ const agent = context?.agent;
285
+ const pref = effectivePref(context);
286
+ if (pref.conversation !== "") return renderLangBlock(pref);
287
+ // Unconfigured: one ask per process (global) or per session
288
+ // (project); subagents never see the ask guidance.
289
+ if (readScope(config) === "project") {
290
+ const sessionId = sessionIdOf(agent);
291
+ if (sessionId !== null ? askedSessions.has(sessionId) : asked) return "";
292
+ } else if (asked) return "";
293
+ if (!isRoot(ctx, agent)) return "";
294
+ return renderAskGuidance(askLanguageOf(ctx, localeCache));
295
+ } catch {
296
+ return "";
297
+ }
145
298
  },
146
299
  });
147
300
 
@@ -150,25 +303,47 @@ function applyLanguage(ctx, config) {
150
303
  if (exec?.name === "ask_user_question") {
151
304
  const questions = markerQuestions(exec.arguments);
152
305
  if (questions.length > 0) {
153
- asked = true;
306
+ // Latch before parsing: a declined, pending, or unparsable
307
+ // ask still counts — fail-open, no re-ask.
308
+ const sessionId = sessionIdOf(exec?.agent);
309
+ if (readScope(config) === "project" && sessionId !== null) askedSessions.add(sessionId);
310
+ else asked = true;
154
311
  const pref = parseLangAnswers(questions, result?.isError ? null : result?.value);
155
312
  if (pref !== null) {
156
- void ctx.settings
157
- .update("dsh-switchman-host", {
158
- langConversation: pref.conversation,
159
- langComments: pref.comments,
160
- langDocs: pref.docs,
161
- })
162
- .then(() => {
163
- ctx.logger.info(
164
- `dsh-switchman: language preferences saved (conversation=${pref.conversation} comments=${pref.comments} docs=${pref.docs})`,
313
+ const cwd = exec?.agent?.session?.header?.cwd;
314
+ if (readScope(config) === "project" && typeof cwd === "string" && cwd !== "") {
315
+ // Project scope: persist into the project file
316
+ // (a failed write logs and drops — the answer is
317
+ // re-asked in the next session, never leaked into
318
+ // the inert global fields).
319
+ try {
320
+ const path = writeProjectPref(cwd, pref);
321
+ ctx.logger?.info?.(
322
+ `dsh-switchman: language preferences saved to ${path} (conversation=${pref.conversation} comments=${pref.comments || SAME} docs=${pref.docs || SAME})`,
165
323
  );
166
- })
167
- .catch((error) => {
168
- ctx.logger.warn(
169
- `dsh-switchman: failed to save language preferences: ${error?.message ?? error}`,
324
+ } catch (error) {
325
+ ctx.logger?.warn?.(
326
+ `dsh-switchman: failed to write project language preferences: ${error?.message ?? error}`,
170
327
  );
171
- });
328
+ }
329
+ } else {
330
+ void ctx.settings
331
+ .update("dsh-switchman-host", {
332
+ langConversation: pref.conversation,
333
+ langComments: pref.comments,
334
+ langDocs: pref.docs,
335
+ })
336
+ .then(() => {
337
+ ctx.logger.info(
338
+ `dsh-switchman: language preferences saved (conversation=${pref.conversation} comments=${pref.comments} docs=${pref.docs})`,
339
+ );
340
+ })
341
+ .catch((error) => {
342
+ ctx.logger.warn(
343
+ `dsh-switchman: failed to save language preferences: ${error?.message ?? error}`,
344
+ );
345
+ });
346
+ }
172
347
  }
173
348
  }
174
349
  }
@@ -0,0 +1,18 @@
1
+ /** Shared <DSH_HOME> derivation. Explicit env override wins (with ~
2
+ * expansion), else the per-user default. Mirrors the host's own
3
+ * resolution order (dsh-home-paths' resolveDshHome), so plugin-side file
4
+ * lookups (attachments store, session projcache records) land on the same
5
+ * directories the host reads and writes. */
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+
9
+ /** Best-effort <DSH_HOME> derivation: explicit env override wins, else
10
+ * the per-user default. */
11
+ export function dshHome() {
12
+ const env = process.env.DSH_HOME;
13
+ if (typeof env === "string" && env.trim() !== "") {
14
+ const expanded = env.startsWith("~") ? join(homedir(), env.slice(1)) : env;
15
+ return resolve(expanded);
16
+ }
17
+ return join(homedir(), ".dsh");
18
+ }