dsh-switchman 1.3.0 → 1.5.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.de.md +111 -0
- package/README.es.md +111 -0
- package/README.fr.md +111 -0
- package/README.it.md +111 -0
- package/README.ja.md +111 -0
- package/README.ko.md +111 -0
- package/README.md +58 -26
- package/README.pt.md +111 -0
- package/README.ru.md +111 -0
- package/README.zh-TW.md +111 -0
- package/README.zh.md +55 -23
- package/client.js +900 -17
- package/cordis.patch.yml +24 -8
- package/host/config.js +23 -0
- package/host/context-watch.js +267 -68
- package/host/dispatch.js +107 -6
- package/host/handover-state.js +38 -0
- package/host/lang.js +211 -36
- package/host/lib/dsh-home.js +18 -0
- package/host/routes.js +138 -7
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +144 -0
- package/host/teams.js +430 -0
- package/host/ui-locale.js +35 -0
- package/host/vision.js +367 -0
- package/index.js +40 -8
- package/package.json +4 -1
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) =>
|
|
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:
|
|
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
|
-
? "
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
14
|
-
* and never varies with UI locale, so bundled skills can match it
|
|
15
|
-
* textually.
|
|
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, ≤
|
|
75
|
-
|
|
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, ... })
|
|
130
|
-
* `result` the ToolExecutionResult ({ isError, value?, content, ... });
|
|
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
|
-
/**
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
+
}
|