dsh-switchman 1.3.1 → 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 +61 -29
- package/README.es.md +61 -29
- package/README.fr.md +60 -28
- package/README.it.md +53 -21
- package/README.ja.md +58 -26
- package/README.ko.md +54 -22
- package/README.md +57 -25
- package/README.pt.md +54 -22
- package/README.ru.md +61 -29
- package/README.zh-TW.md +57 -25
- package/README.zh.md +54 -22
- 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 +1 -1
package/host/teams.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/** dsh-switchman Agent Teams layer (teams mode).
|
|
2
|
+
*
|
|
3
|
+
* Owns both switchman-side halves of the teams mode:
|
|
4
|
+
*
|
|
5
|
+
* - The dynamic `[SWITCHMAN:TEAMS]` systemPrompt section (order 10250, after
|
|
6
|
+
* the persona suffix at 10200, before the language section at 10300). The
|
|
7
|
+
* clauses it renders are exactly the team-specific parts extracted from
|
|
8
|
+
* the shared persona-suffix anchor in cordis.patch.yml (conservative-team-
|
|
9
|
+
* policy reversal, spawn_teammate trigger conditions, shared task-board
|
|
10
|
+
* discipline); they are injected only while the live `teamsMode` setting
|
|
11
|
+
* is ON, so OFF falls back to the DSH factory conservative team policy
|
|
12
|
+
* with zero residue (same live-reference pattern as dispatch.js's POOLS).
|
|
13
|
+
* - The ON-mode async orchestration, ALWAYS deferred through setImmediate so
|
|
14
|
+
* it never runs synchronously inside plugin apply (bundle changes must not
|
|
15
|
+
* nest inside the apply HMR transaction — boot/hmr forbids it): first
|
|
16
|
+
* `pluginManager.setBundleEnabled('@deepseek-ai/dsh-experimental-agent-
|
|
17
|
+
* team-profile', true)` (latched per process; never disabled on OFF), then
|
|
18
|
+
* — only when `syncWhitelist` is also ON — one replace-sync of the six-
|
|
19
|
+
* pool union into DSH's `subagent-model-selection-settings` whitelist
|
|
20
|
+
* (`settings.describe()` → `settings.mutate(...)`, re-describing and
|
|
21
|
+
* retrying up to 3 attempts on SettingsConflictError). Replace semantics:
|
|
22
|
+
* switchman is the single source of truth, so the deduplicated exact
|
|
23
|
+
* (provider, model) union overwrites allowedModels wholesale; an empty
|
|
24
|
+
* union skips the write (DSH rejects enabled=true with an empty list).
|
|
25
|
+
* Enforcement is a per-new-session snapshot, so a sync only affects
|
|
26
|
+
* top-level sessions composed after it.
|
|
27
|
+
*
|
|
28
|
+
* Trigger points: plugin apply (applyTeams schedules once), every successful
|
|
29
|
+
* POST /api/dsh-switchman/config (routes.js calls scheduleTeamsOrchestration),
|
|
30
|
+
* and settings/document-updated for this plugin's own namespace (volatile
|
|
31
|
+
* commits never remount the plugin, so edits through DSH's own settings UI
|
|
32
|
+
* must also trigger). Every async path catches and warns — nothing ever
|
|
33
|
+
* throws into the caller. The last sync outcome is module state, snapshotted
|
|
34
|
+
* by routes.js's whitelistSync config field.
|
|
35
|
+
*
|
|
36
|
+
* One caveat the deferral cannot fix: a setImmediate callback inherits the
|
|
37
|
+
* scheduling context's AsyncLocalStorage store, so when apply itself runs
|
|
38
|
+
* inside an HMR transaction (bundle re-enable / code reload with teamsMode
|
|
39
|
+
* persisted ON), the deferred run is STILL rejected as nested. That
|
|
40
|
+
* rejection is detected, recorded in pendingNestedRetry, and pumped again
|
|
41
|
+
* from the next clean-context trigger (the GET config and vision-state HTTP
|
|
42
|
+
* handlers call pumpTeamsOrchestration). Fail-open either way: a missed run
|
|
43
|
+
* only delays the bundle enable / whitelist sync until the next trigger.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { LANES, laneField } from "./config.js";
|
|
47
|
+
|
|
48
|
+
/** The shipped bundle carrying spawn_teammate / team_task_* / send_message /
|
|
49
|
+
* wait_agent (Host composition; enabling it from here never double-registers
|
|
50
|
+
* tools — this bundle contributes no team plugins). */
|
|
51
|
+
const TEAMS_BUNDLE = "@deepseek-ai/dsh-experimental-agent-team-profile";
|
|
52
|
+
|
|
53
|
+
/** DSH settings namespace of the subagent child-model whitelist. */
|
|
54
|
+
const WHITELIST_NS = "subagent-model-selection-settings";
|
|
55
|
+
|
|
56
|
+
/** This plugin's own settings namespace — the LOADER ENTRY id from
|
|
57
|
+
* cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
|
|
58
|
+
* export (dsh-settings' write()/events key entries by entry.options.id).
|
|
59
|
+
* routes.js imports this for its config writes; the document-updated
|
|
60
|
+
* listener below keys on it. */
|
|
61
|
+
const SWITCHMAN_SETTINGS_NS = "dsh-switchman-host";
|
|
62
|
+
|
|
63
|
+
/** Total mutate attempts per sync; each retry re-describes for a fresh
|
|
64
|
+
* revision (SettingsConflictError means another writer changed the row). */
|
|
65
|
+
const MAX_MUTATE_ATTEMPTS = 3;
|
|
66
|
+
|
|
67
|
+
/** The teams clauses, verbatim from the cordis.patch.yml suffix anchor they
|
|
68
|
+
* were extracted from. Glue lines (the marker header and the section title)
|
|
69
|
+
* are the only new text. */
|
|
70
|
+
const TEAMS_SECTION_TEXT = [
|
|
71
|
+
"[SWITCHMAN:TEAMS] Agent Teams 模式已启用。本段是 persona suffix 调度规程动态注入的团队分支(编号续接其「四、边界」)。",
|
|
72
|
+
"",
|
|
73
|
+
"系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。",
|
|
74
|
+
"",
|
|
75
|
+
"## 五、团队触发与协作纪律",
|
|
76
|
+
"- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。",
|
|
77
|
+
"- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
// ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
|
|
81
|
+
|
|
82
|
+
/** Read one volatile settings field's raw value (live `.get()` reference). */
|
|
83
|
+
function readValue(field) {
|
|
84
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
85
|
+
try {
|
|
86
|
+
return field.get();
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return field;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Boolean volatile-field read (false on any malformed value). */
|
|
95
|
+
function readBool(field) {
|
|
96
|
+
return readValue(field) === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- module state ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Last whitelist replace-sync outcome for routes.js's whitelistSync config
|
|
102
|
+
* field; null when no sync has run in this process. Shape:
|
|
103
|
+
* { ok: boolean, count: number, at: number, error?: string }. */
|
|
104
|
+
let lastSync = null;
|
|
105
|
+
|
|
106
|
+
/** Last bundle-ensure outcome for routes.js's teamsBundle field; null before
|
|
107
|
+
* the first orchestration. Shape: { ok, at, state, error? } with state in
|
|
108
|
+
* selected | applied | restart-required | failed | cancelled | <unknown
|
|
109
|
+
* application value> | unavailable. */
|
|
110
|
+
let lastBundleState = null;
|
|
111
|
+
|
|
112
|
+
/** Set when a run must be retried from a clean context: either it was
|
|
113
|
+
* rejected as "HMR transactions cannot be nested" (the setImmediate
|
|
114
|
+
* deferral cannot escape the AsyncLocalStorage store the scheduling context
|
|
115
|
+
* carried — boot/hmr's runExclusive checks that store), or
|
|
116
|
+
* pluginManager.setBundleEnabled RESOLVED with a failed/cancelled
|
|
117
|
+
* application (no exception ever reaches the central catch). The next
|
|
118
|
+
* clean-context HTTP trigger pumps a retry; retries are strictly
|
|
119
|
+
* request-driven, never self-scheduling, so there is no retry loop. */
|
|
120
|
+
let pendingNestedRetry = false;
|
|
121
|
+
|
|
122
|
+
/** Single-flight guard: the apply + POST + document-updated triggers may
|
|
123
|
+
* overlap, but at most one orchestration runs at a time — requests
|
|
124
|
+
* arriving mid-run collapse into one rerun. */
|
|
125
|
+
let orchestrating = false;
|
|
126
|
+
let rerunRequested = false;
|
|
127
|
+
|
|
128
|
+
// ---- orchestration ---------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** Whether an error is the HMR nesting rejection: boot/hmr's runExclusive
|
|
131
|
+
* throws exactly "HMR transactions cannot be nested" when the AsyncLocalStorage
|
|
132
|
+
* store is already set, and setImmediate callbacks inherit that store from
|
|
133
|
+
* the scheduling context (e.g. an apply that itself ran in a transaction). */
|
|
134
|
+
function isNestedHmrError(error) {
|
|
135
|
+
return /cannot be nested/iu.test(String(error?.message ?? ""));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Deduplicated exact (provider, model) union of the six pools. Exact string
|
|
139
|
+
* identity (trimmed) — DSH's whitelist authorizes exact pairs and its
|
|
140
|
+
* assertAllowedModelRoutes rejects repeated provider/model routes outright. */
|
|
141
|
+
function poolUnion(config) {
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
const routes = [];
|
|
144
|
+
for (const lane of LANES) {
|
|
145
|
+
const raw = readValue(config?.[laneField(lane)]);
|
|
146
|
+
if (!Array.isArray(raw)) continue;
|
|
147
|
+
for (const entry of raw) {
|
|
148
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
149
|
+
const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
|
|
150
|
+
const model = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
151
|
+
if (provider === "" || model === "") continue;
|
|
152
|
+
const key = `${provider}\u0000${model}`;
|
|
153
|
+
if (seen.has(key)) continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
routes.push({ provider, model });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return routes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Ensure the shipped agent-team-profile bundle layer is selected in the
|
|
162
|
+
* profile. pluginManager.setBundleEnabled NEVER rejects: its change
|
|
163
|
+
* transaction swallows every error into a resolved
|
|
164
|
+
* {application:'failed', error} (cancellations into 'cancelled'), while
|
|
165
|
+
* 'restart-required' means the manifest was persisted and the tools
|
|
166
|
+
* activate after a host restart (boot/plugin-manager/src/index.ts:795-819).
|
|
167
|
+
* Treating that resolved value as success latches a false positive and
|
|
168
|
+
* suppresses every later retry — so only verified outcomes latch, and a
|
|
169
|
+
* listBundles pre-check both short-circuits the already-enabled case and
|
|
170
|
+
* converges with disk truth after a late failure (selectBundle persists
|
|
171
|
+
* before reload can throw). There is deliberately NO process-level success
|
|
172
|
+
* latch — the DSH-side toggle can be flipped off at any time, so every
|
|
173
|
+
* orchestration re-verifies via listBundles and re-enables when needed.
|
|
174
|
+
* A missing pluginManager service only warns; the doctrine section still
|
|
175
|
+
* renders, the tools simply stay absent. */
|
|
176
|
+
async function ensureTeamsBundle(ctx) {
|
|
177
|
+
const manager = ctx.get?.("pluginManager", false);
|
|
178
|
+
if (manager === undefined || manager === null || typeof manager.setBundleEnabled !== "function") {
|
|
179
|
+
lastBundleState = { ok: false, at: Date.now(), state: "unavailable" };
|
|
180
|
+
ctx.logger?.warn?.("dsh-switchman: pluginManager service unavailable; agent-team bundle not enabled");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (typeof manager.listBundles === "function") {
|
|
184
|
+
try {
|
|
185
|
+
const bundles = await manager.listBundles();
|
|
186
|
+
const selected = Array.isArray(bundles)
|
|
187
|
+
? bundles.find((item) => item?.name === TEAMS_BUNDLE)
|
|
188
|
+
: undefined;
|
|
189
|
+
if (selected?.enabled === true) {
|
|
190
|
+
// Already selected in the profile manifest: no call, no reload —
|
|
191
|
+
// and deliberately NO process-level latch. The user may flip the
|
|
192
|
+
// DSH-side toggle off at any moment; every orchestration re-reads
|
|
193
|
+
// this disk truth instead (live-verified regression: a boot-time
|
|
194
|
+
// latch froze the state right before the user toggled DSH off).
|
|
195
|
+
lastBundleState = { ok: true, at: Date.now(), state: "selected" };
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Unreadable manifest: fall through and let setBundleEnabled decide.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const result = await manager.setBundleEnabled(TEAMS_BUNDLE, true);
|
|
203
|
+
// Whitelist latch: only the two verified-persisted outcomes count. Anything
|
|
204
|
+
// else — failed, cancelled, or a future unknown application value — warns
|
|
205
|
+
// and stays unlatched so the next trigger retries (unknown values must NOT
|
|
206
|
+
// be assumed successful; that is exactly the false-positive this fix ends).
|
|
207
|
+
const outcome = typeof result?.application === "string" ? result.application : "applied";
|
|
208
|
+
if (outcome !== "applied" && outcome !== "restart-required") {
|
|
209
|
+
// A resolved failure raises no exception, so isNestedHmrError and
|
|
210
|
+
// orchestrateTeams' central catch never see it. Arm the clean-context
|
|
211
|
+
// pump here: the next GET (Switchman page view, post-save refresh)
|
|
212
|
+
// retries from a plain HTTP context, where transient causes — an HMR
|
|
213
|
+
// transaction around reload(), a package.json lock held by a boot-time
|
|
214
|
+
// pnpm/refresh — are gone. Verified live: the identical call succeeds
|
|
215
|
+
// when made from a clean context moments after failing in-page.
|
|
216
|
+
pendingNestedRetry = true;
|
|
217
|
+
lastBundleState = {
|
|
218
|
+
ok: false,
|
|
219
|
+
at: Date.now(),
|
|
220
|
+
state: outcome,
|
|
221
|
+
error: typeof result?.error === "string" ? result.error : result?.error?.message,
|
|
222
|
+
};
|
|
223
|
+
ctx.logger?.warn?.(
|
|
224
|
+
`dsh-switchman: agent-team bundle enable ${outcome}: `
|
|
225
|
+
+ `${result?.error?.message ?? result?.error ?? "unknown error"} — retrying on the next trigger`,
|
|
226
|
+
);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
lastBundleState = { ok: true, at: Date.now(), state: outcome };
|
|
230
|
+
if (outcome === "restart-required") {
|
|
231
|
+
ctx.logger?.info?.("dsh-switchman: agent-team bundle persisted; restart DSH to activate team tools in new sessions");
|
|
232
|
+
} else {
|
|
233
|
+
ctx.logger?.info?.(`dsh-switchman: agent-team bundle enabled (${TEAMS_BUNDLE})`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One replace-sync of the six-pool union into the DSH whitelist. describe →
|
|
238
|
+
* mutate with the fresh revision; SettingsConflictError retries (re-describe
|
|
239
|
+
* first) up to MAX_MUTATE_ATTEMPTS total. Every terminal outcome lands in
|
|
240
|
+
* the module-level lastSync status; nothing throws. */
|
|
241
|
+
async function syncWhitelist(ctx, config) {
|
|
242
|
+
const routes = poolUnion(config);
|
|
243
|
+
if (routes.length === 0) {
|
|
244
|
+
// DSH's invariant: enabled=true with an empty allowedModels breaks
|
|
245
|
+
// every new session's composition — skipping is the only safe move.
|
|
246
|
+
lastSync = {
|
|
247
|
+
ok: false,
|
|
248
|
+
count: 0,
|
|
249
|
+
at: Date.now(),
|
|
250
|
+
error: "six-pool union is empty; sync skipped (enabled=true requires a non-empty allowedModels)",
|
|
251
|
+
};
|
|
252
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync skipped: ${lastSync.error}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const settings = ctx.settings;
|
|
256
|
+
if (settings === null || typeof settings !== "object" || typeof settings.describe !== "function" || typeof settings.mutate !== "function") {
|
|
257
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: "settings service unavailable" };
|
|
258
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (let attempt = 1; attempt <= MAX_MUTATE_ATTEMPTS; attempt += 1) {
|
|
262
|
+
let revision;
|
|
263
|
+
try {
|
|
264
|
+
const described = settings.describe();
|
|
265
|
+
const row = Array.isArray(described) ? described.find((entry) => entry?.ns === WHITELIST_NS) : undefined;
|
|
266
|
+
if (row === undefined || !Number.isFinite(row.revision)) {
|
|
267
|
+
lastSync = {
|
|
268
|
+
ok: false,
|
|
269
|
+
count: routes.length,
|
|
270
|
+
at: Date.now(),
|
|
271
|
+
error: `settings namespace "${WHITELIST_NS}" not found or carries no revision`,
|
|
272
|
+
};
|
|
273
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
revision = row.revision;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
// Drift-guard: describe() is a synchronous file read in current DSH
|
|
279
|
+
// (it never crosses runExclusive), so this branch is unreachable
|
|
280
|
+
// today — kept in case a future revision routes it through the
|
|
281
|
+
// editor lock.
|
|
282
|
+
if (isNestedHmrError(error)) {
|
|
283
|
+
pendingNestedRetry = true;
|
|
284
|
+
ctx.logger?.info?.("dsh-switchman: whitelist describe deferred (HMR transaction active); the next clean-context trigger retries");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
|
|
288
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist describe failed: ${lastSync.error}`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await settings.mutate(
|
|
293
|
+
WHITELIST_NS,
|
|
294
|
+
[
|
|
295
|
+
{ op: "set", path: ["enabled"], value: true },
|
|
296
|
+
{ op: "set", path: ["allowedModels"], value: routes },
|
|
297
|
+
],
|
|
298
|
+
revision,
|
|
299
|
+
);
|
|
300
|
+
lastSync = { ok: true, count: routes.length, at: Date.now() };
|
|
301
|
+
ctx.logger?.info?.(
|
|
302
|
+
`dsh-switchman: whitelist replace-synced (${routes.length} routes; applies to newly composed top-level sessions only)`,
|
|
303
|
+
);
|
|
304
|
+
return;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// Conflict = another writer changed the row between describe and
|
|
307
|
+
// mutate: re-describe and retry. A nested-HMR rejection defers the
|
|
308
|
+
// whole sync to the next clean trigger (transient — no failure
|
|
309
|
+
// status is recorded). Anything else is terminal.
|
|
310
|
+
if (isNestedHmrError(error)) {
|
|
311
|
+
pendingNestedRetry = true;
|
|
312
|
+
ctx.logger?.info?.("dsh-switchman: whitelist sync deferred (HMR transaction active); the next clean-context trigger retries");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (error?.name === "SettingsConflictError" && attempt < MAX_MUTATE_ATTEMPTS) continue;
|
|
316
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
|
|
317
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed after ${attempt} attempt(s): ${lastSync.error}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The orchestration behind the two INDEPENDENT switches: teamsMode drives
|
|
324
|
+
* the bundle ensure (+ the doctrine section, handled elsewhere);
|
|
325
|
+
* syncWhitelist drives the whitelist replace-sync — either may be on alone.
|
|
326
|
+
* Single-flight (see orchestrating); every failure is caught and warned —
|
|
327
|
+
* a nested-HMR rejection arms the clean-context retry pump instead —
|
|
328
|
+
* never rethrown. */
|
|
329
|
+
async function orchestrateTeams(ctx, config) {
|
|
330
|
+
if (orchestrating) {
|
|
331
|
+
rerunRequested = true;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
orchestrating = true;
|
|
335
|
+
try {
|
|
336
|
+
if (readBool(config?.teamsMode) === true) {
|
|
337
|
+
await ensureTeamsBundle(ctx).catch((error) => {
|
|
338
|
+
// A nested rejection must defer the WHOLE run (the whitelist
|
|
339
|
+
// sync would hit the same wall) — rethrow to the central catch.
|
|
340
|
+
if (isNestedHmrError(error)) throw error;
|
|
341
|
+
ctx.logger?.warn?.(`dsh-switchman: agent-team bundle enable failed: ${error?.message ?? error}`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (readBool(config?.syncWhitelist) === true) await syncWhitelist(ctx, config);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (isNestedHmrError(error)) {
|
|
347
|
+
pendingNestedRetry = true;
|
|
348
|
+
ctx.logger?.info?.("dsh-switchman: teams orchestration deferred (HMR transaction active); the next clean-context trigger retries");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration failed: ${error?.message ?? error}`);
|
|
352
|
+
} finally {
|
|
353
|
+
orchestrating = false;
|
|
354
|
+
if (rerunRequested) {
|
|
355
|
+
rerunRequested = false;
|
|
356
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Last whitelist replace-sync outcome ({ ok, count, at, error? } | null).
|
|
362
|
+
* Raw module state — routes.js gates exposure on teamsMode && syncWhitelist. */
|
|
363
|
+
function teamsBundleState() {
|
|
364
|
+
return lastBundleState === null ? null : { ...lastBundleState };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function teamsSyncSnapshot() {
|
|
368
|
+
return lastSync;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Fire-and-forget orchestration trigger. Safe from any code path (plugin
|
|
372
|
+
* apply, HTTP handlers): the work always lands on a setImmediate turn, so
|
|
373
|
+
* nothing runs synchronously inside apply or a route handler. The deferral
|
|
374
|
+
* does NOT escape an enclosing HMR transaction's AsyncLocalStorage store —
|
|
375
|
+
* a nested rejection is detected here and retried from the next clean
|
|
376
|
+
* trigger (see pendingNestedRetry / pumpTeamsOrchestration). */
|
|
377
|
+
function scheduleTeamsOrchestration(ctx, config) {
|
|
378
|
+
try {
|
|
379
|
+
// Either switch alone is a reason to run (they are independent).
|
|
380
|
+
if (readBool(config?.teamsMode) !== true && readBool(config?.syncWhitelist) !== true) return;
|
|
381
|
+
setImmediate(() => {
|
|
382
|
+
Promise.resolve(orchestrateTeams(ctx, config)).catch((error) => {
|
|
383
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration crashed: ${error?.message ?? error}`);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration scheduling failed: ${error?.message ?? error}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Retry pump for runs rejected as nested: call from clean-context code
|
|
392
|
+
* paths (HTTP handlers never run inside an HMR transaction). A no-op
|
|
393
|
+
* single-boolean check when nothing is pending; a retry that fails nested
|
|
394
|
+
* again simply re-arms the flag, so retries stay request-driven. */
|
|
395
|
+
function pumpTeamsOrchestration(ctx, config) {
|
|
396
|
+
if (!pendingNestedRetry) return;
|
|
397
|
+
pendingNestedRetry = false;
|
|
398
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Mount the teams layer: the dynamic `[SWITCHMAN:TEAMS]` section (empty
|
|
402
|
+
* while OFF — the live reference makes toggling instant) plus the
|
|
403
|
+
* orchestration triggers (apply-time scheduling and settings-document
|
|
404
|
+
* updates for this plugin's own namespace). */
|
|
405
|
+
function applyTeams(ctx, config) {
|
|
406
|
+
ctx.systemPrompt.section({
|
|
407
|
+
name: "switchman:teams",
|
|
408
|
+
order: 10250,
|
|
409
|
+
interpolate: false,
|
|
410
|
+
text: () => {
|
|
411
|
+
try {
|
|
412
|
+
if (readBool(config.teamsMode) !== true) return "";
|
|
413
|
+
return TEAMS_SECTION_TEXT;
|
|
414
|
+
} catch {
|
|
415
|
+
return "";
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
// Volatile commits never remount the plugin, so teamsMode/syncWhitelist
|
|
420
|
+
// (or pool) edits through DSH's OWN settings UI — which bypass our POST
|
|
421
|
+
// config route — must re-trigger the orchestration too. The settings
|
|
422
|
+
// service emits this app-wide with no emitting thisArg, so a listener on
|
|
423
|
+
// this plugin's context receives every namespace's event; filter on ns.
|
|
424
|
+
ctx.on("settings/document-updated", (ns) => {
|
|
425
|
+
if (ns === SWITCHMAN_SETTINGS_NS) scheduleTeamsOrchestration(ctx, config);
|
|
426
|
+
});
|
|
427
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** dsh-switchman UI-locale bridge store (host half).
|
|
2
|
+
*
|
|
3
|
+
* The DeepSeek Harness UI language resolves on the renderer as
|
|
4
|
+
* Host preference (settings ns "locale", field "preference") ??
|
|
5
|
+
* navigator.languages ?? "en" (dsh-client-locale/lib/client.js). The host
|
|
6
|
+
* half can read only the first link — when the user never explicitly chose
|
|
7
|
+
* a language there is no host-side trace of the navigator fallback. So the
|
|
8
|
+
* browser half reports the final active locale through
|
|
9
|
+
* POST /api/dsh-switchman/ui-locale whenever it mounts; lang.js merges the
|
|
10
|
+
* two sources (explicit preference wins, reported locale fills the gap).
|
|
11
|
+
*
|
|
12
|
+
* Module-level singleton on purpose: exactly one host bundle instance runs
|
|
13
|
+
* per profile process, and the route family shares the value with the
|
|
14
|
+
* language layer without extra wiring. Fail-open everywhere.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Last locale tag reported by the client half ("" = never reported). */
|
|
18
|
+
let reported = "";
|
|
19
|
+
|
|
20
|
+
/** Normalize a reported tag: lowercase, trimmed, bounded (defensive). */
|
|
21
|
+
function normalizeTag(value) {
|
|
22
|
+
if (typeof value !== "string") return "";
|
|
23
|
+
const tag = value.trim().toLowerCase();
|
|
24
|
+
return tag.length > 0 && tag.length <= 16 ? tag : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Store the client-reported active UI locale (unusable values ignored). */
|
|
28
|
+
export function setReportedUiLocale(value) {
|
|
29
|
+
reported = normalizeTag(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The client-reported active UI locale ("" when none arrived yet). */
|
|
33
|
+
export function reportedUiLocale() {
|
|
34
|
+
return reported;
|
|
35
|
+
}
|