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/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 — 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 },
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: 10 },
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 the shared
143
- * NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
144
- * 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. */
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: 200000 },
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 = Number(input[field]);
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 = Number(input[field]);
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 = Number(input[field]);
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)` (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.5",
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": [