@ran-sh/dsh-crew 2.2.3 → 2.2.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -256,6 +256,7 @@ export function buildConfigReadinessMatrix({
256
256
  platform,
257
257
  hubCompatibility,
258
258
  workerProviderMode,
259
+ workerSelection: currentSelections?.worker ?? null,
259
260
  providerCatalogChecked,
260
261
  providerCatalogOk: catalogOk,
261
262
  evidence,
package/src/hub/index.mjs CHANGED
@@ -43,6 +43,7 @@ import { inspectProviderProfile, readProviderDeclarations, readProviderMateriali
43
43
  import { hasHarnessDefaultBlock, inspectProviderSettings, readHarnessDefault, readProviderSettingsDeclarations, readProviderSettingsMaterialization } from '../provider-settings-store.mjs';
44
44
  import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
45
45
  import { createProviderHealthStore } from '../provider-health.mjs';
46
+ import { pruneCrewTempLogs } from '../log-prune.mjs';
46
47
  import { planProviderDelete } from '../provider-lifecycle.mjs';
47
48
  import { createProviderDeleteFileHooks, isRecoverableProviderDeleteBackup, readProviderDeleteManifestFile } from '../provider-delete-adapters.mjs';
48
49
  import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
@@ -2809,6 +2810,13 @@ export async function apply(ctx) {
2809
2810
  return () => { for (const d of disposers.reverse()) d(); };
2810
2811
  });
2811
2812
 
2813
+ // Each hub start writes another diagnostics pair into %TEMP%; bound them here,
2814
+ // on the machine that owns them, keeping the newest runs (see log-prune.mjs).
2815
+ try {
2816
+ const pruned = pruneCrewTempLogs();
2817
+ if (pruned.removed.length > 0) ctx.logger?.info?.(`dsh-crew diagnostics pruned: ${pruned.removed.length} file(s)`);
2818
+ } catch { /* pruning is best-effort and must never block a boot */ }
2819
+
2812
2820
  ctx.logger?.info?.('dsh-crew hub mounted (jobs API + installer endpoints)');
2813
2821
  return async () => {
2814
2822
  for (const d of disposers.reverse()) { try { d(); } catch {} }
@@ -383,6 +383,11 @@ export const GLOBAL_CONFIG_DEFAULTS = {
383
383
  // Automatic Pro review after a successful Flash run (review-pipeline forces
384
384
  // this on; balanced/custom only when this flag is set).
385
385
  pro_reviews_flash: false,
386
+ // Opt-in: the login/background launcher also starts the Crew-managed 3080
387
+ // frontend — without opening a browser, logging the session URL instead. Off by
388
+ // default: the interactive desktop entry is what opens a session, and an extra
389
+ // always-on harness process is the operator's choice to make.
390
+ frontend_autostart: false,
386
391
  // Worker provider routing for HUB workers: which DSH provider backs each
387
392
  // worker session. Fresh installs stay on the built-in DeepSeek provider;
388
393
  // follow-dsh is an explicit opt-in that uses the provider selected in DSH
@@ -51,6 +51,7 @@ import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted
51
51
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, ensureCrewWebProfile, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, gcRetainedRuntimes, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
52
52
  import { desktopAttach, desktopDetach, desktopStatus } from './desktop-profile.mjs';
53
53
  import { mirrorOfficialHarnessConfig, officialConfigMirrorStatus } from './harness-config-import.mjs';
54
+ import { pruneCrewTempLogs } from '../log-prune.mjs';
54
55
  import {
55
56
  ensureOfficialWebIntegration,
56
57
  officialWebIntegrationStatus,
@@ -2772,6 +2773,21 @@ export async function npxDesktop({ home = homedir(), args = [], dryRun = false,
2772
2773
  return result;
2773
2774
  }
2774
2775
 
2776
+ // Bound the diagnostics that accumulate in the temp directory. The hub does this
2777
+ // on every boot too; the command exists so an operator can reclaim space without
2778
+ // waiting for a restart or guessing which files are safe to delete.
2779
+ export async function npxLogs({ args = [], log = console.log } = {}) {
2780
+ const action = args[0] ?? 'prune';
2781
+ if (action !== 'prune') {
2782
+ log(`✗ unknown logs action: ${action}`);
2783
+ return { ok: false, error: 'usage: dsh-crew logs prune [keep]' };
2784
+ }
2785
+ const keep = Number(args[1]);
2786
+ const result = pruneCrewTempLogs(Number.isInteger(keep) && keep > 0 ? { keepRuns: keep } : {});
2787
+ log(`✓ Crew diagnostics pruned: removed ${result.removed.length} file(s); kept ${result.kept.length} run(s)`);
2788
+ return { ok: true, ...result };
2789
+ }
2790
+
2775
2791
  // Bring the operator's user-level DSH configuration across from the official home
2776
2792
  // into Crew's home: the Crew hub then runs the same models, providers, policy and
2777
2793
  // MCP servers as the desktop app. Read-only on ~/.dsh, no secrets copied (provider
@@ -3426,6 +3442,7 @@ Commands:
3426
3442
  home, so the Crew hub matches the desktop app. Read-only on ~/.dsh; no
3427
3443
  secrets are copied (provider entries keep their apiKeyEnv references);
3428
3444
  entries naming packages absent from Crew's runtime cohort are skipped
3445
+ logs prune the Crew diagnostics in the temp directory: prune [keep]
3429
3446
  status read-only report of launcher/installed versions and integrations
3430
3447
  inspect print the machine-readable extension capability/readiness contract
3431
3448
  jobs machine-first job API: list|get|watch|cancel|submit
@@ -3830,7 +3847,7 @@ export async function runNpxCli({
3830
3847
  error(USAGE);
3831
3848
  return 1;
3832
3849
  }
3833
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'desktop', 'config', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3850
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'desktop', 'config', 'logs', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3834
3851
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
3835
3852
  return 1;
3836
3853
  }
@@ -3841,6 +3858,7 @@ export async function runNpxCli({
3841
3858
  detach: commands.detach ?? npxDetach,
3842
3859
  desktop: commands.desktop ?? npxDesktop,
3843
3860
  config: commands.config ?? npxConfig,
3861
+ logs: commands.logs ?? npxLogs,
3844
3862
  status: commands.status ?? npxStatus,
3845
3863
  inspect: commands.inspect ?? npxInspect,
3846
3864
  jobs: commands.jobs ?? npxJobs,
@@ -3856,6 +3874,7 @@ export async function runNpxCli({
3856
3874
  else if (command === 'update') result = await actions.update({ candidate, log });
3857
3875
  else if (command === 'desktop') result = await actions.desktop({ args, dryRun, log });
3858
3876
  else if (command === 'config') result = await actions.config({ args, dryRun, log });
3877
+ else if (command === 'logs') result = await actions.logs({ args, log });
3859
3878
  else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
3860
3879
  else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
3861
3880
  else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
@@ -107,14 +107,15 @@ export function windowsStartupStatus({
107
107
  const sourceControl = readFileSync(join(root, 'windows', WINDOWS_CONTROL_FILENAME), 'utf8');
108
108
  const sourceVbs = readFileSync(join(root, 'windows', 'start-dsh-crew.vbs'), 'utf8');
109
109
  const startup = readFileSync(resolved.startupFile, 'utf16le').replace(/^\uFEFF/, '');
110
- components.startup_entry_content = startup === renderVbs(sourceVbs, resolved.launcherFile);
111
- components.launcher_content = readFileSync(resolved.launcherFile, 'utf8') === sourceLauncher;
112
- components.helper_content = readFileSync(resolved.helperFile, 'utf8') === sourceHelper;
113
- components.control_content = readFileSync(resolved.controlFile, 'utf8') === sourceControl;
110
+ components.startup_entry_content = normalizeEol(startup) === normalizeEol(renderVbs(sourceVbs, resolved.launcherFile));
111
+ components.launcher_content = normalizeEol(readFileSync(resolved.launcherFile, 'utf8')) === normalizeEol(sourceLauncher);
112
+ components.helper_content = normalizeEol(readFileSync(resolved.helperFile, 'utf8')) === normalizeEol(sourceHelper);
113
+ components.control_content = normalizeEol(readFileSync(resolved.controlFile, 'utf8')) === normalizeEol(sourceControl);
114
+ // The manifest is written from the INSTALLED bytes, so it is checked against
115
+ // them (inside readWindowsSupervisorAssets); what ties the installation to the
116
+ // shipped payload is the *_content* comparison above, which is EOL-tolerant.
114
117
  const assets = readWindowsSupervisorAssets({ home });
115
- components.supervisor_manifest = assets.ok
116
- && assets.helper_hash === sha256File(join(root, 'windows', WINDOWS_HELPER_FILENAME))
117
- && assets.control_hash === sha256File(join(root, 'windows', WINDOWS_CONTROL_FILENAME));
118
+ components.supervisor_manifest = assets.ok;
118
119
  } catch { /* missing or unreadable content remains false */ }
119
120
  }
120
121
  const missing = Object.entries(components).filter(([, ready]) => !ready).map(([key]) => key);
@@ -125,6 +126,14 @@ function sha256File(file) {
125
126
  try { return createHash('sha256').update(readFileSync(file)).digest('hex'); } catch { return null; }
126
127
  }
127
128
 
129
+ // Git checks these scripts out as CRLF on a `core.autocrlf=true` machine and
130
+ // PowerShell accepts either, so a line-ending difference is not a difference this
131
+ // check may report: comparing raw bytes made every Windows dev checkout read as
132
+ // "needs repair" while the npm payload (LF) was installed correctly.
133
+ function normalizeEol(text) {
134
+ return String(text).replace(/\r\n/g, '\n');
135
+ }
136
+
128
137
  function samePath(left, right) {
129
138
  try { return resolve(left).toLowerCase() === resolve(right).toLowerCase(); } catch { return false; }
130
139
  }
@@ -0,0 +1,70 @@
1
+ // Bound the Crew diagnostics that accumulate in %TEMP%: every hub start writes a
2
+ // new dsh-crew-dsh-crew-3210-<stamp>.out/.err.log pair, every desktop launch a
3
+ // dsh-crew-web-*, every official launch a dsh-official-web-*, and the launcher
4
+ // appends to dsh-crew-launcher.log forever. They are the only post-mortem evidence
5
+ // for a crash-before-readiness, so the rule keeps the most recent runs instead of
6
+ // deleting everything: per family, the newest `keep` runs survive, plus anything
7
+ // written within the recency guard (a running process owns its own log files).
8
+
9
+ import { readdirSync, rmSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+
13
+ export const DEFAULT_KEEP_RUNS = 20;
14
+ export const DEFAULT_MAX_AGE_DAYS = 14;
15
+ const RECENT_GUARD_MS = 5 * 60 * 1000;
16
+
17
+ const LOG_FAMILIES = [
18
+ { prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log' },
19
+ { prefix: 'dsh-crew-web-', suffix: '.out.log' },
20
+ { prefix: 'dsh-official-web-', suffix: '.out.log' },
21
+ ];
22
+
23
+ // `dsh-crew-<profile>-<port>-<stamp>.out.log` -> the stamp identifies one run, and
24
+ // its .err.log sibling must live or die with it.
25
+ function runStamp(fileName, family) {
26
+ if (!fileName.startsWith(family.prefix) || !fileName.endsWith(family.suffix)) return null;
27
+ const stamp = fileName.slice(family.prefix.length, fileName.length - family.suffix.length);
28
+ return stamp || null;
29
+ }
30
+
31
+ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = DEFAULT_KEEP_RUNS, maxAgeDays = DEFAULT_MAX_AGE_DAYS, now = Date.now() } = {}) {
32
+ const keep = Number.isInteger(keepRuns) && keepRuns > 0 ? keepRuns : DEFAULT_KEEP_RUNS;
33
+ const maxAgeMs = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : DEFAULT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
34
+ let names;
35
+ try { names = readdirSync(tempDir); } catch { return { ok: true, removed: [], kept: [], skipped: 'unreadable' }; }
36
+
37
+ const removed = [];
38
+ const kept = [];
39
+ for (const family of LOG_FAMILIES) {
40
+ const runs = new Map();
41
+ for (const name of names) {
42
+ const stamp = runStamp(name, family);
43
+ if (!stamp) continue;
44
+ const file = join(tempDir, name);
45
+ let mtimeMs = 0;
46
+ try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
47
+ const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
48
+ run.files.push(name);
49
+ run.newest = Math.max(run.newest, mtimeMs);
50
+ runs.set(stamp, run);
51
+ }
52
+ const ordered = [...runs.values()].sort((left, right) => right.newest - left.newest);
53
+ ordered.forEach((run, index) => {
54
+ const tooOld = now - run.newest > maxAgeMs;
55
+ const tooMany = index >= keep;
56
+ const tooRecentToTouch = now - run.newest < RECENT_GUARD_MS;
57
+ if ((tooOld || tooMany) && !tooRecentToTouch) {
58
+ for (const name of run.files) removed.push(name);
59
+ const errName = run.files[0].replace(/\.out\.log$/, '.err.log');
60
+ if (names.includes(errName)) removed.push(errName);
61
+ } else {
62
+ kept.push(run.stamp);
63
+ }
64
+ });
65
+ }
66
+ for (const name of removed) {
67
+ try { rmSync(join(tempDir, name), { force: true }); } catch { /* a locked file stays until the next pass */ }
68
+ }
69
+ return { ok: true, removed: [...new Set(removed)].sort(), kept: kept.sort() };
70
+ }
@@ -73,7 +73,10 @@ function boundedTimestamp(value, fallback) {
73
73
  function view(entry, now) {
74
74
  if (!entry) return { state: 'unprobed', reason_code: null, observed_at: null, expires_at: null, fresh: false };
75
75
  const fresh = now < entry.expires_at;
76
- if (!fresh) return { state: 'unprobed', reason_code: null, observed_at: entry.observed_at, expires_at: entry.expires_at, fresh: false };
76
+ // An aged-out record keeps its identity (provider/model) and timestamps: the
77
+ // readiness projection matches it against the selected route to report STALE
78
+ // instead of "never probed". Only the verdict is reset to `unprobed`.
79
+ if (!fresh) return { ...entry, state: 'unprobed', reason_code: null, fresh: false };
77
80
  return { ...entry, fresh: true };
78
81
  }
79
82
 
@@ -124,7 +127,12 @@ export function createProviderHealthStore({ clock = () => Date.now(), ttls = {},
124
127
  return get(provider, model);
125
128
  };
126
129
 
127
- const list = () => [...records.values()].map((entry) => view(entry, clock())).filter((entry) => entry.fresh).slice(0, boundedMax);
130
+ // Expired records are included, as `fresh: false` with their timestamps: "this
131
+ // route was probed and the evidence has aged out" is knowledge the readiness
132
+ // projection needs (it renders STALE), and dropping it made an aged-out probe
133
+ // read as never-probed. Consumers that need live evidence filter on `fresh`
134
+ // themselves (currentRouteHealthEvidence does exactly that).
135
+ const list = () => [...records.values()].map((entry) => view(entry, clock())).slice(0, boundedMax);
128
136
 
129
137
  return { get, record, list, ttls: { ...limits }, maxEntries: boundedMax };
130
138
  }
@@ -22,6 +22,7 @@ export const READINESS_REASON_CODES = Object.freeze({
22
22
  NO_CI_EVIDENCE: 'NO_CI_EVIDENCE',
23
23
  NO_EXECUTION_EVIDENCE: 'NO_EXECUTION_EVIDENCE',
24
24
  CREDENTIAL_STATUS_NOT_PROBED: 'CREDENTIAL_STATUS_NOT_PROBED',
25
+ ROUTE_NOT_SELECTED: 'ROUTE_NOT_SELECTED',
25
26
  EVIDENCE_REPORTED: 'EVIDENCE_REPORTED',
26
27
  });
27
28
 
@@ -101,6 +102,7 @@ export function buildReadinessMatrix({
101
102
  platform = process.platform,
102
103
  hubCompatibility = null,
103
104
  workerProviderMode = null,
105
+ workerSelection = null,
104
106
  providerCatalogChecked = false,
105
107
  providerCatalogOk = false,
106
108
  evidence = {},
@@ -161,6 +163,21 @@ export function buildReadinessMatrix({
161
163
  );
162
164
  }
163
165
 
166
+ // The two built-in DeepSeek rows describe a route only when the operator runs
167
+ // it. Under follow-dsh with a KNOWN, other selected provider they can never
168
+ // gather evidence, and NOT_RUN reads like a check that should have run rather
169
+ // than a route that was never chosen. An unknown selection stays NOT_RUN: this
170
+ // matrix is conservative, and "we could not tell" must not read as "not
171
+ // applicable". Reported evidence still wins, because it is applied below.
172
+ const selectionKnown = typeof workerSelection?.provider === 'string' && workerSelection.provider.length > 0;
173
+ const deepseekRouteSelected = workerProviderMode === 'deepseek-official'
174
+ || workerSelection?.provider === 'deepseek-official';
175
+ if (workerProviderMode !== null && selectionKnown && !deepseekRouteSelected) {
176
+ for (const id of ['deepseek_flash', 'deepseek_pro']) {
177
+ rows[id] = baseRow(id, 'real-execution', 'SKIP', READINESS_REASON_CODES.ROUTE_NOT_SELECTED, 'runtime-policy');
178
+ }
179
+ }
180
+
164
181
  for (const [id] of TARGET_ROWS) {
165
182
  rows[id] = normalizeEvidence(rows[id], evidence?.[id]);
166
183
  }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.2.3';
39
+ export const RUNTIME_VERSION = '2.2.4';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -95,13 +95,13 @@ function projectModelRole({ role, selected, health, jobs, runtime, now, executio
95
95
  })[0];
96
96
  const expiresAt = Number.isFinite(matchingHealth?.expires_at) ? matchingHealth.expires_at : null;
97
97
  const observedAt = Number.isFinite(matchingHealth?.observed_at) ? matchingHealth.observed_at : null;
98
- if (matchingHealth) {
99
- if (observedAt === null || observedAt > now || expiresAt === null || expiresAt <= now
100
- || expiresAt - observedAt > MAX_EXECUTION_EVIDENCE_TTL_MS) {
101
- return { state: 'STALE', reason_code: 'PROVIDER_HEALTH_STALE_OR_INVALID', selected, observed_at: observedAt, expires_at: expiresAt, source: 'provider_health', last_success: null };
102
- }
103
- }
104
- const healthCurrent = matchingHealth && matchingHealth.fresh === true;
98
+ // An aged-out probe is remembered, not forgotten: it is reported as STALE below,
99
+ // but only after the job evidence is consulted — a real successful execution is
100
+ // stronger than "a probe aged out", and letting the stale record decide first
101
+ // would downgrade a route that just ran.
102
+ const healthExpired = Boolean(matchingHealth) && (observedAt === null || observedAt > now || expiresAt === null
103
+ || expiresAt <= now || expiresAt - observedAt > MAX_EXECUTION_EVIDENCE_TTL_MS);
104
+ const healthCurrent = matchingHealth && matchingHealth.fresh === true && !healthExpired;
105
105
  if (healthCurrent && matchingHealth.state !== 'callable') {
106
106
  return { state: 'NOT_CALLABLE', reason_code: matchingHealth.reason_code ?? 'PROVIDER_ROUTE_UNCALLABLE', selected, observed_at: matchingHealth.observed_at ?? null, expires_at: expiresAt, source: 'provider_health', last_success: null };
107
107
  }
@@ -122,6 +122,9 @@ function projectModelRole({ role, selected, health, jobs, runtime, now, executio
122
122
  if (expires > now) return { state: 'CALLABLE', reason_code: 'RECENT_EXECUTION_PASSED', selected, observed_at: completed.endedAt, expires_at: expires, source: 'execution', last_success: lastSuccess };
123
123
  return { state: 'STALE', reason_code: 'EXECUTION_EVIDENCE_EXPIRED', selected, observed_at: completed.endedAt, expires_at: expires, source: 'execution', last_success: lastSuccess };
124
124
  }
125
+ if (healthExpired) {
126
+ return { state: 'STALE', reason_code: 'PROVIDER_HEALTH_STALE_OR_INVALID', selected, observed_at: observedAt, expires_at: expiresAt, source: 'provider_health', last_success: null };
127
+ }
125
128
  return { state: 'UNKNOWN', reason_code: 'NO_CURRENT_MODEL_EVIDENCE', selected, last_success: null };
126
129
  }
127
130
 
@@ -372,6 +372,33 @@ function Start-CrewManagedFrontendQuietly {
372
372
  }
373
373
  }
374
374
 
375
+ function Test-CrewFrontendAutostart {
376
+ # Opt-in only. The login/background launch has never started 3080 — the desktop
377
+ # entry does that, with a browser — which left operators who expect the browser
378
+ # surface after a reboot with nothing on 3080. Any unreadable config reads as off.
379
+ try {
380
+ $configFile = Join-Path $env:USERPROFILE '.config\dsh-crew\config.json'
381
+ if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return $false }
382
+ $config = Get-Content -LiteralPath $configFile -Raw | ConvertFrom-Json
383
+ return $config.frontend_autostart -eq $true
384
+ } catch { return $false }
385
+ }
386
+
387
+ function Start-CrewFrontendFromLogin {
388
+ # Never fatal: this runs before the supervisor starts, so a failure must not stop
389
+ # the backend. The session URL goes to the log instead of a browser window.
390
+ try {
391
+ if (Open-CrewManagedFrontend -Quiet) {
392
+ $url = Get-CrewWebSessionUrl
393
+ Write-LaunchLog ('Crew-managed frontend on 3080 is serving{0}' -f $(if ($url) { '; session: ' + $url } else { '' }))
394
+ } else {
395
+ Write-LaunchLog 'Crew-managed frontend on 3080 was not started (no Crew-managed CLI entry).' 'WARN'
396
+ }
397
+ } catch {
398
+ Write-LaunchLog ('Crew-managed frontend on 3080 could not be started: {0}' -f $_.Exception.Message) 'WARN'
399
+ }
400
+ }
401
+
375
402
  function Get-OfficialFrontendOverlay {
376
403
  $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
377
404
  $path = Join-Path $frontendRoot 'official-web.patch.json'
@@ -1510,7 +1537,9 @@ try {
1510
1537
  throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
1511
1538
  }
1512
1539
  if ($Mode -eq 'open') { Open-OfficialFrontend }
1513
- # Background/watch modes remain independent of the official frontend.
1540
+ # Background/watch modes remain independent of the official frontend, unless the
1541
+ # operator opted in: then the login launch also serves 3080, without a browser.
1542
+ if ($Mode -ne 'open' -and (Test-CrewFrontendAutostart)) { Start-CrewFrontendFromLogin }
1514
1543
 
1515
1544
  if ($Mode -eq 'watch') {
1516
1545
  Start-ServiceSupervisor