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/README.de.md +56 -34
- package/README.es.md +53 -31
- package/README.fr.md +52 -30
- package/README.it.md +47 -25
- package/README.ja.md +54 -32
- package/README.ko.md +55 -33
- package/README.md +53 -31
- package/README.pt.md +47 -25
- package/README.ru.md +52 -30
- package/README.zh-TW.md +52 -30
- package/README.zh.md +46 -24
- package/client.js +2344 -40
- package/cordis.patch.yml +24 -8
- package/host/config.js +31 -0
- package/host/context-watch.js +424 -84
- 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 +143 -8
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +160 -0
- package/host/teams.js +583 -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/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
package/host/teams.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
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
|
+
* - The teams-gated `child_message` bridge tool: enabling the agent-team
|
|
29
|
+
* bundle REPLACES the global send_message with a team variant that
|
|
30
|
+
* resolves TEAMMATE NAMES only, after which subagent child session UUIDs
|
|
31
|
+
* are not addressable at all. The bridge restores child addressing through
|
|
32
|
+
* ctx.subagents.sendMessage (sender = the exact live calling agent,
|
|
33
|
+
* exec.agent), registered while the live `teamsMode` setting reads ON and
|
|
34
|
+
* disposed again the moment it reads OFF (same live-reference gating as
|
|
35
|
+
* the doctrine section).
|
|
36
|
+
*
|
|
37
|
+
* Trigger points: plugin apply (applyTeams schedules once), every successful
|
|
38
|
+
* POST /api/dsh-switchman/config (routes.js calls scheduleTeamsOrchestration),
|
|
39
|
+
* and settings/document-updated for this plugin's own namespace (volatile
|
|
40
|
+
* commits never remount the plugin, so edits through DSH's own settings UI
|
|
41
|
+
* must also trigger). Every async path catches and warns — nothing ever
|
|
42
|
+
* throws into the caller. The last sync outcome is module state, snapshotted
|
|
43
|
+
* by routes.js's whitelistSync config field.
|
|
44
|
+
*
|
|
45
|
+
* One caveat the deferral cannot fix: a setImmediate callback inherits the
|
|
46
|
+
* scheduling context's AsyncLocalStorage store, so when apply itself runs
|
|
47
|
+
* inside an HMR transaction (bundle re-enable / code reload with teamsMode
|
|
48
|
+
* persisted ON), the deferred run is STILL rejected as nested. That
|
|
49
|
+
* rejection is detected, recorded in pendingNestedRetry, and pumped again
|
|
50
|
+
* from the next clean-context trigger (the GET config and vision-state HTTP
|
|
51
|
+
* handlers call pumpTeamsOrchestration). Fail-open either way: a missed run
|
|
52
|
+
* only delays the bundle enable / whitelist sync until the next trigger.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { LANES, laneField } from "./config.js";
|
|
56
|
+
|
|
57
|
+
/** The shipped bundle carrying spawn_teammate / team_task_* / send_message /
|
|
58
|
+
* wait_agent (Host composition; enabling it from here never double-registers
|
|
59
|
+
* tools — this bundle contributes no team plugins). */
|
|
60
|
+
const TEAMS_BUNDLE = "@deepseek-ai/dsh-experimental-agent-team-profile";
|
|
61
|
+
|
|
62
|
+
/** DSH settings namespace of the subagent child-model whitelist. */
|
|
63
|
+
const WHITELIST_NS = "subagent-model-selection-settings";
|
|
64
|
+
|
|
65
|
+
/** This plugin's own settings namespace — the LOADER ENTRY id from
|
|
66
|
+
* cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
|
|
67
|
+
* export (dsh-settings' write()/events key entries by entry.options.id).
|
|
68
|
+
* routes.js imports this for its config writes; the document-updated
|
|
69
|
+
* listener below keys on it. */
|
|
70
|
+
const SWITCHMAN_SETTINGS_NS = "dsh-switchman-host";
|
|
71
|
+
|
|
72
|
+
/** Total mutate attempts per sync; each retry re-describes for a fresh
|
|
73
|
+
* revision (SettingsConflictError means another writer changed the row). */
|
|
74
|
+
const MAX_MUTATE_ATTEMPTS = 3;
|
|
75
|
+
|
|
76
|
+
/** The teams clauses, verbatim from the cordis.patch.yml suffix anchor they
|
|
77
|
+
* were extracted from. Glue lines (the marker header and the section title)
|
|
78
|
+
* are the only new text. */
|
|
79
|
+
const TEAMS_SECTION_TEXT = [
|
|
80
|
+
"[SWITCHMAN:TEAMS] Agent Teams 模式已启用。本段是 persona suffix 调度规程动态注入的团队分支(编号续接其「四、边界」)。",
|
|
81
|
+
"",
|
|
82
|
+
"系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。",
|
|
83
|
+
"",
|
|
84
|
+
"## 五、团队触发与协作纪律",
|
|
85
|
+
"- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。",
|
|
86
|
+
"- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
// ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
|
|
90
|
+
|
|
91
|
+
/** Read one volatile settings field's raw value (live `.get()` reference). */
|
|
92
|
+
function readValue(field) {
|
|
93
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
94
|
+
try {
|
|
95
|
+
return field.get();
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return field;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Boolean volatile-field read (false on any malformed value). */
|
|
104
|
+
function readBool(field) {
|
|
105
|
+
return readValue(field) === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- module state ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Last whitelist replace-sync outcome for routes.js's whitelistSync config
|
|
111
|
+
* field; null when no sync has run in this process. Shape:
|
|
112
|
+
* { ok: boolean, count: number, at: number, error?: string }. */
|
|
113
|
+
let lastSync = null;
|
|
114
|
+
|
|
115
|
+
/** Last bundle-ensure outcome for routes.js's teamsBundle field; null before
|
|
116
|
+
* the first orchestration. Shape: { ok, at, state, error? } with state in
|
|
117
|
+
* selected | applied | restart-required | failed | cancelled | <unknown
|
|
118
|
+
* application value> | unavailable. */
|
|
119
|
+
let lastBundleState = null;
|
|
120
|
+
|
|
121
|
+
/** Set when a run must be retried from a clean context: either it was
|
|
122
|
+
* rejected as "HMR transactions cannot be nested" (the setImmediate
|
|
123
|
+
* deferral cannot escape the AsyncLocalStorage store the scheduling context
|
|
124
|
+
* carried — boot/hmr's runExclusive checks that store), or
|
|
125
|
+
* pluginManager.setBundleEnabled RESOLVED with a failed/cancelled
|
|
126
|
+
* application (no exception ever reaches the central catch). The next
|
|
127
|
+
* clean-context HTTP trigger pumps a retry; retries are strictly
|
|
128
|
+
* request-driven, never self-scheduling, so there is no retry loop. */
|
|
129
|
+
let pendingNestedRetry = false;
|
|
130
|
+
|
|
131
|
+
/** Single-flight guard: the apply + POST + document-updated triggers may
|
|
132
|
+
* overlap, but at most one orchestration runs at a time — requests
|
|
133
|
+
* arriving mid-run collapse into one rerun. */
|
|
134
|
+
let orchestrating = false;
|
|
135
|
+
let rerunRequested = false;
|
|
136
|
+
|
|
137
|
+
// ---- orchestration ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** Whether an error is the HMR nesting rejection: boot/hmr's runExclusive
|
|
140
|
+
* throws exactly "HMR transactions cannot be nested" when the AsyncLocalStorage
|
|
141
|
+
* store is already set, and setImmediate callbacks inherit that store from
|
|
142
|
+
* the scheduling context (e.g. an apply that itself ran in a transaction). */
|
|
143
|
+
function isNestedHmrError(error) {
|
|
144
|
+
return /cannot be nested/iu.test(String(error?.message ?? ""));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Deduplicated exact (provider, model) union of the six pools. Exact string
|
|
148
|
+
* identity (trimmed) — DSH's whitelist authorizes exact pairs and its
|
|
149
|
+
* assertAllowedModelRoutes rejects repeated provider/model routes outright. */
|
|
150
|
+
function poolUnion(config) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const routes = [];
|
|
153
|
+
for (const lane of LANES) {
|
|
154
|
+
const raw = readValue(config?.[laneField(lane)]);
|
|
155
|
+
if (!Array.isArray(raw)) continue;
|
|
156
|
+
for (const entry of raw) {
|
|
157
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
158
|
+
const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
|
|
159
|
+
const model = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
160
|
+
if (provider === "" || model === "") continue;
|
|
161
|
+
const key = `${provider}\u0000${model}`;
|
|
162
|
+
if (seen.has(key)) continue;
|
|
163
|
+
seen.add(key);
|
|
164
|
+
routes.push({ provider, model });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return routes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Ensure the shipped agent-team-profile bundle layer is selected in the
|
|
171
|
+
* profile. pluginManager.setBundleEnabled NEVER rejects: its change
|
|
172
|
+
* transaction swallows every error into a resolved
|
|
173
|
+
* {application:'failed', error} (cancellations into 'cancelled'), while
|
|
174
|
+
* 'restart-required' means the manifest was persisted and the tools
|
|
175
|
+
* activate after a host restart (boot/plugin-manager/src/index.ts:795-819).
|
|
176
|
+
* Treating that resolved value as success latches a false positive and
|
|
177
|
+
* suppresses every later retry — so only verified outcomes latch, and a
|
|
178
|
+
* listBundles pre-check both short-circuits the already-enabled case and
|
|
179
|
+
* converges with disk truth after a late failure (selectBundle persists
|
|
180
|
+
* before reload can throw). There is deliberately NO process-level success
|
|
181
|
+
* latch — the DSH-side toggle can be flipped off at any time, so every
|
|
182
|
+
* orchestration re-verifies via listBundles and re-enables when needed.
|
|
183
|
+
* A missing pluginManager service only warns; the doctrine section still
|
|
184
|
+
* renders, the tools simply stay absent. */
|
|
185
|
+
async function ensureTeamsBundle(ctx) {
|
|
186
|
+
const manager = ctx.get?.("pluginManager", false);
|
|
187
|
+
if (manager === undefined || manager === null || typeof manager.setBundleEnabled !== "function") {
|
|
188
|
+
lastBundleState = { ok: false, at: Date.now(), state: "unavailable" };
|
|
189
|
+
ctx.logger?.warn?.("dsh-switchman: pluginManager service unavailable; agent-team bundle not enabled");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (typeof manager.listBundles === "function") {
|
|
193
|
+
try {
|
|
194
|
+
const bundles = await manager.listBundles();
|
|
195
|
+
const selected = Array.isArray(bundles)
|
|
196
|
+
? bundles.find((item) => item?.name === TEAMS_BUNDLE)
|
|
197
|
+
: undefined;
|
|
198
|
+
if (selected?.enabled === true) {
|
|
199
|
+
// Already selected in the profile manifest: no call, no reload —
|
|
200
|
+
// and deliberately NO process-level latch. The user may flip the
|
|
201
|
+
// DSH-side toggle off at any moment; every orchestration re-reads
|
|
202
|
+
// this disk truth instead (live-verified regression: a boot-time
|
|
203
|
+
// latch froze the state right before the user toggled DSH off).
|
|
204
|
+
lastBundleState = { ok: true, at: Date.now(), state: "selected" };
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Unreadable manifest: fall through and let setBundleEnabled decide.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const result = await manager.setBundleEnabled(TEAMS_BUNDLE, true);
|
|
212
|
+
// Whitelist latch: only the two verified-persisted outcomes count. Anything
|
|
213
|
+
// else — failed, cancelled, or a future unknown application value — warns
|
|
214
|
+
// and stays unlatched so the next trigger retries (unknown values must NOT
|
|
215
|
+
// be assumed successful; that is exactly the false-positive this fix ends).
|
|
216
|
+
const outcome = typeof result?.application === "string" ? result.application : "applied";
|
|
217
|
+
if (outcome !== "applied" && outcome !== "restart-required") {
|
|
218
|
+
// A resolved failure raises no exception, so isNestedHmrError and
|
|
219
|
+
// orchestrateTeams' central catch never see it. Arm the clean-context
|
|
220
|
+
// pump here: the next GET (Switchman page view, post-save refresh)
|
|
221
|
+
// retries from a plain HTTP context, where transient causes — an HMR
|
|
222
|
+
// transaction around reload(), a package.json lock held by a boot-time
|
|
223
|
+
// pnpm/refresh — are gone. Verified live: the identical call succeeds
|
|
224
|
+
// when made from a clean context moments after failing in-page.
|
|
225
|
+
pendingNestedRetry = true;
|
|
226
|
+
lastBundleState = {
|
|
227
|
+
ok: false,
|
|
228
|
+
at: Date.now(),
|
|
229
|
+
state: outcome,
|
|
230
|
+
error: typeof result?.error === "string" ? result.error : result?.error?.message,
|
|
231
|
+
};
|
|
232
|
+
ctx.logger?.warn?.(
|
|
233
|
+
`dsh-switchman: agent-team bundle enable ${outcome}: `
|
|
234
|
+
+ `${result?.error?.message ?? result?.error ?? "unknown error"} — retrying on the next trigger`,
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
lastBundleState = { ok: true, at: Date.now(), state: outcome };
|
|
239
|
+
if (outcome === "restart-required") {
|
|
240
|
+
ctx.logger?.info?.("dsh-switchman: agent-team bundle persisted; restart DSH to activate team tools in new sessions");
|
|
241
|
+
} else {
|
|
242
|
+
ctx.logger?.info?.(`dsh-switchman: agent-team bundle enabled (${TEAMS_BUNDLE})`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One replace-sync of the six-pool union into the DSH whitelist. describe →
|
|
247
|
+
* mutate with the fresh revision; SettingsConflictError retries (re-describe
|
|
248
|
+
* first) up to MAX_MUTATE_ATTEMPTS total. Every terminal outcome lands in
|
|
249
|
+
* the module-level lastSync status; nothing throws. */
|
|
250
|
+
async function syncWhitelist(ctx, config) {
|
|
251
|
+
const routes = poolUnion(config);
|
|
252
|
+
if (routes.length === 0) {
|
|
253
|
+
// DSH's invariant: enabled=true with an empty allowedModels breaks
|
|
254
|
+
// every new session's composition — skipping is the only safe move.
|
|
255
|
+
lastSync = {
|
|
256
|
+
ok: false,
|
|
257
|
+
count: 0,
|
|
258
|
+
at: Date.now(),
|
|
259
|
+
error: "six-pool union is empty; sync skipped (enabled=true requires a non-empty allowedModels)",
|
|
260
|
+
};
|
|
261
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync skipped: ${lastSync.error}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const settings = ctx.settings;
|
|
265
|
+
if (settings === null || typeof settings !== "object" || typeof settings.describe !== "function" || typeof settings.mutate !== "function") {
|
|
266
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: "settings service unavailable" };
|
|
267
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (let attempt = 1; attempt <= MAX_MUTATE_ATTEMPTS; attempt += 1) {
|
|
271
|
+
let revision;
|
|
272
|
+
try {
|
|
273
|
+
const described = settings.describe();
|
|
274
|
+
const row = Array.isArray(described) ? described.find((entry) => entry?.ns === WHITELIST_NS) : undefined;
|
|
275
|
+
if (row === undefined || !Number.isFinite(row.revision)) {
|
|
276
|
+
lastSync = {
|
|
277
|
+
ok: false,
|
|
278
|
+
count: routes.length,
|
|
279
|
+
at: Date.now(),
|
|
280
|
+
error: `settings namespace "${WHITELIST_NS}" not found or carries no revision`,
|
|
281
|
+
};
|
|
282
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
revision = row.revision;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Drift-guard: describe() is a synchronous file read in current DSH
|
|
288
|
+
// (it never crosses runExclusive), so this branch is unreachable
|
|
289
|
+
// today — kept in case a future revision routes it through the
|
|
290
|
+
// editor lock.
|
|
291
|
+
if (isNestedHmrError(error)) {
|
|
292
|
+
pendingNestedRetry = true;
|
|
293
|
+
ctx.logger?.info?.("dsh-switchman: whitelist describe deferred (HMR transaction active); the next clean-context trigger retries");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
|
|
297
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist describe failed: ${lastSync.error}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
await settings.mutate(
|
|
302
|
+
WHITELIST_NS,
|
|
303
|
+
[
|
|
304
|
+
{ op: "set", path: ["enabled"], value: true },
|
|
305
|
+
{ op: "set", path: ["allowedModels"], value: routes },
|
|
306
|
+
],
|
|
307
|
+
revision,
|
|
308
|
+
);
|
|
309
|
+
lastSync = { ok: true, count: routes.length, at: Date.now() };
|
|
310
|
+
ctx.logger?.info?.(
|
|
311
|
+
`dsh-switchman: whitelist replace-synced (${routes.length} routes; applies to newly composed top-level sessions only)`,
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Conflict = another writer changed the row between describe and
|
|
316
|
+
// mutate: re-describe and retry. A nested-HMR rejection defers the
|
|
317
|
+
// whole sync to the next clean trigger (transient — no failure
|
|
318
|
+
// status is recorded). Anything else is terminal.
|
|
319
|
+
if (isNestedHmrError(error)) {
|
|
320
|
+
pendingNestedRetry = true;
|
|
321
|
+
ctx.logger?.info?.("dsh-switchman: whitelist sync deferred (HMR transaction active); the next clean-context trigger retries");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (error?.name === "SettingsConflictError" && attempt < MAX_MUTATE_ATTEMPTS) continue;
|
|
325
|
+
lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
|
|
326
|
+
ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed after ${attempt} attempt(s): ${lastSync.error}`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The orchestration behind the two INDEPENDENT switches: teamsMode drives
|
|
333
|
+
* the bundle ensure (+ the doctrine section, handled elsewhere);
|
|
334
|
+
* syncWhitelist drives the whitelist replace-sync — either may be on alone.
|
|
335
|
+
* Single-flight (see orchestrating); every failure is caught and warned —
|
|
336
|
+
* a nested-HMR rejection arms the clean-context retry pump instead —
|
|
337
|
+
* never rethrown. */
|
|
338
|
+
async function orchestrateTeams(ctx, config) {
|
|
339
|
+
if (orchestrating) {
|
|
340
|
+
rerunRequested = true;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
orchestrating = true;
|
|
344
|
+
try {
|
|
345
|
+
if (readBool(config?.teamsMode) === true) {
|
|
346
|
+
await ensureTeamsBundle(ctx).catch((error) => {
|
|
347
|
+
// A nested rejection must defer the WHOLE run (the whitelist
|
|
348
|
+
// sync would hit the same wall) — rethrow to the central catch.
|
|
349
|
+
if (isNestedHmrError(error)) throw error;
|
|
350
|
+
ctx.logger?.warn?.(`dsh-switchman: agent-team bundle enable failed: ${error?.message ?? error}`);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (readBool(config?.syncWhitelist) === true) await syncWhitelist(ctx, config);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (isNestedHmrError(error)) {
|
|
356
|
+
pendingNestedRetry = true;
|
|
357
|
+
ctx.logger?.info?.("dsh-switchman: teams orchestration deferred (HMR transaction active); the next clean-context trigger retries");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration failed: ${error?.message ?? error}`);
|
|
361
|
+
} finally {
|
|
362
|
+
orchestrating = false;
|
|
363
|
+
if (rerunRequested) {
|
|
364
|
+
rerunRequested = false;
|
|
365
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Last whitelist replace-sync outcome ({ ok, count, at, error? } | null).
|
|
371
|
+
* Raw module state — routes.js gates exposure on teamsMode && syncWhitelist. */
|
|
372
|
+
function teamsBundleState() {
|
|
373
|
+
return lastBundleState === null ? null : { ...lastBundleState };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function teamsSyncSnapshot() {
|
|
377
|
+
return lastSync;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Fire-and-forget orchestration trigger. Safe from any code path (plugin
|
|
381
|
+
* apply, HTTP handlers): the work always lands on a setImmediate turn, so
|
|
382
|
+
* nothing runs synchronously inside apply or a route handler. The deferral
|
|
383
|
+
* does NOT escape an enclosing HMR transaction's AsyncLocalStorage store —
|
|
384
|
+
* a nested rejection is detected here and retried from the next clean
|
|
385
|
+
* trigger (see pendingNestedRetry / pumpTeamsOrchestration). */
|
|
386
|
+
function scheduleTeamsOrchestration(ctx, config) {
|
|
387
|
+
try {
|
|
388
|
+
// Either switch alone is a reason to run (they are independent).
|
|
389
|
+
if (readBool(config?.teamsMode) !== true && readBool(config?.syncWhitelist) !== true) return;
|
|
390
|
+
setImmediate(() => {
|
|
391
|
+
Promise.resolve(orchestrateTeams(ctx, config)).catch((error) => {
|
|
392
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration crashed: ${error?.message ?? error}`);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
ctx.logger?.warn?.(`dsh-switchman: teams orchestration scheduling failed: ${error?.message ?? error}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Retry pump for runs rejected as nested: call from clean-context code
|
|
401
|
+
* paths (HTTP handlers never run inside an HMR transaction). A no-op
|
|
402
|
+
* single-boolean check when nothing is pending; a retry that fails nested
|
|
403
|
+
* again simply re-arms the flag, so retries stay request-driven. */
|
|
404
|
+
function pumpTeamsOrchestration(ctx, config) {
|
|
405
|
+
if (!pendingNestedRetry) return;
|
|
406
|
+
pendingNestedRetry = false;
|
|
407
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- child_message bridge tool ----------------------------------------------
|
|
411
|
+
|
|
412
|
+
/** Map one ctx.subagents.sendMessage rejection to a readable reason. The
|
|
413
|
+
* continuation runtime throws typed SubagentErrors whose stable `code`
|
|
414
|
+
* names the failure mode: UNAUTHORIZED (the sender is not the exact live
|
|
415
|
+
* agent, or the target is not the sender's direct continuable child /
|
|
416
|
+
* direct parent), NOT_RESUMABLE (no continuable session resolves to the
|
|
417
|
+
* id), CONTINUATION_UNAVAILABLE (this composition exposes no continuation
|
|
418
|
+
* runtime), DRAINING (admission is closing), and ACTIVATION_CLOSING (the
|
|
419
|
+
* resident child is mid-disposal). */
|
|
420
|
+
function describeChildMessageError(error) {
|
|
421
|
+
const message = error?.message ?? String(error);
|
|
422
|
+
switch (typeof error?.code === "string" ? error.code : "") {
|
|
423
|
+
case "UNAUTHORIZED":
|
|
424
|
+
return `wrong sender or lineage: ${message} (childId must be one of your own direct continuable children — teammate names and other agents' children are not addressable here)`;
|
|
425
|
+
case "NOT_RESUMABLE":
|
|
426
|
+
return `unknown or non-continuable child: ${message}`;
|
|
427
|
+
case "CONTINUATION_UNAVAILABLE":
|
|
428
|
+
return `service unavailable: ${message}`;
|
|
429
|
+
case "DRAINING":
|
|
430
|
+
return `continuable subagents are draining: ${message}`;
|
|
431
|
+
case "ACTIVATION_CLOSING":
|
|
432
|
+
return `the child is closing: ${message}`;
|
|
433
|
+
default:
|
|
434
|
+
return error?.name === "AbortError" ? `cancelled before delivery: ${message}` : message;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** WHY this tool exists: enabling the agent-team-profile bundle REPLACES the
|
|
439
|
+
* global send_message with a team variant that resolves TEAMMATE NAMES
|
|
440
|
+
* only, after which subagent child session UUIDs are not addressable at
|
|
441
|
+
* all — background children dispatched before the switch (or recorded by a
|
|
442
|
+
* context handover) would become uncollectable silos. This bridge restores
|
|
443
|
+
* child addressing through the subagents service's public sendMessage,
|
|
444
|
+
* passing the exact live calling agent (exec.agent) as the sender — the
|
|
445
|
+
* same authority contract the factory send_message tool uses; the service
|
|
446
|
+
* itself validates the sender identity and the direct-child lineage.
|
|
447
|
+
* Registration uses the register-level shape (bare ctx.tools.register runs
|
|
448
|
+
* no defineTool DSL compilation, so parameters is a full object-root JSON
|
|
449
|
+
* Schema), mirroring context-watch.js's ctx_handover tool.
|
|
450
|
+
* @returns the registration disposer. */
|
|
451
|
+
function registerChildMessageTool(ctx) {
|
|
452
|
+
if (typeof ctx.tools?.register !== "function") throw new Error("the tools registry is unavailable");
|
|
453
|
+
const dispose = ctx.tools.register({
|
|
454
|
+
name: "child_message",
|
|
455
|
+
description:
|
|
456
|
+
"Send a message to one of your subagent child sessions by its session UUID. Needed while Agent Teams mode is active: the team send_message resolves teammate names only, so subagent child ids must go through this bridge. Delivers to a direct continuable child — a running child takes the message at its nearest step boundary, an idle child starts a turn, and an absent continuable child is cold-resumed from persistence. Returns delivery confirmation, not the child's answer.",
|
|
457
|
+
parameters: {
|
|
458
|
+
type: "object",
|
|
459
|
+
properties: {
|
|
460
|
+
childId: {
|
|
461
|
+
type: "string",
|
|
462
|
+
description: "The child session UUID to address (the id returned when the subagent was started).",
|
|
463
|
+
},
|
|
464
|
+
content: {
|
|
465
|
+
type: "string",
|
|
466
|
+
description: "The message to deliver to the child.",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
required: ["childId", "content"],
|
|
470
|
+
},
|
|
471
|
+
output: {
|
|
472
|
+
schema: { type: "string" },
|
|
473
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
474
|
+
},
|
|
475
|
+
async execute(args, exec) {
|
|
476
|
+
const sender = exec?.agent;
|
|
477
|
+
if (sender === undefined || sender === null)
|
|
478
|
+
return "child_message failed: no calling agent (exec.agent was undefined) — the subagents service requires the exact live sender.";
|
|
479
|
+
const childId = typeof args?.childId === "string" ? args.childId.trim() : "";
|
|
480
|
+
const content = typeof args?.content === "string" ? args.content : "";
|
|
481
|
+
if (childId === "" || content === "")
|
|
482
|
+
return "child_message failed: childId and content are required non-empty strings.";
|
|
483
|
+
const service = ctx.subagents;
|
|
484
|
+
if (service === undefined || service === null || typeof service.sendMessage !== "function")
|
|
485
|
+
return "child_message failed: the subagents service is unavailable in this composition.";
|
|
486
|
+
try {
|
|
487
|
+
const messageId = await service.sendMessage(
|
|
488
|
+
sender,
|
|
489
|
+
childId,
|
|
490
|
+
[{ type: "text", text: content }],
|
|
491
|
+
// The service requires options.signal (it calls
|
|
492
|
+
// throwIfAborted unconditionally); the tool turn's own
|
|
493
|
+
// signal cancels only pre-acceptance work.
|
|
494
|
+
{ signal: exec?.signal ?? new AbortController().signal },
|
|
495
|
+
);
|
|
496
|
+
return `child_message: message ${messageId} accepted for child ${childId} — a running child takes it at the nearest step boundary, an idle or absent continuable child starts (or cold-resumes into) a turn. This confirms delivery only, not the child's answer.`;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return `child_message failed: ${describeChildMessageError(error)}`;
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
presentCall: (args) => ({
|
|
502
|
+
card: "generic",
|
|
503
|
+
title: "Switchman child message",
|
|
504
|
+
kind: "other",
|
|
505
|
+
rawInput: args,
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
// If the registry registered the tool but handed back a non-function
|
|
509
|
+
// disposer, latch a no-op instead of throwing: throwing would leave a
|
|
510
|
+
// live tool unlatched, and a later retry would register it twice.
|
|
511
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Mount the teams layer: the dynamic `[SWITCHMAN:TEAMS]` section (empty
|
|
515
|
+
* while OFF — the live reference makes toggling instant), the child_message
|
|
516
|
+
* bridge tool (same live teamsMode gating: registered while ON, disposed
|
|
517
|
+
* the moment it reads OFF), plus the orchestration triggers (apply-time
|
|
518
|
+
* scheduling and settings-document updates for this plugin's own
|
|
519
|
+
* namespace). */
|
|
520
|
+
function applyTeams(ctx, config) {
|
|
521
|
+
ctx.systemPrompt.section({
|
|
522
|
+
name: "switchman:teams",
|
|
523
|
+
order: 10250,
|
|
524
|
+
interpolate: false,
|
|
525
|
+
text: () => {
|
|
526
|
+
try {
|
|
527
|
+
if (readBool(config.teamsMode) !== true) return "";
|
|
528
|
+
return TEAMS_SECTION_TEXT;
|
|
529
|
+
} catch {
|
|
530
|
+
return "";
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
// child_message lifecycle: reconcile the registration against the live
|
|
535
|
+
// teamsMode read (idempotent — a no-op whenever the state already
|
|
536
|
+
// matches). The closure holds the disposer so every plugin mount starts
|
|
537
|
+
// clean; the registry entry itself is scope-tracked to this fiber, so a
|
|
538
|
+
// remount never leaves a stale tool behind. A registration failure only
|
|
539
|
+
// warns and leaves the state unlatched, and a failed removal keeps the
|
|
540
|
+
// latch — the next toggle retries either way.
|
|
541
|
+
let childMessageDispose = null;
|
|
542
|
+
const syncChildMessageTool = () => {
|
|
543
|
+
const wanted = readBool(config?.teamsMode) === true;
|
|
544
|
+
if (wanted === (childMessageDispose !== null)) return;
|
|
545
|
+
if (!wanted) {
|
|
546
|
+
// Clear the latch only after a successful dispose: a throwing
|
|
547
|
+
// disposer keeps the latch set, so the next OFF sync retries the
|
|
548
|
+
// removal instead of no-oping while the tool stays live.
|
|
549
|
+
const dispose = childMessageDispose;
|
|
550
|
+
try {
|
|
551
|
+
dispose();
|
|
552
|
+
childMessageDispose = null;
|
|
553
|
+
ctx.logger?.info?.("dsh-switchman: child_message tool removed (teams mode off)");
|
|
554
|
+
} catch (error) {
|
|
555
|
+
ctx.logger?.warn?.(`dsh-switchman: child_message removal failed: ${error?.message ?? error}`);
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
childMessageDispose = registerChildMessageTool(ctx);
|
|
561
|
+
ctx.logger?.info?.("dsh-switchman: child_message tool registered (teams mode on)");
|
|
562
|
+
} catch (error) {
|
|
563
|
+
childMessageDispose = null;
|
|
564
|
+
ctx.logger?.warn?.(`dsh-switchman: child_message registration failed: ${error?.message ?? error}`);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
// Volatile commits never remount the plugin, so teamsMode/syncWhitelist
|
|
568
|
+
// (or pool) edits through DSH's OWN settings UI — which bypass our POST
|
|
569
|
+
// config route — must re-trigger the orchestration (and re-sync the
|
|
570
|
+
// child_message tool) too. The settings service emits this app-wide with
|
|
571
|
+
// no emitting thisArg, so a listener on this plugin's context receives
|
|
572
|
+
// every namespace's event; filter on ns.
|
|
573
|
+
ctx.on("settings/document-updated", (ns) => {
|
|
574
|
+
if (ns === SWITCHMAN_SETTINGS_NS) {
|
|
575
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
576
|
+
syncChildMessageTool();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
scheduleTeamsOrchestration(ctx, config);
|
|
580
|
+
syncChildMessageTool();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
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
|
+
}
|