@ran-sh/dsh-crew 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { PanelHeader, PanelStyles } from './panel-chrome';
4
4
  // DSH Crew QUICK CONTROLS panel for the official 3080 surface.
5
5
  //
6
6
  // Narrow by design: only the user-facing master switch, flash/pro model
7
- // priority lists and vision/imagegen toggles — all other operations belong
7
+ // priority lists — all other operations belong
8
8
  // to the native 3210 full control plane. Talks only to the quick endpoints
9
9
  // (/_dsh/dsh-crew/quick-config, /quick-status). Runtime maintenance stays on 3210.
10
10
 
@@ -17,17 +17,9 @@ type QuickConfig = {
17
17
  subagents_enabled?: boolean;
18
18
  flash_model_priority?: ModelEntry[];
19
19
  pro_model_priority?: ModelEntry[];
20
- vision_enabled?: boolean;
21
- imagegen_enabled?: boolean;
22
- vision_provider?: string;
23
- imagegen_provider?: string;
24
20
  };
25
21
 
26
22
  const RESTART_KEYS = new Set([
27
- 'vision_enabled',
28
- 'imagegen_enabled',
29
- 'vision_provider',
30
- 'imagegen_provider',
31
23
  ]);
32
24
 
33
25
  const T = {
@@ -48,10 +40,7 @@ const T = {
48
40
  flash: 'Worker / Flash',
49
41
  pro: 'Reviewer / Pro',
50
42
  addModel: '+ 添加模型',
51
- multimodal: '多模态',
52
- vision: '视觉',
53
- imagegen: '生图',
54
- provider: 'Provider',
43
+ provider: 'Provider',
55
44
  applyRestart: '应用并重启 Crew',
56
45
  savedNeedsRestart: '配置已保存 · 需要重启 Crew 才会生效',
57
46
  saved: '已保存',
@@ -76,9 +65,6 @@ const T = {
76
65
  flash: 'Worker / Flash',
77
66
  pro: 'Reviewer / Pro',
78
67
  addModel: '+ Add model',
79
- multimodal: 'Multimodal',
80
- vision: 'Vision',
81
- imagegen: 'Imagegen',
82
68
  provider: 'Provider',
83
69
  applyRestart: 'Apply & restart Crew',
84
70
  savedNeedsRestart: 'Saved · restart Crew to take effect',
@@ -167,15 +153,11 @@ export function QuickPanel({ ctx }: { ctx: any }) {
167
153
  } finally { setBusy(false); }
168
154
  }, [t]);
169
155
 
170
- const toggle = (key: 'subagents_enabled' | 'vision_enabled' | 'imagegen_enabled', value: boolean) => {
156
+ const toggle = (key: 'subagents_enabled', value: boolean) => {
171
157
  setConfig((c) => ({ ...(c ?? {}), [key]: value }));
172
158
  void patch({ [key]: value } as QuickConfig);
173
159
  };
174
160
 
175
- const setProvider = (key: 'vision_provider' | 'imagegen_provider', value: string) => {
176
- setConfig((c) => ({ ...(c ?? {}), [key]: value }));
177
- void patch({ [key]: value } as QuickConfig);
178
- };
179
161
 
180
162
  const moveModel = (listKey: 'flash_model_priority' | 'pro_model_priority', index: number, dir: -1 | 1) => {
181
163
  const list = [...((config?.[listKey] as ModelEntry[] | undefined) ?? [])];
@@ -268,23 +250,6 @@ export function QuickPanel({ ctx }: { ctx: any }) {
268
250
  </div>
269
251
  {modelList('flash_model_priority', t.flash)}
270
252
  {modelList('pro_model_priority', t.pro)}
271
- <details className="crew-quick-group">
272
- <summary><strong>{t.multimodal}</strong><span className="crew-quick-count">{t.vision} / {t.imagegen}</span></summary>
273
- <div className="crew-quick-content">
274
- <div className="crew-quick-media">
275
- <label><input type="checkbox" disabled={busy} checked={config.vision_enabled === true}
276
- onChange={(e) => toggle('vision_enabled', e.target.checked)} /> {t.vision}</label>
277
- <input className="crew-quick-input" aria-label={`${t.vision} Provider`} placeholder={t.provider} disabled={busy} defaultValue={config.vision_provider ?? ''} key={`vision-${config.vision_provider ?? ''}`}
278
- onBlur={(e) => setProvider('vision_provider', e.target.value)} />
279
- </div>
280
- <div className="crew-quick-media">
281
- <label><input type="checkbox" disabled={busy} checked={config.imagegen_enabled === true}
282
- onChange={(e) => toggle('imagegen_enabled', e.target.checked)} /> {t.imagegen}</label>
283
- <input className="crew-quick-input" aria-label={`${t.imagegen} Provider`} placeholder={t.provider} disabled={busy} defaultValue={config.imagegen_provider ?? ''}
284
- onBlur={(e) => setProvider('imagegen_provider', e.target.value)} />
285
- </div>
286
- </div>
287
- </details>
288
253
  {restartPending && (
289
254
  <div className="crew-quick-row">
290
255
  <a className="crew-quick-btn primary" href={FULL} target="_blank" rel="noopener noreferrer">{t.openFull}</a>
@@ -26,7 +26,7 @@ export function classifyCrewSurface({ bridgeStatus, runtime } = {}) {
26
26
  /**
27
27
  * Surface capability model. The NATIVE 3210 Crew harness is the single full
28
28
  * control plane; the OFFICIAL 3080 surface is a narrow quick-controls panel
29
- * (total switch, flash/pro model priority, vision/imagegen toggles) plus a
29
+ * (total switch and the flash/pro model priority lists) plus a
30
30
  * deep link back to 3210. Unknown surfaces get diagnostics only — never write
31
31
  * authority. This replaces the old binary full-vs-minimal assumption.
32
32
  */
package/src/hub/entry.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { apply as applyHub, inject, name, WorkerRegistry } from './index.mjs';
11
11
  import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
12
12
  import { getProcessAdaptiveHealthStore } from '../adaptive-routing.mjs';
13
+ import { claimReleaseInUse } from '../release-in-use.mjs';
13
14
 
14
15
  const RUNTIME_PATH = '/_dsh/dsh-crew/runtime';
15
16
  const ADAPTIVE_OBSERVER_INSTALLED = Symbol.for('@ran-sh/dsh-crew/adaptive-observer-installed');
@@ -92,6 +93,11 @@ export function installAdaptiveHealthObserver() {
92
93
  }
93
94
 
94
95
  export async function apply(ctx) {
96
+ // Declare this release in use before anything else. The Hub loads several
97
+ // modules lazily with a cache-busting query, so deleting the release under a
98
+ // running process would break those routes with no way to recover but a
99
+ // restart. Retention reads these claims and leaves a live release alone.
100
+ claimReleaseInUse();
95
101
  registerRuntimeEndpoint(ctx);
96
102
  installAdaptiveHealthObserver();
97
103
  return applyHub(ctx);
package/src/hub/index.mjs CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  normalizeGlobalConfig,
13
13
  chooseDefaultTier,
14
14
  normalizeWorkerProviderMode,
15
- getMultimodalRegistrationPlan,
16
15
  canDispatchRole,
17
16
  resolveModelPolicy,
18
17
  resolveRoleTierHint,
@@ -112,10 +111,6 @@ export const QUICK_CONFIG_KEYS = Object.freeze([
112
111
  'subagents_enabled',
113
112
  'flash_model_priority',
114
113
  'pro_model_priority',
115
- 'vision_enabled',
116
- 'imagegen_enabled',
117
- 'vision_provider',
118
- 'imagegen_provider',
119
114
  ]);
120
115
  const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro' };
121
116
  // Local copy (the hub must not import jobs.mjs, which pulls the DSH SDK into
@@ -1366,39 +1361,6 @@ export async function apply(ctx) {
1366
1361
  while (credentialPurgePlans.size > 32) credentialPurgePlans.delete(credentialPurgePlans.keys().next().value);
1367
1362
  };
1368
1363
 
1369
- // Multimodal bridge: register describe_image / generate_image for the DS
1370
- // model. Config is read per call so settings-page edits apply live; the
1371
- // capability switches (vision_enabled / imagegen_enabled) decide which tools
1372
- // are registered at plugin boot, so toggling them takes effect on restart.
1373
- try {
1374
- const { createMultimodalTools } = await import('../multimodal.mjs');
1375
- const { readGlobalConfig } = await import('../install/install.mjs');
1376
- const plan = getMultimodalRegistrationPlan(normalizeGlobalConfig(readGlobalConfig()));
1377
- for (const tool of createMultimodalTools(() => readGlobalConfig())) {
1378
- if (!plan.tools[tool.name]) {
1379
- ctx.logger?.info?.(`dsh-crew: ${tool.name} not registered (disabled by capability switch)`);
1380
- continue;
1381
- }
1382
- disposers.push(ctx.effect(() => ctx.tools.register(tool), `dsh-crew: ${tool.name} tool`));
1383
- }
1384
- } catch (err) {
1385
- ctx.logger?.warn?.(`dsh-crew: multimodal tools unavailable: ${err?.message ?? err}`);
1386
- }
1387
-
1388
- // Vision route: image paste on the text-only DS models (admission adapter +
1389
- // pre-step transcription). Installed only while Crew Vision is enabled.
1390
- try {
1391
- const { installVisionRoute } = await import('../vision-route.mjs');
1392
- const { readGlobalConfig } = await import('../install/install.mjs');
1393
- if (getMultimodalRegistrationPlan(normalizeGlobalConfig(readGlobalConfig())).visionRoute) {
1394
- disposers.push(installVisionRoute(ctx, () => readGlobalConfig()));
1395
- } else {
1396
- ctx.logger?.info?.('dsh-crew: vision route not installed (Crew Vision disabled by capability switch)');
1397
- }
1398
- } catch (err) {
1399
- ctx.logger?.warn?.(`dsh-crew: vision route unavailable: ${err?.message ?? err}`);
1400
- }
1401
-
1402
1364
  ctx.inject(['webServer'], (webCtx) => {
1403
1365
  const webServer = webCtx.webServer;
1404
1366
  const disposers = [];
@@ -2676,43 +2638,6 @@ export async function apply(ctx) {
2676
2638
  },
2677
2639
  }));
2678
2640
 
2679
- disposers.push(webServer.register({
2680
- kind: 'exact', path: `${ROUTE_BASE}/vision-models`,
2681
- handler: async (req, res) => {
2682
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
2683
- try {
2684
- const url = new URL(req.url, 'http://localhost');
2685
- const provider = url.searchParams.get('provider');
2686
- const force = url.searchParams.get('refresh') === '1';
2687
- const lang = url.searchParams.get('lang');
2688
- adoptLang(lang);
2689
- const { listVisionModels } = await import('../multimodal.mjs');
2690
- const { readGlobalConfig } = await import('../install/install.mjs');
2691
- return sendJson(res, 200, { ok: true, models: await listVisionModels(provider, force, () => readGlobalConfig(), lang) });
2692
- } catch (err) {
2693
- return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
2694
- }
2695
- },
2696
- }));
2697
-
2698
- disposers.push(webServer.register({
2699
- kind: 'exact', path: `${ROUTE_BASE}/provider-test`,
2700
- handler: async (req, res) => {
2701
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
2702
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'POST only' });
2703
- try {
2704
- // The entry comes from the panel form so unsaved edits can be probed.
2705
- const entry = await readBody(req);
2706
- adoptLang(entry?.lang);
2707
- const { testProvider } = await import('../multimodal.mjs');
2708
- const result = await testProvider(entry, entry?.lang);
2709
- return sendJson(res, 200, { ok: true, result });
2710
- } catch (err) {
2711
- return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
2712
- }
2713
- },
2714
- }));
2715
-
2716
2641
  disposers.push(webServer.register({
2717
2642
  kind: 'exact', path: `${ROUTE_BASE}/install/status`,
2718
2643
  handler: async (req, res) => {
@@ -2761,7 +2686,7 @@ export async function apply(ctx) {
2761
2686
  return () => { for (const d of disposers.reverse()) d(); };
2762
2687
  });
2763
2688
 
2764
- ctx.logger?.info?.('dsh-crew hub mounted (jobs API + installer endpoints + multimodal tools)');
2689
+ ctx.logger?.info?.('dsh-crew hub mounted (jobs API + installer endpoints)');
2765
2690
  return async () => {
2766
2691
  for (const d of disposers.reverse()) { try { d(); } catch {} }
2767
2692
  await hub.dispose();
package/src/i18n.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Server-side locale for strings that reach the user: connectivity-test
2
- // reports, model-list labels, error messages and the vision transcription.
2
+ // reports, model-list labels and error messages.
3
3
  //
4
4
  // The panel owns the authoritative locale (DSH's setting may be absent, in
5
5
  // which case the browser decides), so it passes `lang` with every request and
@@ -15,6 +15,20 @@ const MARKETPLACE_NAME = 'dsh-crew';
15
15
  const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
16
16
  const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
17
17
  const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
18
+
19
+ /**
20
+ * The directory Codex actually reads.
21
+ *
22
+ * Codex honours `CODEX_HOME` and falls back to `~/.codex`; writing only to the
23
+ * latter silently does nothing for anyone who set it — the registration stays
24
+ * frozen on whatever release was current when they set it, while every install
25
+ * reports success because the file it wrote really did change. Resolve the same
26
+ * way Codex does, and treat an empty or whitespace value as unset.
27
+ */
28
+ export function codexHomeDir(home = homedir(), env = process.env) {
29
+ const override = typeof env?.CODEX_HOME === 'string' ? env.CODEX_HOME.trim() : '';
30
+ return override ? resolve(override) : join(home, '.codex');
31
+ }
18
32
  export const CODEX_LEGACY_POLICY_HASHES = Object.freeze([
19
33
  '2d6f3839bb3df4bda90f481726281292b1a4b4585298b1cf9ec56215295b5c78',
20
34
  ]);
@@ -80,8 +94,8 @@ export function stripKnownLegacyCodexPolicy(text, { knownHashes = CODEX_LEGACY_P
80
94
  return `${keptPrefix}${match[0]}${keptSuffix}`;
81
95
  }
82
96
 
83
- function installGlobalCodexPolicy({ home, root }) {
84
- const file = join(home, '.codex', 'AGENTS.md');
97
+ function installGlobalCodexPolicy({ home, root, env = process.env }) {
98
+ const file = join(codexHomeDir(home, env), 'AGENTS.md');
85
99
  const block = managedPolicyBlock(root);
86
100
  if (!block) return { ok: false, action: 'global policy template missing' };
87
101
  mkdirSync(dirname(file), { recursive: true });
@@ -100,14 +114,14 @@ function installGlobalCodexPolicy({ home, root }) {
100
114
  return { ok: true, action: `global policy: ${file}` };
101
115
  }
102
116
 
103
- function globalCodexPolicyReady({ home, root = ROOT }) {
117
+ function globalCodexPolicyReady({ home, root = ROOT, env = process.env }) {
104
118
  const block = managedPolicyBlock(root);
105
- const text = readText(join(home, '.codex', 'AGENTS.md'));
119
+ const text = readText(join(codexHomeDir(home, env), 'AGENTS.md'));
106
120
  return !!block && typeof text === 'string' && text.includes(block);
107
121
  }
108
122
 
109
- function uninstallGlobalCodexPolicy({ home }) {
110
- const file = join(home, '.codex', 'AGENTS.md');
123
+ function uninstallGlobalCodexPolicy({ home, env = process.env }) {
124
+ const file = join(codexHomeDir(home, env), 'AGENTS.md');
111
125
  const current = readText(file);
112
126
  if (typeof current !== 'string') return null;
113
127
  const managed = new RegExp(`(?:\\r?\\n){0,2}${POLICY_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${POLICY_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\r?\\n)?`, 'm');
@@ -282,26 +296,6 @@ export const GLOBAL_CONFIG_DEFAULTS = {
282
296
  pro_model_priority: [],
283
297
  pro_model_priority_configured: false,
284
298
  pro_model_fallback: 'harness-default',
285
- // ---- configurable-crew multimodal switches ----
286
- // False = the Crew describe_image tool and vision route are not registered
287
- // at DSH boot (takes effect after a DSH restart). Provider/model values are
288
- // kept untouched so re-enabling restores the previous setup.
289
- vision_enabled: false,
290
- imagegen_enabled: false,
291
- // Multimodal bridge: which subscription CLI lends the text-only DS model
292
- // eyes (describe_image) and a brush (generate_image).
293
- vision_provider: 'off', // claude-code | codex | grok | agy | <custom id> | off
294
- vision_model: 'haiku', // model passed to the vision CLI; free-form
295
- imagegen_provider: 'off', // codex | agy | grok | <custom id> | off
296
- // User-defined providers, each either an OpenAI-compatible HTTP endpoint or a
297
- // local command. Entry shape:
298
- // { id, name, type: 'api' | 'cli', models: [],
299
- // api: base_url, api_key, imagegen_model
300
- // cli: vision_command, imagegen_command }
301
- // CLI commands run via bash with shell-quoted placeholders: vision
302
- // {image} {question} {model} (stdout is the answer), imagegen
303
- // {prompt} {output} {size} (must write the file to {output}).
304
- custom_providers: [],
305
299
  // User-added model ids per provider, merged into the panel's model list.
306
300
  extra_models: {},
307
301
  // Hub-mode agent preset per tier: 'default' follows the DSH roster default.
@@ -356,8 +350,6 @@ export function mergeStoredGlobalConfig(stored) {
356
350
  // workspace-targeted jobs on Windows; an explicit user-set `minimal` value
357
351
  // is still preserved by the normal object merge above.
358
352
  if (!has('preset_flash')) merged.preset_flash = 'default';
359
- if (!has('vision_enabled')) merged.vision_enabled = stored.vision_provider !== 'off';
360
- if (!has('imagegen_enabled')) merged.imagegen_enabled = stored.imagegen_provider !== 'off';
361
353
  return merged;
362
354
  }
363
355
 
@@ -393,7 +385,7 @@ export function writeGlobalConfig(patch) {
393
385
  }
394
386
 
395
387
  /** What is currently installed where — drives the settings-page buttons. */
396
- export function installStatus({ home = homedir(), root = ROOT } = {}) {
388
+ export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
397
389
  const settings = readJson(join(home, '.claude', 'settings.json'), {});
398
390
  const enabled = settings.enabledPlugins;
399
391
  const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
@@ -409,7 +401,7 @@ export function installStatus({ home = homedir(), root = ROOT } = {}) {
409
401
  permissions: claudePermissionsReady(settings),
410
402
  };
411
403
  const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
412
- const codexRoot = join(home, '.codex');
404
+ const codexRoot = codexHomeDir(home, env);
413
405
  const configFile = join(codexRoot, 'config.toml');
414
406
  const configText = readText(configFile) ?? '';
415
407
  const workerFile = join(codexRoot, 'agents', 'ds-worker.toml');
@@ -429,7 +421,7 @@ export function installStatus({ home = homedir(), root = ROOT } = {}) {
429
421
  status_prompt: expectedStatusPrompt !== null && readText(join(codexRoot, 'prompts', 'dsh-status.md')) === expectedStatusPrompt,
430
422
  mcp: !!expectedTarget && mcpTarget === expectedTarget,
431
423
  target_alignment: !!expectedTarget && workerTarget === expectedTarget && reviewerTarget === expectedTarget && mcpTarget === expectedTarget,
432
- global_policy: globalCodexPolicyReady({ home, root }),
424
+ global_policy: globalCodexPolicyReady({ home, root, env }),
433
425
  };
434
426
  const codexInstalled = Object.values(components).some(Boolean)
435
427
  || existsSync(join(codexRoot, 'agents', 'ds-flash.toml'))
@@ -448,24 +440,24 @@ export function installStatus({ home = homedir(), root = ROOT } = {}) {
448
440
  };
449
441
  }
450
442
 
451
- export function uninstallCodex({ home = homedir() } = {}) {
443
+ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
452
444
  const actions = [];
453
445
  // Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
454
446
  // aliases (ds-flash / ds-pro) are dsh-crew managed; uninstall removes only
455
447
  // these, never a user's own role files.
456
448
  for (const f of ['ds-flash.toml', 'ds-pro.toml', 'ds-worker.toml', 'ds-reviewer.toml']) {
457
- const p = join(home, '.codex', 'agents', f);
449
+ const p = join(codexHomeDir(home, env), 'agents', f);
458
450
  if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
459
451
  }
460
452
  for (const f of ['dsh-config.md', 'dsh-status.md']) {
461
- const p = join(home, '.codex', 'prompts', f);
453
+ const p = join(codexHomeDir(home, env), 'prompts', f);
462
454
  if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
463
455
  }
464
- const policyAction = uninstallGlobalCodexPolicy({ home });
456
+ const policyAction = uninstallGlobalCodexPolicy({ home, env });
465
457
  if (policyAction) actions.push(policyAction);
466
458
  // Remove only the dsh-crew entry from [mcp_servers], keeping any other
467
459
  // MCP servers the user configured.
468
- const configFile = join(home, '.codex', 'config.toml');
460
+ const configFile = join(codexHomeDir(home, env), 'config.toml');
469
461
  if (existsSync(configFile)) {
470
462
  const before = readFileSync(configFile, 'utf8');
471
463
  const lineRe = /(^|\n)[ \t]*dsh-crew\s*=\s*\{[^\n]*\}/;
@@ -590,9 +582,9 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
590
582
  return { ok: true, actions };
591
583
  }
592
584
 
593
- export function installCodex({ home = homedir(), scope, root = ROOT } = {}) {
585
+ export function installCodex({ home = homedir(), scope, root = ROOT, env = process.env } = {}) {
594
586
  const actions = [];
595
- const agentsDir = scope === 'project' ? join(process.cwd(), '.codex', 'agents') : join(home, '.codex', 'agents');
587
+ const agentsDir = scope === 'project' ? join(process.cwd(), '.codex', 'agents') : join(codexHomeDir(home, env), 'agents');
596
588
  mkdirSync(agentsDir, { recursive: true });
597
589
  const srcDir = join(root, 'codex', 'agents');
598
590
  // Windows drive paths must render with forward slashes: backslashes in a
@@ -609,7 +601,7 @@ export function installCodex({ home = homedir(), scope, root = ROOT } = {}) {
609
601
  writeFileSync(dest, rendered);
610
602
  actions.push(`role: ${dest}`);
611
603
  }
612
- const promptsDir = scope === 'project' ? join(process.cwd(), '.codex', 'prompts') : join(home, '.codex', 'prompts');
604
+ const promptsDir = scope === 'project' ? join(process.cwd(), '.codex', 'prompts') : join(codexHomeDir(home, env), 'prompts');
613
605
  mkdirSync(promptsDir, { recursive: true });
614
606
  const promptsSrc = join(root, 'codex', 'prompts');
615
607
  for (const f of readdirSync(promptsSrc).filter((f) => f.endsWith('.md'))) {
@@ -617,9 +609,9 @@ export function installCodex({ home = homedir(), scope, root = ROOT } = {}) {
617
609
  actions.push(`prompt: ${join(promptsDir, f)}`);
618
610
  }
619
611
  if (scope !== 'project') {
620
- const act = writeGlobalCodexMcpServer(home, renderedPath);
612
+ const act = writeGlobalCodexMcpServer(home, renderedPath, env);
621
613
  actions.push(...act);
622
- const policy = installGlobalCodexPolicy({ home, root });
614
+ const policy = installGlobalCodexPolicy({ home, root, env });
623
615
  if (!policy.ok) return { ok: false, actions: [...actions, policy.action] };
624
616
  actions.push(policy.action);
625
617
  }
@@ -634,8 +626,8 @@ export function installCodex({ home = homedir(), scope, root = ROOT } = {}) {
634
626
  * the user already configured. Idempotent (updates in place). Never requires
635
627
  * the codex CLI.
636
628
  */
637
- export function writeGlobalCodexMcpServer(home, renderedPath) {
638
- const configFile = join(home, '.codex', 'config.toml');
629
+ export function writeGlobalCodexMcpServer(home, renderedPath, env = process.env) {
630
+ const configFile = join(codexHomeDir(home, env), 'config.toml');
639
631
  const entry = `dsh-crew = { command = "node", args = ["${renderedPath}"] }`;
640
632
  const backupFile = backup(configFile);
641
633
  const existing = existsSync(configFile) ? readFileSync(configFile, 'utf8') : '';
@@ -42,6 +42,7 @@ import { homedir } from 'node:os';
42
42
  import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
+ import { liveReleaseClaims } from '../release-in-use.mjs';
45
46
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
46
47
  import {
47
48
  ensureOfficialWebIntegration,
@@ -1085,9 +1086,15 @@ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1085
1086
  const releasesDir = crewReleasesDir({ home });
1086
1087
  if (!existsSync(releasesDir)) return;
1087
1088
  const removed = [];
1089
+ // A running Hub keeps executing the release it started from and re-reads
1090
+ // several modules from disk on every request, so removing that release breaks
1091
+ // those routes with no recovery but a restart. Protect what live processes
1092
+ // claim, not just what the pointer names.
1093
+ const claimed = liveReleaseClaims({ home }).map((dir) => resolve(dir));
1094
+ const live = new Set([...(protect ? [resolve(protect)] : []), ...claimed]);
1088
1095
  const dirs = readdirSync(releasesDir)
1089
1096
  .map((name) => join(releasesDir, name))
1090
- .filter((dir) => (!pointer || dir !== pointer.path) && (!protect || dir !== protect));
1097
+ .filter((dir) => (!pointer || dir !== pointer.path) && !live.has(resolve(dir)));
1091
1098
  const incomplete = [];
1092
1099
  const complete = [];
1093
1100
  for (const dir of dirs) {
@@ -3434,7 +3441,14 @@ export async function runNpxCli({
3434
3441
  else if (command === 'releases') result = await actions.releases({ args, log });
3435
3442
  else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
3436
3443
  else result = await actions[command]({ log });
3437
- return result?.ok === false ? 1 : 0;
3444
+ // Surface why the command failed. Actions return a structured failure rather
3445
+ // than throwing, so without this the CLI exits 1 having printed nothing —
3446
+ // `dsh-crew rollback` with no version was a silent failure.
3447
+ if (result?.ok === false) {
3448
+ error(`dsh-crew ${command} failed: ${result.error ?? result.code ?? 'unknown error'}`);
3449
+ return 1;
3450
+ }
3451
+ return 0;
3438
3452
  } catch (err) {
3439
3453
  error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
3440
3454
  return 1;
@@ -506,15 +506,6 @@ export function normalizeGlobalConfig(raw = {}) {
506
506
  else { flashState = flashState ?? 'auto'; proState = proState ?? 'auto'; }
507
507
  }
508
508
 
509
- // Capability switches: an old config with provider=off must not flip the
510
- // capability back on; anything else keeps the capability enabled.
511
- const visionEnabled = has('vision_enabled')
512
- ? normalizeEnabled(raw.vision_enabled)
513
- : raw.vision_provider !== 'off';
514
- const imagegenEnabled = has('imagegen_enabled')
515
- ? normalizeEnabled(raw.imagegen_enabled)
516
- : raw.imagegen_provider !== 'off';
517
-
518
509
  const normalized = {
519
510
  ...raw,
520
511
  subagents_enabled: normalizeEnabled(raw.subagents_enabled),
@@ -534,8 +525,6 @@ export function normalizeGlobalConfig(raw = {}) {
534
525
  pro_model_priority: normalizeModelPriority(raw.pro_model_priority),
535
526
  pro_model_priority_configured: raw.pro_model_priority_configured === true || normalizeModelPriority(raw.pro_model_priority).length > 0,
536
527
  pro_model_fallback: raw.pro_model_fallback === 'harness-default' ? raw.pro_model_fallback : 'harness-default',
537
- vision_enabled: visionEnabled,
538
- imagegen_enabled: imagegenEnabled,
539
528
  };
540
529
  // This normalized legacy view is the single input to the v0.2 canonical
541
530
  // migration, so every consumer sees the same worker/reviewer model policy
@@ -788,43 +777,4 @@ export function validateRoles(raw) {
788
777
  if (!seen.has(r)) { seen.add(r); out.push(r); }
789
778
  }
790
779
  return { roles: out, dropped };
791
- }
792
-
793
- // ---------- multimodal capability ----------
794
-
795
- /** Capability availability after both the switch and the provider are checked. */
796
- export function getCapabilities(config) {
797
- const c = config;
798
- const vision = {
799
- enabled: normalizeBool(c.vision_enabled, true),
800
- provider: c.vision_provider ?? 'off',
801
- providerOff: c.vision_provider === 'off',
802
- usable: normalizeBool(c.vision_enabled, true) && c.vision_provider !== 'off',
803
- };
804
- const imagegen = {
805
- enabled: normalizeBool(c.imagegen_enabled, true),
806
- provider: c.imagegen_provider ?? 'off',
807
- providerOff: c.imagegen_provider === 'off',
808
- usable: normalizeBool(c.imagegen_enabled, true) && c.imagegen_provider !== 'off',
809
- };
810
- return { vision, imagegen };
811
- }
812
-
813
- /**
814
- * Registration plan for the hub's multimodal bridge: which tools to register
815
- * and whether the vision route (deepseek-vision adapter + transcription
816
- * waterfall) should be installed. Tool names are the decision points; the
817
- * hub applies the plan at plugin boot, so capability switches take effect
818
- * after a DSH restart.
819
- */
820
- export function getMultimodalRegistrationPlan(config) {
821
- const { vision, imagegen } = getCapabilities(config);
822
- return {
823
- tools: {
824
- describe_image: vision.usable,
825
- generate_image: imagegen.usable,
826
- },
827
- visionRoute: vision.usable,
828
- requiresRestart: true,
829
- };
830
- }
780
+ }
@@ -92,7 +92,7 @@ function modelIds(provider) {
92
92
  * Build a secret-free provider inventory from live Harness and Crew-owned
93
93
  * observations. No credential value is accepted or copied into the result.
94
94
  */
95
- export function buildProviderInventory({ catalog = {}, declarations = [], policy = {}, tombstones = {}, activeJobs = [], multimodalRefs = {}, additionalProviderIds = [] } = {}) {
95
+ export function buildProviderInventory({ catalog = {}, declarations = [], policy = {}, tombstones = {}, activeJobs = [], additionalProviderIds = [] } = {}) {
96
96
  const catalogProviders = Array.isArray(catalog?.providers) ? catalog.providers : [];
97
97
  const safeDeclarations = Array.isArray(declarations) ? declarations : [];
98
98
  const ids = [];
@@ -121,7 +121,6 @@ export function buildProviderInventory({ catalog = {}, declarations = [], policy
121
121
  ? activeJobs.filter((job) => normalizeProviderId(job?.provider) === id
122
122
  && !['done', 'failed', 'cancelled'].includes(job?.status)).length
123
123
  : Number.isInteger(activeJobs?.[id]) ? Math.max(0, activeJobs[id]) : 0;
124
- const multimodal = Array.isArray(multimodalRefs?.[id]) ? multimodalRefs[id].length : Number.isInteger(multimodalRefs?.[id]) ? Math.max(0, multimodalRefs[id]) : 0;
125
124
  const declarationFile = text(declaration?.file);
126
125
  const locator = text(declaration?.locator);
127
126
 
@@ -173,7 +172,6 @@ export function buildProviderInventory({ catalog = {}, declarations = [], policy
173
172
  reviewer_priority: findPriorityIndex(policy?.reviewer ?? policy?.review, 'priority', id),
174
173
  reviewer_escalation: findPriorityIndex(policy?.reviewer ?? policy?.review, 'escalation_priority', id),
175
174
  active_jobs: active,
176
- multimodal_refs: multimodal,
177
175
  },
178
176
  desired_state: tombstoned ? 'absent' : 'present',
179
177
  activation: declared ? 'restart-required' : 'live',