dsh-switchman 1.3.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/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
+ }