dsh-switchman 1.11.6 → 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/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,33 +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 — NUMBER_FIELDS only rejects negatives,
100
- * which would let a 0 ratio silently disable subagent tier scaling. */
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 the
105
- * shared NUMBER_FIELDS coercion (which only rejects negatives) is too
106
- * loose — these validate against each field's declared min/max. This group
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: 25000 },
112
- { field: "trimPressurePercent", min: 5, max: 100, def: 65 },
113
- { field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
114
- { field: "trimAdvisoryPercent", min: 5, max: 100, def: 65 },
115
- { field: "trimAdvisoryCriticalPercent", min: 5, max: 100, def: 95 },
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 },
116
129
  { field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
117
- { field: "trimProtectedTail", min: 0, max: 100, def: 10 },
130
+ { field: "trimProtectedTail", min: 0, max: 100, def: 6 },
118
131
  { field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
119
132
  { field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
120
133
  { field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
@@ -140,18 +153,20 @@ const BOOLEAN_FIELDS = [
140
153
  "trimEnabled",
141
154
  "trimLadderEnabled",
142
155
  "trimProtectTaskIntent",
156
+ "trimCriticalRelief",
157
+ "trimVerboseLog",
143
158
  "surfaceFold",
144
159
  ];
145
- /** Intent numbers carry their own schema bounds (config.js), so the shared
146
- * NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
147
- * validate against each field's declared min/max. */
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. */
148
163
  const INTENT_NUMBER_FIELDS = [
149
164
  { field: "intentCacheTtlSeconds", min: 0, max: Number.POSITIVE_INFINITY, def: 300 },
150
165
  { field: "intentSwitchConfidence", min: 0, max: 100, def: 80 },
151
166
  { field: "intentTimeoutMs", min: 200, max: Number.POSITIVE_INFINITY, def: 60000 },
152
167
  { field: "intentHistoryMessages", min: 1, max: 20, def: 6 },
153
168
  { field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
154
- { field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 200000 },
169
+ { field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 250000 },
155
170
  ];
156
171
  /** Intent classifier routes round-trip the "unset" {provider:"", model:""}
157
172
  * pair, so they validate against the loosened cleanIntentRoute (both keys
@@ -225,6 +240,28 @@ function isLoopbackHostname(hostname) {
225
240
  return parts.length === 4 && parts.every((p) => /^\d+$/u.test(p)) && parts[0] === "127";
226
241
  }
227
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
+
228
265
  /** Whether this request comes from this machine's own browser page. */
229
266
  function isLoopbackRequest(request) {
230
267
  const host = request.headers?.host;
@@ -236,6 +273,7 @@ function isLoopbackRequest(request) {
236
273
  return false;
237
274
  }
238
275
  if (!isLoopbackHostname(hostUrl.hostname)) return false;
276
+ if (!isLoopbackRemoteAddress(request)) return false;
239
277
  if (request.headers["sec-fetch-site"] === "cross-site") return false;
240
278
  const origin = request.headers.origin;
241
279
  if (origin === undefined) return true;
@@ -389,10 +427,6 @@ export function snapshotOf(config) {
389
427
  const raw = Number(rawOf(config?.[field]));
390
428
  values[field] = Number.isFinite(raw) ? raw : def;
391
429
  }
392
- for (const field of NUMBER_FIELDS) {
393
- const raw = Number(rawOf(config?.[field]));
394
- values[field] = Number.isFinite(raw) ? raw : 0;
395
- }
396
430
  for (const { field, def } of WM_BOUNDED_NUMBER_FIELDS) {
397
431
  const raw = Number(rawOf(config?.[field]));
398
432
  values[field] = Number.isFinite(raw) ? raw : def;
@@ -416,6 +450,20 @@ function whitelistSyncOf(config) {
416
450
  /** Coerce and validate a client-submitted values object; null when invalid. */
417
451
  /** Validate+normalize an incoming settings payload; exported so the
418
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
+
419
467
  export function coerceValues(input) {
420
468
  if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
421
469
  const values = {};
@@ -458,7 +506,7 @@ export function coerceValues(input) {
458
506
  if (!ENUM_FIELDS.intentClassifierKind.includes(input.intentClassifierKind)) return null;
459
507
  values.intentClassifierKind = input.intentClassifierKind;
460
508
  for (const { field, min, max } of INTENT_NUMBER_FIELDS) {
461
- const raw = Number(input[field]);
509
+ const raw = strictNumberOf(input[field]);
462
510
  if (!Number.isFinite(raw) || raw < min || raw > max) return null;
463
511
  values[field] = raw;
464
512
  }
@@ -475,17 +523,12 @@ export function coerceValues(input) {
475
523
  values[field] = input[field];
476
524
  }
477
525
  for (const { field, min, max } of TRIM_NUMBER_FIELDS) {
478
- const raw = Number(input[field]);
526
+ const raw = strictNumberOf(input[field]);
479
527
  if (!Number.isFinite(raw) || raw < min || raw > max) return null;
480
528
  values[field] = raw;
481
529
  }
482
- for (const field of NUMBER_FIELDS) {
483
- const raw = Number(input[field]);
484
- if (!Number.isFinite(raw) || raw < 0) return null;
485
- values[field] = raw;
486
- }
487
530
  for (const { field, min, max } of WM_BOUNDED_NUMBER_FIELDS) {
488
- const raw = Number(input[field]);
531
+ const raw = strictNumberOf(input[field]);
489
532
  if (!Number.isFinite(raw) || raw < min || raw > max) return null;
490
533
  values[field] = raw;
491
534
  }
@@ -594,6 +637,10 @@ export function makeRoutes(ctx, config) {
594
637
  writeJson(res, 405, { error: `method not allowed: ${req.method}` });
595
638
  return;
596
639
  }
640
+ if (!isJsonContentType(req)) {
641
+ writeJson(res, 415, { error: "unsupported media type: content-type must be application/json" });
642
+ return;
643
+ }
597
644
  let body;
598
645
  try {
599
646
  body = await readJsonBody(req);
@@ -609,6 +656,10 @@ export function makeRoutes(ctx, config) {
609
656
  // Optimistic-concurrency fence: when the client submits the
610
657
  // snapshot it loaded (`expected`), require it to still match.
611
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
+ }
612
663
  if (body.expected !== undefined) {
613
664
  const expected = coerceValues(body.expected);
614
665
  // Compare candidate rows through the idempotent merge on
@@ -727,11 +778,32 @@ export function makeRoutes(ctx, config) {
727
778
  }
728
779
  },
729
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
+ },
730
798
  {
731
799
  kind: "exact",
732
800
  path: ROUTES.intentReset,
733
801
  handler: async (req, res) => {
734
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
+ }
735
807
  let body;
736
808
  try {
737
809
  body = await readJsonBody(req);
@@ -753,6 +825,10 @@ export function makeRoutes(ctx, config) {
753
825
  path: ROUTES.uiLocale,
754
826
  handler: async (req, res) => {
755
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
+ }
756
832
  let body;
757
833
  try {
758
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)` (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
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. Shape:
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 | failed | cancelled | <unknown
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. Exact string
151
- * identity (trimmed) — DSH's whitelist authorizes exact pairs and its
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 poolUnion(config) {
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 missing pluginManager service only warns; the doctrine section still
187
- * renders, the tools simply stay absent. */
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 six-pool union into the DSH whitelist. describe →
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 = poolUnion(config);
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: "six-pool union is empty; sync skipped (enabled=true requires a non-empty allowedModels)",
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
- await copyFile(src, dst);
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
- await copyFile(src, raw);
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 six-pool
38
- * → DSH whitelist replace-sync); host/subagent-model.js annotates successful
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.6",
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": [