dsh-switchman 1.3.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/routes.js ADDED
@@ -0,0 +1,398 @@
1
+ /** Host-side HTTP routes for the dsh-switchman client half.
2
+ *
3
+ * The browser half of this bundle runs inside the restricted dynamic-module
4
+ * runtime: it has no `configForms`/`remote` services and no direct network —
5
+ * the sanctioned bridge is same-origin `fetch` against routes the Host half
6
+ * registers on `ctx.webServer` (the pattern the shipped skill-explorer
7
+ * plugin established). This module owns that route family:
8
+ *
9
+ * GET /api/dsh-switchman/health — liveness probe for the client page.
10
+ * GET /api/dsh-switchman/config — current settings values snapshot.
11
+ * POST /api/dsh-switchman/config — { expected, values } fenced write
12
+ * through ctx.settings.update; a stale
13
+ * `expected` answers 409 + fresh values.
14
+ * GET /api/dsh-switchman/models — model-catalog pass-through from the
15
+ * host sessionController (degrades to
16
+ * llm.listProviders, then []).
17
+ *
18
+ * Every route carries the shared trust fence (loopback + same-origin browser
19
+ * markers; a live remoteWebUiPairing cookie is an extra allow path), mirroring
20
+ * skill-explorer's access model: the write route changes real persisted
21
+ * settings, so unpaired LAN clients must not reach it.
22
+ */
23
+
24
+ /** Route paths (client.js mirrors these literals). */
25
+ const ROUTES = {
26
+ health: "/api/dsh-switchman/health",
27
+ config: "/api/dsh-switchman/config",
28
+ models: "/api/dsh-switchman/models",
29
+ };
30
+
31
+ /** Settings namespace this plugin's writes target: the LOADER ENTRY id from
32
+ * cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
33
+ * export — dsh-settings' write() looks entries up by entry.options.id. */
34
+ const NS = "dsh-switchman-host";
35
+
36
+ /** All settings field names, grouped for coercion (mirrors host/config.js). */
37
+ const STRING_FIELDS = ["langConversation", "langComments", "langDocs"];
38
+ const POOL_FIELDS = [
39
+ "poolEconomy",
40
+ "poolMechanical",
41
+ "poolMain",
42
+ "poolHard",
43
+ "poolVision",
44
+ "poolReview",
45
+ ];
46
+ const NUMBER_FIELDS = [
47
+ "wmSoftTokens",
48
+ "wmHardTokens",
49
+ "wmForceTokens",
50
+ "wmReadBudgetTokens",
51
+ "wmSubagentForceTokens",
52
+ ];
53
+ const BOOLEAN_FIELDS = [
54
+ "wmAutoHandover",
55
+ "wmSubagentCap",
56
+ // 以下六个 mirrors host/config.js 的 pool*Manual 开关
57
+ "poolEconomyManual",
58
+ "poolMechanicalManual",
59
+ "poolMainManual",
60
+ "poolHardManual",
61
+ "poolVisionManual",
62
+ "poolReviewManual",
63
+ ];
64
+ /** Per-pool effort-route lists (mirrors host/config.js pool*Efforts). */
65
+ const EFFORTS_FIELDS = [
66
+ "poolEconomyEfforts",
67
+ "poolMechanicalEfforts",
68
+ "poolMainEfforts",
69
+ "poolHardEfforts",
70
+ "poolVisionEfforts",
71
+ "poolReviewEfforts",
72
+ ];
73
+ const ENUM_FIELDS = {
74
+ dispatchEnforce: ["off", "advice", "enforce"],
75
+ wmDenyMode: ["cap", "deny"],
76
+ };
77
+
78
+ /** JSON response helper. */
79
+ function writeJson(res, status, payload) {
80
+ const body = JSON.stringify(payload);
81
+ res.writeHead(status, {
82
+ "content-type": "application/json; charset=utf-8",
83
+ "cache-control": "no-store",
84
+ });
85
+ res.end(body);
86
+ }
87
+
88
+ /** Read and parse one JSON request body (size-capped at 1 MiB). */
89
+ function readJsonBody(req) {
90
+ return new Promise((resolve, reject) => {
91
+ const chunks = [];
92
+ let size = 0;
93
+ req.on("data", (chunk) => {
94
+ size += chunk.length;
95
+ if (size > 1024 * 1024) {
96
+ reject(new Error("body too large"));
97
+ req.destroy();
98
+ return;
99
+ }
100
+ chunks.push(chunk);
101
+ });
102
+ req.on("end", () => {
103
+ try {
104
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")));
105
+ } catch (error) {
106
+ reject(new Error(`invalid JSON body: ${error?.message ?? error}`));
107
+ }
108
+ });
109
+ req.on("error", reject);
110
+ });
111
+ }
112
+
113
+ /** Whether a Host-header hostname names the local loopback authority. */
114
+ function isLoopbackHostname(hostname) {
115
+ if (hostname === "localhost" || hostname === "[::1]") return true;
116
+ if (!hostname || hostname.startsWith("[")) return false;
117
+ const parts = hostname.split(".");
118
+ return parts.length === 4 && parts.every((p) => /^\d+$/u.test(p)) && parts[0] === "127";
119
+ }
120
+
121
+ /** Whether this request comes from this machine's own browser page. */
122
+ function isLoopbackRequest(request) {
123
+ const host = request.headers?.host;
124
+ if (typeof host !== "string" || host === "") return false;
125
+ let hostUrl;
126
+ try {
127
+ hostUrl = new URL(`http://${host}`);
128
+ } catch {
129
+ return false;
130
+ }
131
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
132
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
133
+ const origin = request.headers.origin;
134
+ if (origin === undefined) return true;
135
+ try {
136
+ return new URL(origin).host === hostUrl.host;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Whether this request may enter the plugin's host routes: loopback by
144
+ * default, or a live paired-device cookie when remote-web-ui is loaded.
145
+ * @param {import("@deepseek-ai/cordis").Context} ctx - host context.
146
+ * @param {import("node:http").IncomingMessage} request - incoming request.
147
+ */
148
+ function isAllowed(ctx, request) {
149
+ if (isLoopbackRequest(request)) return true;
150
+ const pairing = typeof ctx.get === "function" ? ctx.get("remoteWebUiPairing", false) : undefined;
151
+ return (
152
+ pairing !== undefined &&
153
+ pairing !== null &&
154
+ typeof pairing.isPairedDevice === "function" &&
155
+ pairing.isPairedDevice(request) === true
156
+ );
157
+ }
158
+
159
+ /** Read one volatile settings field's raw value (live `.get()` reference). */
160
+ function rawOf(field) {
161
+ if (field !== null && typeof field === "object" && typeof field.get === "function") return field.get();
162
+ return field;
163
+ }
164
+
165
+ /** Coerce one route candidate `{provider, model}` entry; null when invalid. */
166
+ function cleanRoute(entry) {
167
+ if (entry === null || typeof entry !== "object") return null;
168
+ const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
169
+ const model = typeof entry.model === "string" ? entry.model.trim() : "";
170
+ return provider !== "" && model !== "" ? { provider, model } : null;
171
+ }
172
+
173
+ /** Coerce one route candidate rank entry (tier optional). */
174
+ function cleanRank(entry) {
175
+ const route = cleanRoute(entry);
176
+ if (route === null) return null;
177
+ const tier =
178
+ entry.tier === undefined || entry.tier === null || entry.tier === ""
179
+ ? undefined
180
+ : ["S", "A", "B", "C"].includes(entry.tier)
181
+ ? entry.tier
182
+ : undefined;
183
+ return tier === undefined ? route : { ...route, tier };
184
+ }
185
+
186
+ /** Coerce one effort candidate `{provider, model, effort}` entry; null when invalid. */
187
+ function cleanEffortEntry(entry) {
188
+ if (entry === null || typeof entry !== "object") return null;
189
+ const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
190
+ const model = typeof entry.model === "string" ? entry.model.trim() : "";
191
+ const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
192
+ return provider !== "" && model !== "" && effort !== "" ? { provider, model, effort } : null;
193
+ }
194
+
195
+ /** Snapshot the plugin's live volatile config into plain JSON values. */
196
+ export function snapshotOf(config) {
197
+ const values = {};
198
+ for (const field of STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
199
+ for (const field of POOL_FIELDS)
200
+ values[field] = Array.isArray(rawOf(config?.[field]))
201
+ ? rawOf(config[field]).map(cleanRoute).filter(Boolean)
202
+ : [];
203
+ const rank = rawOf(config?.modelRank);
204
+ values.modelRank = Array.isArray(rank) ? rank.map(cleanRank).filter(Boolean) : [];
205
+ for (const field of EFFORTS_FIELDS)
206
+ values[field] = Array.isArray(rawOf(config?.[field]))
207
+ ? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
208
+ : [];
209
+ values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
210
+ ? rawOf(config.dispatchEnforce)
211
+ : "advice";
212
+ for (const field of NUMBER_FIELDS) {
213
+ const raw = Number(rawOf(config?.[field]));
214
+ values[field] = Number.isFinite(raw) ? raw : 0;
215
+ }
216
+ values.wmDenyMode = ENUM_FIELDS.wmDenyMode.includes(rawOf(config?.wmDenyMode))
217
+ ? rawOf(config.wmDenyMode)
218
+ : "cap";
219
+ for (const field of BOOLEAN_FIELDS) values[field] = Boolean(rawOf(config?.[field]));
220
+ return values;
221
+ }
222
+
223
+ /** Coerce and validate a client-submitted values object; null when invalid. */
224
+ function coerceValues(input) {
225
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
226
+ const values = {};
227
+ for (const field of STRING_FIELDS) {
228
+ if (typeof input[field] !== "string") return null;
229
+ values[field] = input[field];
230
+ }
231
+ for (const field of POOL_FIELDS) {
232
+ if (!Array.isArray(input[field])) return null;
233
+ const routes = input[field].map(cleanRoute);
234
+ if (routes.some((route) => route === null)) return null;
235
+ values[field] = routes;
236
+ }
237
+ if (!Array.isArray(input.modelRank)) return null;
238
+ const rank = input.modelRank.map(cleanRank);
239
+ if (rank.some((entry) => entry === null)) return null;
240
+ values.modelRank = rank;
241
+ for (const field of EFFORTS_FIELDS) {
242
+ if (!Array.isArray(input[field])) return null;
243
+ const efforts = input[field].map(cleanEffortEntry);
244
+ if (efforts.some((entry) => entry === null)) return null;
245
+ values[field] = efforts;
246
+ }
247
+ if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
248
+ values.dispatchEnforce = input.dispatchEnforce;
249
+ for (const field of NUMBER_FIELDS) {
250
+ const raw = Number(input[field]);
251
+ if (!Number.isFinite(raw) || raw < 0) return null;
252
+ values[field] = raw;
253
+ }
254
+ if (!ENUM_FIELDS.wmDenyMode.includes(input.wmDenyMode)) return null;
255
+ values.wmDenyMode = input.wmDenyMode;
256
+ for (const field of BOOLEAN_FIELDS) {
257
+ if (typeof input[field] !== "boolean") return null;
258
+ values[field] = input[field];
259
+ }
260
+ return values;
261
+ }
262
+
263
+ /** Deep-equal on the plain-JSON value shapes this module exchanges. */
264
+ function sameValues(a, b) {
265
+ return JSON.stringify(a) === JSON.stringify(b);
266
+ }
267
+
268
+ /** Enumerate the model catalog from the host session controller. */
269
+ async function modelCatalogOf(ctx) {
270
+ const controller = typeof ctx.get === "function" ? ctx.get("sessionController", false) : undefined;
271
+ if (controller !== undefined && typeof controller.modelCatalog === "function") {
272
+ const catalog = await controller.modelCatalog();
273
+ if (catalog !== null && typeof catalog === "object") return catalog;
274
+ }
275
+ const llm = typeof ctx.get === "function" ? ctx.get("llm", false) : undefined;
276
+ if (llm !== undefined && typeof llm.listProviders === "function") {
277
+ const providers = await llm.listProviders();
278
+ return {
279
+ groups: Array.isArray(providers)
280
+ ? providers.map((info) => ({
281
+ provider: info?.id ?? info?.name ?? "unknown",
282
+ models: Array.isArray(info?.models) ? info.models : [],
283
+ }))
284
+ : [],
285
+ failures: [],
286
+ };
287
+ }
288
+ return { groups: [], failures: [] };
289
+ }
290
+
291
+ /**
292
+ * Build the /api/dsh-switchman route family (exact paths).
293
+ * @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
294
+ * @param {object} config - this plugin's live volatile config.
295
+ * @returns {Array<{kind: "exact", path: string, handler: Function}>} routes
296
+ * for ctx.webServer.register.
297
+ */
298
+ export function makeRoutes(ctx, config) {
299
+ /** Guard helper: fence + method check. */
300
+ const guard = (req, res, method) => {
301
+ if (!isAllowed(ctx, req)) {
302
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
303
+ return false;
304
+ }
305
+ if (req.method !== method) {
306
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
307
+ return false;
308
+ }
309
+ return true;
310
+ };
311
+
312
+ return [
313
+ {
314
+ kind: "exact",
315
+ path: ROUTES.health,
316
+ handler: async (req, res) => {
317
+ if (!guard(req, res, "GET")) return;
318
+ writeJson(res, 200, { ok: true, ns: NS });
319
+ },
320
+ },
321
+ {
322
+ kind: "exact",
323
+ path: ROUTES.config,
324
+ handler: async (req, res) => {
325
+ if (!isAllowed(ctx, req)) {
326
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
327
+ return;
328
+ }
329
+ if (req.method === "GET") {
330
+ writeJson(res, 200, { values: snapshotOf(config) });
331
+ return;
332
+ }
333
+ if (req.method !== "POST") {
334
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
335
+ return;
336
+ }
337
+ let body;
338
+ try {
339
+ body = await readJsonBody(req);
340
+ } catch (error) {
341
+ writeJson(res, 400, { error: error?.message ?? String(error) });
342
+ return;
343
+ }
344
+ const values = coerceValues(body?.values);
345
+ if (values === null) {
346
+ writeJson(res, 400, { error: "invalid values payload" });
347
+ return;
348
+ }
349
+ // Optimistic-concurrency fence: when the client submits the
350
+ // snapshot it loaded (`expected`), require it to still match.
351
+ const current = snapshotOf(config);
352
+ if (body.expected !== undefined) {
353
+ const expected = coerceValues(body.expected);
354
+ if (expected === null || !sameValues(expected, current)) {
355
+ writeJson(res, 409, { error: "conflict: settings changed since load", values: current });
356
+ return;
357
+ }
358
+ }
359
+ try {
360
+ await ctx.settings.update(NS, values);
361
+ } catch (error) {
362
+ ctx.logger.warn(`dsh-switchman: settings write failed: ${error?.message ?? error}`);
363
+ writeJson(res, 500, { error: error?.message ?? String(error) });
364
+ return;
365
+ }
366
+ writeJson(res, 200, { values: snapshotOf(config) });
367
+ },
368
+ },
369
+ {
370
+ kind: "exact",
371
+ path: ROUTES.models,
372
+ handler: async (req, res) => {
373
+ if (!guard(req, res, "GET")) return;
374
+ try {
375
+ writeJson(res, 200, await modelCatalogOf(ctx));
376
+ } catch (error) {
377
+ ctx.logger.warn(`dsh-switchman: model catalog failed: ${error?.message ?? error}`);
378
+ writeJson(res, 500, { error: error?.message ?? String(error) });
379
+ }
380
+ },
381
+ },
382
+ ];
383
+ }
384
+
385
+ /**
386
+ * Mount the route family on the webServer (disposable through ctx.effect).
387
+ * @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
388
+ * @param {object} config - this plugin's live volatile config.
389
+ */
390
+ export function applyRoutes(ctx, config) {
391
+ const routes = makeRoutes(ctx, config);
392
+ ctx.effect(() => {
393
+ const disposers = routes.map((route) => ctx.webServer.register(route));
394
+ return () => {
395
+ for (const dispose of disposers) dispose();
396
+ };
397
+ }, "dsh-switchman: client routes");
398
+ }
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <!-- dsh-switchman: switch + bolt mark. Flat, theme-safe, no external refs. -->
3
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="#2B5FFF"/>
4
+ <path d="M14 40h14a10 10 0 0 0 0-20H14" fill="none" stroke="#FFFFFF" stroke-width="5" stroke-linecap="round"/>
5
+ <circle cx="44" cy="40" r="6" fill="#FFFFFF"/>
6
+ <path d="M47 8 34 30h8l-5 18 16-24h-9z" fill="#FFD34D" stroke="#2B5FFF" stroke-width="1.5" stroke-linejoin="round"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,154 @@
1
+ /** Host half of the dsh-switchman bundle.
2
+ *
3
+ * Mounts the bundle's bundled skills: every `skills/<name>/SKILL.md` under
4
+ * this package is registered on `ctx.skills` through one static provider,
5
+ * following the shipped `@deepseek-ai/dsh-skill-badge` / `dsh-skill-office`
6
+ * pattern (source "bundled", rank BUNDLED_SKILL_RANK, directory resource
7
+ * base so the skill tool tells the Agent where `scripts/` and `references/`
8
+ * live). The preset overrides live in cordis.patch.yml; the Client half in
9
+ * client.js renders the "team autonomy" badge beside the preset chip.
10
+ *
11
+ * Phase 1 additionally mounts the language-preference layer (host/lang.js):
12
+ * a dynamic systemPrompt section carrying the `[SWITCHMAN:LANG]` protocol
13
+ * block plus a `tools/post-execute` listener that captures ask_user_question
14
+ * answers and persists them into this plugin's settings namespace.
15
+ *
16
+ * Phase 2 mounts the dispatch-pool layer (host/dispatch.js + host/lib/rank.js):
17
+ * a `[SWITCHMAN:POOLS]` recommendation section ordered by modelRank anchors
18
+ * and the bundled capability snapshot, plus an optional (enforce mode only)
19
+ * `tools/pre-execute` gate that denies subagent routes outside the pools.
20
+ *
21
+ * Phase 3 mounts the context-watermark layer (host/context-watch.js): the
22
+ * `[SWITCHMAN:WATERMARK]` tiered banner from the live token meter, a per-turn
23
+ * read budget with deny/cap gates, force-tier fire-and-forget handover into
24
+ * compaction, and the /ctx-pause, /ctx-resume, /ctx-handover commands.
25
+ */
26
+
27
+ import { readdirSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ // Plugin settings schema: flat `.volatile()` fields exposed as the
32
+ // "dsh-switchman" settings namespace (forms + profile-patch persistence).
33
+ export { Config, LANES, laneField } from "./host/config.js";
34
+
35
+ // Language-preference layer (dynamic prompt section + answer capture).
36
+ import { applyLanguage } from "./host/lang.js";
37
+
38
+ // Dispatch-pool layer (pools recommendation section + enforce gate).
39
+ import { applyDispatch } from "./host/dispatch.js";
40
+
41
+ // Context-watermark layer (banner + read budget + handover + /ctx-* commands).
42
+ import { applyContextWatch } from "./host/context-watch.js";
43
+
44
+ // Client-bridge routes (config snapshot/fenced write + model catalog for the
45
+ // restricted browser half, which has no configForms/remote services).
46
+ import { applyRoutes } from "./host/routes.js";
47
+
48
+ /** Cordis plugin name. */
49
+ const name = "dsh-switchman";
50
+
51
+ /** Services this plugin contributes to: the bundled-skill registry, the
52
+ * system-prompt section registry, the settings store it writes back to, the
53
+ * token meter and agent registry the watermark reads, the command registry
54
+ * for /ctx-*, the subagents service the handover's backup fork goes through,
55
+ * and the sessions persistence the handover's continuation flush uses.
56
+ * Compaction is deliberately NOT injected: the desktop profile provides it
57
+ * only inside each preset's per-agent isolated group (isolate: { compaction:
58
+ * true }), never at this root — host/context-watch.js reaches it by borrowing
59
+ * the session's own per-agent /compact handler through
60
+ * ctx.commands.find(agent, "compact"). */
61
+ const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "agents", "webServer", "subagents", "sessions"];
62
+
63
+ /** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
64
+ * providers and local bundled roots; below user entries, above none). Not
65
+ * imported because that package is not a dependency of this bundle. */
66
+ const BUNDLED_SKILL_RANK = 600;
67
+
68
+ /** Provider id reported by every candidate (must match `provider`). */
69
+ const PROVIDER_NAME = "dsh-switchman";
70
+
71
+ /** Parse the `---` frontmatter subset used by the bundled SKILL.md files:
72
+ * single-line `key: value` scalars. Unknown keys are ignored, matching the
73
+ * shipped filesystem provider's tolerance. */
74
+ function parseSkillFile(raw, path) {
75
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw);
76
+ if (frontmatter?.[1] === undefined)
77
+ throw new Error(`${path}: missing YAML frontmatter`);
78
+ const fields = {};
79
+ for (const line of frontmatter[1].split(/\r?\n/u)) {
80
+ const match = /^([A-Za-z][\w-]*):[ \t]*(.*)$/u.exec(line);
81
+ if (match === null) continue;
82
+ let value = match[2].trim();
83
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
84
+ value = value.slice(1, -1);
85
+ fields[match[1]] = value;
86
+ }
87
+ if (typeof fields.name !== "string" || fields.name.length === 0)
88
+ throw new Error(`${path}: frontmatter requires name`);
89
+ if (typeof fields.description !== "string" || fields.description.length === 0)
90
+ throw new Error(`${path}: frontmatter requires description`);
91
+ return { name: fields.name, description: fields.description, content: raw.slice(frontmatter[0].length).trim() };
92
+ }
93
+
94
+ /** Build one bundled-skill candidate from a skill directory. */
95
+ function loadSkillCandidate(directory) {
96
+ const path = join(directory, "SKILL.md");
97
+ const parsed = parseSkillFile(readFileSync(path, "utf8"), path);
98
+ return {
99
+ name: parsed.name,
100
+ description: parsed.description,
101
+ invocation: { modelInvocable: true, userInvocable: true },
102
+ provider: PROVIDER_NAME,
103
+ source: "bundled",
104
+ rank: BUNDLED_SKILL_RANK,
105
+ resourceBase: { kind: "directory", path: `${directory}/` },
106
+ locator: path,
107
+ };
108
+ }
109
+
110
+ /** Scan ./skills at apply time; broken entries warn and drop, never throw. */
111
+ function loadSkillCandidates(root, warn) {
112
+ const candidates = [];
113
+ let entries;
114
+ try {
115
+ entries = readdirSync(root, { withFileTypes: true });
116
+ } catch {
117
+ warn(`skills directory missing or unreadable: ${root}`);
118
+ return candidates;
119
+ }
120
+ for (const entry of entries) {
121
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
122
+ const directory = join(root, entry.name);
123
+ try {
124
+ candidates.push(loadSkillCandidate(directory));
125
+ } catch (error) {
126
+ warn(`skill directory ${directory} ignored: ${error?.message ?? error}`);
127
+ }
128
+ }
129
+ return candidates;
130
+ }
131
+
132
+ /** Register the bundled dsh-switchman skill provider on `ctx.skills` and
133
+ * mount the language (lang), dispatch (dispatch), and watermark
134
+ * (context-watch) layers. */
135
+ function apply(ctx, config) {
136
+ const root = fileURLToPath(new URL("./skills/", import.meta.url));
137
+ const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
138
+ const candidates = loadSkillCandidates(root, warn);
139
+ const provider = {
140
+ name: PROVIDER_NAME,
141
+ list: () => Promise.resolve(candidates),
142
+ async get(candidate) {
143
+ const { rank: _rank, locator, ...summary } = candidate;
144
+ return { ...summary, content: parseSkillFile(readFileSync(locator, "utf8"), locator).content };
145
+ },
146
+ };
147
+ ctx.skills.registerProvider(() => provider);
148
+ applyLanguage(ctx, config);
149
+ applyDispatch(ctx, config);
150
+ applyContextWatch(ctx, config);
151
+ applyRoutes(ctx, config);
152
+ }
153
+
154
+ export { apply, inject, name };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-switchman",
4
+ "description": "Autonomous Agent Teams for the four shipped presets, plus language preferences, dispatch pools with model ranking, and context watermark control."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "dsh-switchman 调度管家",
4
+ "description": "为四个出厂预设注入「默认委派 + 分级验证」的自主智能体团队规程(总纲:返工是最贵的 token),并提供多语言偏好、派发池与模型能力排序、上下文水位控制的设置页。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "dsh-switchman",
3
+ "version": "1.3.0",
4
+ "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "cordis",
10
+ "agent-teams",
11
+ "preset",
12
+ "orchestration",
13
+ "context-watermark"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mrzturn/dsh-switchman.git"
18
+ },
19
+ "homepage": "https://github.com/mrzturn/dsh-switchman#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/mrzturn/dsh-switchman/issues"
22
+ },
23
+ "type": "module",
24
+ "main": "./index.js",
25
+ "icon": "./icon.svg",
26
+ "engines": {
27
+ "node": ">=24",
28
+ "dsh": ">=0.2.0-rc.0"
29
+ },
30
+ "exports": {
31
+ ".": "./index.js",
32
+ "./client": "./client.js",
33
+ "./package.json": "./package.json",
34
+ "./locale/*.json": "./locale/*.json"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "client.js",
39
+ "host/",
40
+ "skills/db-query/SKILL.md",
41
+ "skills/db-query/package.json",
42
+ "skills/db-query/package-lock.json",
43
+ "skills/db-query/scripts",
44
+ "skills/db-query/references",
45
+ "skills/git-commit-message/SKILL.md",
46
+ "skills/requirement-docs/SKILL.md",
47
+ "skills/requirement-docs/references",
48
+ "locale/",
49
+ "icon.svg",
50
+ "cordis.patch.yml"
51
+ ],
52
+ "dependencies": {
53
+ "@deepseek-ai/schemastery": "3.18.4"
54
+ },
55
+ "dsh": {
56
+ "manifestVersion": 1,
57
+ "bundle": {
58
+ "patch": "./cordis.patch.yml"
59
+ },
60
+ "client": {
61
+ "platform": "web",
62
+ "immediately": true,
63
+ "inject": [
64
+ "@deepseek-ai/dsh-client-locale",
65
+ "@deepseek-ai/dsh-client-ui-conversation",
66
+ "@deepseek-ai/dsh-client-ui-settings"
67
+ ]
68
+ }
69
+ }
70
+ }