dsh-switchman 1.11.5 → 1.11.7
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 +36 -29
- package/README.es.md +36 -29
- package/README.fr.md +36 -29
- package/README.it.md +36 -29
- package/README.ja.md +36 -29
- package/README.ko.md +36 -29
- package/README.md +38 -31
- package/README.pt.md +36 -29
- package/README.ru.md +36 -29
- package/README.zh-TW.md +36 -29
- package/README.zh.md +36 -29
- package/client.js +445 -35
- package/cordis.patch.yml +25 -9
- package/host/config.js +75 -30
- package/host/context-trim/index.js +806 -51
- package/host/context-trim/state.js +3 -2
- package/host/context-trim/surface.js +218 -7
- package/host/context-trim/trigger.js +29 -12
- package/host/context-trim/view.js +17 -2
- package/host/context-watch.js +17 -8
- package/host/intent.js +41 -4
- package/host/lang.js +4 -2
- package/host/ptc-bindings.js +510 -0
- package/host/ptc-guard.js +9 -1
- package/host/routes.js +111 -32
- package/host/teams.js +61 -15
- package/host/vision.js +24 -3
- package/index.js +5 -2
- package/package.json +1 -1
package/host/routes.js
CHANGED
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
* auto-selection and pinned decision).
|
|
33
33
|
* POST /api/dsh-switchman/intent-reset — { session } clears one session's
|
|
34
34
|
* intent decision state (debug/reset).
|
|
35
|
+
* GET /api/dsh-switchman/trim-pressure — live trim-pressure snapshot for
|
|
36
|
+
* the client dock chip (session-scoped
|
|
37
|
+
* via the optional `session` query):
|
|
38
|
+
* {available, mode, mass, window,
|
|
39
|
+
* pressure, view, wire, proj,
|
|
40
|
+
* advisoryLine, criticalLine,
|
|
41
|
+
* reliefEnabled, reliefCooldownRemaining}.
|
|
42
|
+
* Observability only — no gate reads it.
|
|
35
43
|
* POST /api/dsh-switchman/ui-locale — the client half reports its active
|
|
36
44
|
* UI locale (feeds the ask guidance's
|
|
37
45
|
* question language; no settings write).
|
|
@@ -49,6 +57,7 @@ import { INTENT_PRESETS, mergeIntentCandidates } from "./intent-presets.js";
|
|
|
49
57
|
import { pumpTeamsOrchestration, scheduleTeamsOrchestration, SWITCHMAN_SETTINGS_NS, teamsBundleState, teamsSyncSnapshot } from "./teams.js";
|
|
50
58
|
import { reportedUiLocale, setReportedUiLocale } from "./ui-locale.js";
|
|
51
59
|
import { visionStateOf } from "./vision.js";
|
|
60
|
+
import { trimPressureSnapshotOf } from "./context-trim/index.js";
|
|
52
61
|
|
|
53
62
|
/** Route paths (client.js mirrors these literals). */
|
|
54
63
|
const ROUTES = {
|
|
@@ -61,6 +70,7 @@ const ROUTES = {
|
|
|
61
70
|
intentState: "/api/dsh-switchman/intent-state",
|
|
62
71
|
intentReset: "/api/dsh-switchman/intent-reset",
|
|
63
72
|
uiLocale: "/api/dsh-switchman/ui-locale",
|
|
73
|
+
trimPressure: "/api/dsh-switchman/trim-pressure",
|
|
64
74
|
};
|
|
65
75
|
|
|
66
76
|
/** Settings namespace this plugin's writes target: the LOADER ENTRY id from
|
|
@@ -88,31 +98,36 @@ const POOL_FIELDS = [
|
|
|
88
98
|
"poolVision",
|
|
89
99
|
"poolReview",
|
|
90
100
|
];
|
|
91
|
-
const NUMBER_FIELDS = [
|
|
92
|
-
"wmSoftTokens",
|
|
93
|
-
"wmHardTokens",
|
|
94
|
-
"wmForceTokens",
|
|
95
|
-
"wmReadBudgetTokens",
|
|
96
|
-
"wmSubagentForceTokens",
|
|
97
|
-
];
|
|
98
101
|
/** Watermark numbers with REAL bounds (host/config.js schema): rejected (not
|
|
99
|
-
* clamped) on write, defaulted on read —
|
|
100
|
-
*
|
|
102
|
+
* clamped) on write, defaulted on read — a negative-only check would let a 0
|
|
103
|
+
* ratio silently disable subagent tier scaling, and let an astronomic token
|
|
104
|
+
* budget (a fat-fingered or hostile write) wedge the tier math. The token cap
|
|
105
|
+
* is 10,000,000: every real model window is orders of magnitude smaller. This
|
|
106
|
+
* group MUST mirror the client page's WM_NUMBER_FIELDS: a field coerced here
|
|
107
|
+
* but never sent by the client fails every fenced write (and vice versa). */
|
|
101
108
|
const WM_BOUNDED_NUMBER_FIELDS = [
|
|
109
|
+
{ field: "wmSoftTokens", min: 1000, max: 10000000, def: 80000 },
|
|
110
|
+
{ field: "wmHardTokens", min: 2000, max: 10000000, def: 100000 },
|
|
111
|
+
{ field: "wmForceTokens", min: 3000, max: 10000000, def: 200000 },
|
|
112
|
+
{ field: "wmReadBudgetTokens", min: 200, max: 10000000, def: 3500 },
|
|
113
|
+
{ field: "wmSubagentForceTokens", min: 0, max: 10000000, def: 0 },
|
|
102
114
|
{ field: "wmSubagentWindowRatio", min: 0.1, max: 1, def: 0.75 },
|
|
103
115
|
];
|
|
104
|
-
/** Trim numbers carry their own schema bounds (host/config.js), so
|
|
105
|
-
*
|
|
106
|
-
*
|
|
116
|
+
/** Trim numbers carry their own schema bounds (host/config.js), so a
|
|
117
|
+
* negative-only coercion is too loose — these validate against each
|
|
118
|
+
* field's declared min/max. This group
|
|
107
119
|
* MUST mirror the client page's TRIM_NUMBER_FIELDS: a field coerced here
|
|
108
120
|
* but never sent by the client fails every fenced write (and vice versa). */
|
|
109
121
|
const TRIM_NUMBER_FIELDS = [
|
|
110
122
|
{ field: "trimGrowthTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 22500 },
|
|
111
|
-
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def:
|
|
112
|
-
{ field: "trimPressurePercent", min: 5, max: 100, def:
|
|
113
|
-
{ field: "trimEmergencyPercent", min: 5, max: 100, def:
|
|
123
|
+
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 45000 },
|
|
124
|
+
{ field: "trimPressurePercent", min: 5, max: 100, def: 45 },
|
|
125
|
+
{ field: "trimEmergencyPercent", min: 5, max: 100, def: 90 },
|
|
126
|
+
{ field: "trimAdvisoryPercent", min: 5, max: 100, def: 50 },
|
|
127
|
+
{ field: "trimAdvisoryCriticalPercent", min: 5, max: 100, def: 80 },
|
|
128
|
+
{ field: "trimAdvisoryRepeatMs", min: 5000, max: 3600000, def: 120000 },
|
|
114
129
|
{ field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
|
|
115
|
-
{ field: "trimProtectedTail", min: 0, max: 100, def:
|
|
130
|
+
{ field: "trimProtectedTail", min: 0, max: 100, def: 6 },
|
|
116
131
|
{ field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
|
|
117
132
|
{ field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
|
|
118
133
|
{ field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
|
|
@@ -137,18 +152,21 @@ const BOOLEAN_FIELDS = [
|
|
|
137
152
|
// Context trim switches (mirrors the trim group in host/config.js).
|
|
138
153
|
"trimEnabled",
|
|
139
154
|
"trimLadderEnabled",
|
|
155
|
+
"trimProtectTaskIntent",
|
|
156
|
+
"trimCriticalRelief",
|
|
157
|
+
"trimVerboseLog",
|
|
140
158
|
"surfaceFold",
|
|
141
159
|
];
|
|
142
|
-
/** Intent numbers carry their own schema bounds (config.js), so
|
|
143
|
-
*
|
|
144
|
-
*
|
|
160
|
+
/** Intent numbers carry their own schema bounds (config.js), so a
|
|
161
|
+
* negative-only coercion is too loose — these validate against each
|
|
162
|
+
* field's declared min/max. */
|
|
145
163
|
const INTENT_NUMBER_FIELDS = [
|
|
146
164
|
{ field: "intentCacheTtlSeconds", min: 0, max: Number.POSITIVE_INFINITY, def: 300 },
|
|
147
165
|
{ field: "intentSwitchConfidence", min: 0, max: 100, def: 80 },
|
|
148
166
|
{ field: "intentTimeoutMs", min: 200, max: Number.POSITIVE_INFINITY, def: 60000 },
|
|
149
167
|
{ field: "intentHistoryMessages", min: 1, max: 20, def: 6 },
|
|
150
168
|
{ field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
|
|
151
|
-
{ field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def:
|
|
169
|
+
{ field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 250000 },
|
|
152
170
|
];
|
|
153
171
|
/** Intent classifier routes round-trip the "unset" {provider:"", model:""}
|
|
154
172
|
* pair, so they validate against the loosened cleanIntentRoute (both keys
|
|
@@ -222,6 +240,28 @@ function isLoopbackHostname(hostname) {
|
|
|
222
240
|
return parts.length === 4 && parts.every((p) => /^\d+$/u.test(p)) && parts[0] === "127";
|
|
223
241
|
}
|
|
224
242
|
|
|
243
|
+
/** Whether the socket peer address is loopback. Absent (non-TCP transport,
|
|
244
|
+
* e.g. a unix-socket/IPC listener) passes — the Host/Origin header fence
|
|
245
|
+
* still applies. A PRESENT address must be loopback so a LAN or remote
|
|
246
|
+
* TCP peer can never ride a forged Host header through the fence. */
|
|
247
|
+
function isLoopbackRemoteAddress(request) {
|
|
248
|
+
const raw = request.socket?.remoteAddress ?? request.connection?.remoteAddress;
|
|
249
|
+
if (raw === undefined || raw === null || raw === "") return true;
|
|
250
|
+
const host = raw.startsWith("::ffff:") ? raw.slice(7) : raw;
|
|
251
|
+
if (host === "::1") return true;
|
|
252
|
+
const parts = host.split(".");
|
|
253
|
+
return parts.length === 4 && parts.every((p) => /^\d{1,3}$/u.test(p)) && parts[0] === "127";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Whether a state-changing request declares a JSON body. Same-origin
|
|
257
|
+
* fetches always do (client.js sets content-type on every POST); simple
|
|
258
|
+
* cross-origin form posts cannot — belt-and-braces under the
|
|
259
|
+
* Host/socket fence. */
|
|
260
|
+
function isJsonContentType(request) {
|
|
261
|
+
const header = request.headers["content-type"];
|
|
262
|
+
return typeof header === "string" && header.trim().toLowerCase().startsWith("application/json");
|
|
263
|
+
}
|
|
264
|
+
|
|
225
265
|
/** Whether this request comes from this machine's own browser page. */
|
|
226
266
|
function isLoopbackRequest(request) {
|
|
227
267
|
const host = request.headers?.host;
|
|
@@ -233,6 +273,7 @@ function isLoopbackRequest(request) {
|
|
|
233
273
|
return false;
|
|
234
274
|
}
|
|
235
275
|
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
276
|
+
if (!isLoopbackRemoteAddress(request)) return false;
|
|
236
277
|
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
237
278
|
const origin = request.headers.origin;
|
|
238
279
|
if (origin === undefined) return true;
|
|
@@ -386,10 +427,6 @@ export function snapshotOf(config) {
|
|
|
386
427
|
const raw = Number(rawOf(config?.[field]));
|
|
387
428
|
values[field] = Number.isFinite(raw) ? raw : def;
|
|
388
429
|
}
|
|
389
|
-
for (const field of NUMBER_FIELDS) {
|
|
390
|
-
const raw = Number(rawOf(config?.[field]));
|
|
391
|
-
values[field] = Number.isFinite(raw) ? raw : 0;
|
|
392
|
-
}
|
|
393
430
|
for (const { field, def } of WM_BOUNDED_NUMBER_FIELDS) {
|
|
394
431
|
const raw = Number(rawOf(config?.[field]));
|
|
395
432
|
values[field] = Number.isFinite(raw) ? raw : def;
|
|
@@ -413,6 +450,20 @@ function whitelistSyncOf(config) {
|
|
|
413
450
|
/** Coerce and validate a client-submitted values object; null when invalid. */
|
|
414
451
|
/** Validate+normalize an incoming settings payload; exported so the
|
|
415
452
|
* smoke suite can exercise the PUT-fence round-trip invariant. */
|
|
453
|
+
/** Strict numeric coercion for client-submitted settings. Only finite
|
|
454
|
+
* JSON numbers and plain decimal strings coerce; "", booleans, null,
|
|
455
|
+
* and hex/other-radix strings become NaN (rejected by the callers'
|
|
456
|
+
* Number.isFinite check) instead of silently becoming 0/1/16. */
|
|
457
|
+
function strictNumberOf(value) {
|
|
458
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : Number.NaN;
|
|
459
|
+
if (typeof value === "string") {
|
|
460
|
+
const trimmed = value.trim();
|
|
461
|
+
if (trimmed === "" || /^[+-]?0[xXbBoO]/u.test(trimmed) || !/^[+-]?\d+(\.\d+)?$/u.test(trimmed)) return Number.NaN;
|
|
462
|
+
return Number(trimmed);
|
|
463
|
+
}
|
|
464
|
+
return Number.NaN;
|
|
465
|
+
}
|
|
466
|
+
|
|
416
467
|
export function coerceValues(input) {
|
|
417
468
|
if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
|
|
418
469
|
const values = {};
|
|
@@ -455,7 +506,7 @@ export function coerceValues(input) {
|
|
|
455
506
|
if (!ENUM_FIELDS.intentClassifierKind.includes(input.intentClassifierKind)) return null;
|
|
456
507
|
values.intentClassifierKind = input.intentClassifierKind;
|
|
457
508
|
for (const { field, min, max } of INTENT_NUMBER_FIELDS) {
|
|
458
|
-
const raw =
|
|
509
|
+
const raw = strictNumberOf(input[field]);
|
|
459
510
|
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
460
511
|
values[field] = raw;
|
|
461
512
|
}
|
|
@@ -472,17 +523,12 @@ export function coerceValues(input) {
|
|
|
472
523
|
values[field] = input[field];
|
|
473
524
|
}
|
|
474
525
|
for (const { field, min, max } of TRIM_NUMBER_FIELDS) {
|
|
475
|
-
const raw =
|
|
526
|
+
const raw = strictNumberOf(input[field]);
|
|
476
527
|
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
477
528
|
values[field] = raw;
|
|
478
529
|
}
|
|
479
|
-
for (const field of NUMBER_FIELDS) {
|
|
480
|
-
const raw = Number(input[field]);
|
|
481
|
-
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
482
|
-
values[field] = raw;
|
|
483
|
-
}
|
|
484
530
|
for (const { field, min, max } of WM_BOUNDED_NUMBER_FIELDS) {
|
|
485
|
-
const raw =
|
|
531
|
+
const raw = strictNumberOf(input[field]);
|
|
486
532
|
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
487
533
|
values[field] = raw;
|
|
488
534
|
}
|
|
@@ -591,6 +637,10 @@ export function makeRoutes(ctx, config) {
|
|
|
591
637
|
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
592
638
|
return;
|
|
593
639
|
}
|
|
640
|
+
if (!isJsonContentType(req)) {
|
|
641
|
+
writeJson(res, 415, { error: "unsupported media type: content-type must be application/json" });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
594
644
|
let body;
|
|
595
645
|
try {
|
|
596
646
|
body = await readJsonBody(req);
|
|
@@ -606,6 +656,10 @@ export function makeRoutes(ctx, config) {
|
|
|
606
656
|
// Optimistic-concurrency fence: when the client submits the
|
|
607
657
|
// snapshot it loaded (`expected`), require it to still match.
|
|
608
658
|
const current = snapshotOf(config);
|
|
659
|
+
if (body?.expected === undefined || body?.expected === null) {
|
|
660
|
+
writeJson(res, 400, { error: "missing expected snapshot: the optimistic-concurrency fence is required — send the values snapshot you loaded" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
609
663
|
if (body.expected !== undefined) {
|
|
610
664
|
const expected = coerceValues(body.expected);
|
|
611
665
|
// Compare candidate rows through the idempotent merge on
|
|
@@ -724,11 +778,32 @@ export function makeRoutes(ctx, config) {
|
|
|
724
778
|
}
|
|
725
779
|
},
|
|
726
780
|
},
|
|
781
|
+
{
|
|
782
|
+
kind: "exact",
|
|
783
|
+
path: ROUTES.trimPressure,
|
|
784
|
+
handler: async (req, res) => {
|
|
785
|
+
if (!guard(req, res, "GET")) return;
|
|
786
|
+
try {
|
|
787
|
+
// Optional `session` query parameter: the dock chip is
|
|
788
|
+
// session-scoped; a missing session id reports available=false
|
|
789
|
+
// (never another conversation's pressure).
|
|
790
|
+
const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
|
|
791
|
+
writeJson(res, 200, trimPressureSnapshotOf(typeof session === "string" && session !== "" ? session : ""));
|
|
792
|
+
} catch (error) {
|
|
793
|
+
ctx.logger.warn(`dsh-switchman: trim-pressure read failed: ${error?.message ?? error}`);
|
|
794
|
+
writeJson(res, 500, { error: error?.message ?? String(error) });
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
},
|
|
727
798
|
{
|
|
728
799
|
kind: "exact",
|
|
729
800
|
path: ROUTES.intentReset,
|
|
730
801
|
handler: async (req, res) => {
|
|
731
802
|
if (!guard(req, res, "POST")) return;
|
|
803
|
+
if (!isJsonContentType(req)) {
|
|
804
|
+
writeJson(res, 415, { error: "unsupported media type: content-type must be application/json" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
732
807
|
let body;
|
|
733
808
|
try {
|
|
734
809
|
body = await readJsonBody(req);
|
|
@@ -750,6 +825,10 @@ export function makeRoutes(ctx, config) {
|
|
|
750
825
|
path: ROUTES.uiLocale,
|
|
751
826
|
handler: async (req, res) => {
|
|
752
827
|
if (!guard(req, res, "POST")) return;
|
|
828
|
+
if (!isJsonContentType(req)) {
|
|
829
|
+
writeJson(res, 415, { error: "unsupported media type: content-type must be application/json" });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
753
832
|
let body;
|
|
754
833
|
try {
|
|
755
834
|
body = await readJsonBody(req);
|
package/host/teams.js
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* it never runs synchronously inside plugin apply (bundle changes must not
|
|
15
15
|
* nest inside the apply HMR transaction — boot/hmr forbids it): first
|
|
16
16
|
* `pluginManager.setBundleEnabled('@deepseek-ai/dsh-experimental-agent-
|
|
17
|
-
* team-profile', true)` (
|
|
18
|
-
* — only when `syncWhitelist` is also ON — one replace-sync of the
|
|
19
|
-
*
|
|
17
|
+
* team-profile', true)` (never disabled on OFF; re-verified every run),
|
|
18
|
+
* then — only when `syncWhitelist` is also ON — one replace-sync of the
|
|
19
|
+
* dispatch model union (six pools + modelRank) into DSH's
|
|
20
|
+
* `subagent-model-selection-settings` whitelist
|
|
20
21
|
* (`settings.describe()` → `settings.mutate(...)`, re-describing and
|
|
21
22
|
* retrying up to 3 attempts on SettingsConflictError). Replace semantics:
|
|
22
23
|
* switchman is the single source of truth, so the deduplicated exact
|
|
@@ -111,14 +112,15 @@ function readBool(field) {
|
|
|
111
112
|
// ---- module state ----------------------------------------------------------
|
|
112
113
|
|
|
113
114
|
/** Last whitelist replace-sync outcome for routes.js's whitelistSync config
|
|
114
|
-
* field; null when no sync has run in this process.
|
|
115
|
+
* field; null when no sync has run in this process. The synced set is the
|
|
116
|
+
* dispatch model union (six pools + modelRank). Shape:
|
|
115
117
|
* { ok: boolean, count: number, at: number, error?: string }. */
|
|
116
118
|
let lastSync = null;
|
|
117
119
|
|
|
118
120
|
/** Last bundle-ensure outcome for routes.js's teamsBundle field; null before
|
|
119
121
|
* the first orchestration. Shape: { ok, at, state, error? } with state in
|
|
120
|
-
* selected | applied | restart-required |
|
|
121
|
-
* application value> | unavailable. */
|
|
122
|
+
* selected | applied | restart-required | not-offered | failed | cancelled
|
|
123
|
+
* | <unknown application value> | unavailable. */
|
|
122
124
|
let lastBundleState = null;
|
|
123
125
|
|
|
124
126
|
/** Set when a run must be retried from a clean context: either it was
|
|
@@ -147,10 +149,16 @@ function isNestedHmrError(error) {
|
|
|
147
149
|
return /cannot be nested/iu.test(String(error?.message ?? ""));
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
/** Deduplicated exact (provider, model) union of the six pools
|
|
151
|
-
*
|
|
152
|
+
/** Deduplicated exact (provider, model) union of the six pools PLUS
|
|
153
|
+
* modelRank. modelRank belongs in the whitelist union: its head is the
|
|
154
|
+
* dispatch fallback route (orderedPool head / modelRank[0] in dispatch.js),
|
|
155
|
+
* enforce mode denies any route outside "pools or modelRank", and a
|
|
156
|
+
* rank-only head would otherwise leave subagent dispatch unable to name
|
|
157
|
+
* its own fallback (the whitelist silently strips it). Rank entries are
|
|
158
|
+
* { provider, model, tier? }; tier is ignored here. Exact string identity
|
|
159
|
+
* (trimmed) — DSH's whitelist authorizes exact pairs and its
|
|
152
160
|
* assertAllowedModelRoutes rejects repeated provider/model routes outright. */
|
|
153
|
-
function
|
|
161
|
+
function dispatchModelUnion(config) {
|
|
154
162
|
const seen = new Set();
|
|
155
163
|
const routes = [];
|
|
156
164
|
for (const lane of LANES) {
|
|
@@ -167,6 +175,19 @@ function poolUnion(config) {
|
|
|
167
175
|
routes.push({ provider, model });
|
|
168
176
|
}
|
|
169
177
|
}
|
|
178
|
+
const rank = readValue(config?.modelRank);
|
|
179
|
+
if (Array.isArray(rank)) {
|
|
180
|
+
for (const entry of rank) {
|
|
181
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
182
|
+
const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
|
|
183
|
+
const model = typeof entry.model === "string" ? entry.model.trim() : "";
|
|
184
|
+
if (provider === "" || model === "") continue;
|
|
185
|
+
const key = `${provider}\u0000${model}`;
|
|
186
|
+
if (seen.has(key)) continue;
|
|
187
|
+
seen.add(key);
|
|
188
|
+
routes.push({ provider, model });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
170
191
|
return routes;
|
|
171
192
|
}
|
|
172
193
|
|
|
@@ -183,8 +204,11 @@ function poolUnion(config) {
|
|
|
183
204
|
* before reload can throw). There is deliberately NO process-level success
|
|
184
205
|
* latch — the DSH-side toggle can be flipped off at any time, so every
|
|
185
206
|
* orchestration re-verifies via listBundles and re-enables when needed.
|
|
186
|
-
* A
|
|
187
|
-
*
|
|
207
|
+
* A bundle this DSH installation does not offer at all (absent from the
|
|
208
|
+
* listBundles inventory) is terminal, not a failure: enabling it could
|
|
209
|
+
* never succeed, so the orchestration records not-offered once and does
|
|
210
|
+
* not arm a retry. A missing pluginManager service only warns; the
|
|
211
|
+
* doctrine section still renders, the tools simply stay absent. */
|
|
188
212
|
async function ensureTeamsBundle(ctx) {
|
|
189
213
|
const manager = ctx.get?.("pluginManager", false);
|
|
190
214
|
if (manager === undefined || manager === null || typeof manager.setBundleEnabled !== "function") {
|
|
@@ -198,6 +222,28 @@ async function ensureTeamsBundle(ctx) {
|
|
|
198
222
|
const selected = Array.isArray(bundles)
|
|
199
223
|
? bundles.find((item) => item?.name === TEAMS_BUNDLE)
|
|
200
224
|
: undefined;
|
|
225
|
+
if (selected === undefined) {
|
|
226
|
+
// Not in the inventory at all. listBundles enumerates the manifest
|
|
227
|
+
// selection ∪ recorded dependencies ∪ the installation manifest ∪
|
|
228
|
+
// the official on-demand catalog; app-boot strips retired bundles
|
|
229
|
+
// from the manifest, and bundleManifest() resolves packages only
|
|
230
|
+
// from those same sources. A name absent from all of them cannot
|
|
231
|
+
// be enabled — DSH's toggle would resolve application:'failed'
|
|
232
|
+
// (not-bundle) on every attempt — so record the terminal state
|
|
233
|
+
// once and leave pendingNestedRetry untouched (no retry loop
|
|
234
|
+
// against a fact; a future DSH update that ships the bundle
|
|
235
|
+
// re-enters this function at the next orchestration trigger).
|
|
236
|
+
lastBundleState = {
|
|
237
|
+
ok: false,
|
|
238
|
+
at: Date.now(),
|
|
239
|
+
state: "not-offered",
|
|
240
|
+
error: TEAMS_BUNDLE + " is not offered by this DSH installation"
|
|
241
|
+
+ " (absent from the profile manifest, dependencies, and the"
|
|
242
|
+
+ " official catalog); enable skipped",
|
|
243
|
+
};
|
|
244
|
+
ctx.logger?.warn?.("dsh-switchman: agent-team bundle not offered by this installation; enable skipped (teams tools stay absent)");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
201
247
|
if (selected?.enabled === true) {
|
|
202
248
|
// Already selected in the profile manifest: no call, no reload —
|
|
203
249
|
// and deliberately NO process-level latch. The user may flip the
|
|
@@ -246,12 +292,12 @@ async function ensureTeamsBundle(ctx) {
|
|
|
246
292
|
}
|
|
247
293
|
}
|
|
248
294
|
|
|
249
|
-
/** One replace-sync of the
|
|
295
|
+
/** One replace-sync of the dispatch model union into the DSH whitelist. describe →
|
|
250
296
|
* mutate with the fresh revision; SettingsConflictError retries (re-describe
|
|
251
297
|
* first) up to MAX_MUTATE_ATTEMPTS total. Every terminal outcome lands in
|
|
252
298
|
* the module-level lastSync status; nothing throws. */
|
|
253
299
|
async function syncWhitelist(ctx, config) {
|
|
254
|
-
const routes =
|
|
300
|
+
const routes = dispatchModelUnion(config);
|
|
255
301
|
if (routes.length === 0) {
|
|
256
302
|
// DSH's invariant: enabled=true with an empty allowedModels breaks
|
|
257
303
|
// every new session's composition — skipping is the only safe move.
|
|
@@ -259,7 +305,7 @@ async function syncWhitelist(ctx, config) {
|
|
|
259
305
|
ok: false,
|
|
260
306
|
count: 0,
|
|
261
307
|
at: Date.now(),
|
|
262
|
-
error: "
|
|
308
|
+
error: "dispatch model union (six pools + modelRank) is empty; sync skipped (enabled=true requires a non-empty allowedModels)",
|
|
263
309
|
};
|
|
264
310
|
ctx.logger?.warn?.(`dsh-switchman: whitelist sync skipped: ${lastSync.error}`);
|
|
265
311
|
return;
|
|
@@ -583,4 +629,4 @@ function applyTeams(ctx, config) {
|
|
|
583
629
|
syncChildMessageTool();
|
|
584
630
|
}
|
|
585
631
|
|
|
586
|
-
export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, TEAMS_SECTION_TEXT, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
|
|
632
|
+
export { applyTeams, dispatchModelUnion, ensureTeamsBundle, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, TEAMS_SECTION_TEXT, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
|
package/host/vision.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import { execFile as execFileCb } from "node:child_process";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { constants } from "node:fs";
|
|
38
|
-
import { access, copyFile, mkdir, open, rm } from "node:fs/promises";
|
|
38
|
+
import { access, copyFile, mkdir, open, rename, rm } from "node:fs/promises";
|
|
39
39
|
import { join } from "node:path";
|
|
40
40
|
import { promisify } from "node:util";
|
|
41
41
|
import { dshHome } from "./lib/dsh-home.js";
|
|
@@ -141,7 +141,19 @@ async function materializeImage(store, ref, warn) {
|
|
|
141
141
|
if (await exists(dst)) return { path: dst };
|
|
142
142
|
try {
|
|
143
143
|
await mkdir(dir, { recursive: true });
|
|
144
|
-
|
|
144
|
+
// Copy to a temp name and rename into place: copyFile interrupted
|
|
145
|
+
// mid-write (ENOSPC, process exit) must not leave a partial dst
|
|
146
|
+
// that the exists() cache above would serve forever (the same
|
|
147
|
+
// corrupt-cache invariant looksLikePng guards on the transcode
|
|
148
|
+
// path). rename is atomic on the same directory.
|
|
149
|
+
const tmp = `${dst}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
150
|
+
try {
|
|
151
|
+
await copyFile(src, tmp);
|
|
152
|
+
await rename(tmp, dst);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await drop(tmp);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
145
157
|
return { path: dst };
|
|
146
158
|
} catch (error) {
|
|
147
159
|
warn(`vision: native copy failed (${error?.message ?? error})`);
|
|
@@ -184,7 +196,16 @@ async function materializeImage(store, ref, warn) {
|
|
|
184
196
|
const ext = FALLBACK_EXT.get(String(ref?.mediaType ?? "").toLowerCase()) ?? "img";
|
|
185
197
|
const raw = join(dir, `${hash}.${ext}`);
|
|
186
198
|
try {
|
|
187
|
-
|
|
199
|
+
// Same partial-file guard as the native path above: the fallback
|
|
200
|
+
// copy must not leave a half-written ${hash}.${ext} behind either.
|
|
201
|
+
const tmp = `${raw}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
202
|
+
try {
|
|
203
|
+
await copyFile(src, tmp);
|
|
204
|
+
await rename(tmp, raw);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
await drop(tmp);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
188
209
|
warn(`vision: no PNG converter available; kept ${ref?.mediaType} copy at ${raw}`);
|
|
189
210
|
return {
|
|
190
211
|
path: raw,
|
package/index.js
CHANGED
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
* Teams mode mounts two more layers: host/teams.js carries the team clauses
|
|
35
35
|
* extracted from the cordis.patch.yml suffix in a `[SWITCHMAN:TEAMS]` section
|
|
36
36
|
* (order 10250, injected only while teamsMode is ON) plus the deferred ON
|
|
37
|
-
* orchestration (agent-team bundle enable, then the optional
|
|
38
|
-
* → DSH whitelist replace-sync);
|
|
37
|
+
* orchestration (agent-team bundle enable, then the optional dispatch-model
|
|
38
|
+
* union — six pools + modelRank — → DSH whitelist replace-sync);
|
|
39
|
+
* host/subagent-model.js annotates successful
|
|
39
40
|
* subagent / subagent_fork results with one `model: provider/model@effort`
|
|
40
41
|
* line from the child session's modelSelection projection (the same channel
|
|
41
42
|
* host/vision.js reuses for the per-session vision-state verdict).
|
|
@@ -79,6 +80,7 @@ import { applyDispatch } from "./host/dispatch.js";
|
|
|
79
80
|
import { applyTeams } from "./host/teams.js";
|
|
80
81
|
// PTC direct-call guard (final-say transport rule for PTC-executor scopes).
|
|
81
82
|
import { applyPtcGuard } from "./host/ptc-guard.js";
|
|
83
|
+
import { applyPtcBindings } from "./host/ptc-bindings.js";
|
|
82
84
|
// Subagent model-visibility layer (post-execute `model:` annotation).
|
|
83
85
|
import { applySubagentModel } from "./host/subagent-model.js";
|
|
84
86
|
|
|
@@ -225,6 +227,7 @@ function apply(ctx, config) {
|
|
|
225
227
|
applyDispatch(ctx, config);
|
|
226
228
|
applyTeams(ctx, config);
|
|
227
229
|
applyPtcGuard(ctx);
|
|
230
|
+
applyPtcBindings(ctx);
|
|
228
231
|
applyContextWatch(ctx, config);
|
|
229
232
|
applyContextTrim(ctx, config);
|
|
230
233
|
applyVision(ctx, config);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.7",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|