@ran-sh/dsh-crew 2.2.2 → 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.2",
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.2",
3
+ "version": "2.2.4",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -359,7 +359,10 @@ export async function setupUninstall({
359
359
  export async function setupStatus({ log = console.log, root = ROOT, home = homedir(), installer = realInstaller } = {}) {
360
360
  const st = installer.installStatus ? installer.installStatus({ home, root }) : realInstaller.installStatus({ home, root });
361
361
  const integrationLabel = (entry) => !entry?.installed ? 'not installed' : entry.ready === true ? 'installed' : 'needs repair';
362
- const claude = integrationLabel(st?.claude);
362
+ // A host without the Claude CLI has no install to repair: the settings are
363
+ // registered best-effort and the CLI step that creates the plugin snapshot is
364
+ // skipped, so the footprint can never be ready there.
365
+ const claude = st?.claude?.host_detected === false ? 'not installed' : integrationLabel(st?.claude);
363
366
  const codex = integrationLabel(st?.codex);
364
367
  const zcode = integrationLabel(st?.zcode);
365
368
  const startupState = installer.windowsStartupStatus
@@ -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
@@ -497,7 +502,7 @@ export function writeGlobalConfig(patch) {
497
502
  }
498
503
 
499
504
  /** What is currently installed where — drives the settings-page buttons. */
500
- export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
505
+ export function installStatus({ home = homedir(), root = ROOT, env = process.env, claudeDetected = undefined } = {}) {
501
506
  // Host integrations are installed from the profile's loader link, not from the
502
507
  // release directory, so an upgrade re-points one link instead of rewriting four
503
508
  // host configurations. Readiness has to judge them against the path they were
@@ -549,9 +554,16 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
549
554
  || existsSync(join(codexRoot, 'agents', 'ds-flash.toml'))
550
555
  || existsSync(join(codexRoot, 'agents', 'ds-pro.toml'));
551
556
  const missing = Object.entries(components).filter(([, present]) => !present).map(([key]) => key);
557
+ // `installed` stays the footprint: the settings name our plugin, which is a
558
+ // deliberate distinction from `ready` (an enabled setting is not a callable
559
+ // plugin). `host_detected` records the other half a renderer needs — a machine
560
+ // with no Claude Code has nothing installed and nothing to repair, even though
561
+ // the best-effort settings are there.
562
+ const claudeHostDetected = claudeDetected === undefined ? resolveClaudeCommand() !== null : claudeDetected === true;
552
563
  return {
553
564
  claude: {
554
565
  installed: claudeFootprint,
566
+ host_detected: claudeHostDetected,
555
567
  ready: claudeMissing.length === 0,
556
568
  hud: hudWired,
557
569
  components: claudeComponents,
@@ -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
@@ -3284,7 +3300,13 @@ export function npxStatus({
3284
3300
  const integrationLabel = (entry) => !entry?.installed ? 'not installed' : entry.ready === true ? 'installed' : 'needs repair';
3285
3301
  const codex = integrationLabel(st?.codex);
3286
3302
  const zcode = integrationLabel(st?.zcode);
3287
- const claude = integrationLabel(st?.claude);
3303
+ // Claude Code installs best-effort: on a host without the CLI the settings are
3304
+ // registered and the CLI step — the only one that can create the plugin snapshot
3305
+ // — is skipped, so such a machine carries a footprint that can never be ready.
3306
+ // Nothing there is repairable, and "needs repair" told operators to fix an
3307
+ // integration they never installed. A host whose CLI IS present with an
3308
+ // unfinished install still reads as needing repair.
3309
+ const claude = st?.claude?.host_detected === false ? 'not installed' : integrationLabel(st?.claude);
3288
3310
  const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
3289
3311
  const officialWeb = !official.legacy_present ? 'not present (native 3210 control plane)' : official.healthy ? 'legacy full bridge present (deprecated; manual cleanup available)' : 'legacy full bridge record present but unhealthy (deprecated)';
3290
3312
  const startupState = installer.windowsStartupStatus?.({ home, root: runningPackageRoot() });
@@ -3420,6 +3442,7 @@ Commands:
3420
3442
  home, so the Crew hub matches the desktop app. Read-only on ~/.dsh; no
3421
3443
  secrets are copied (provider entries keep their apiKeyEnv references);
3422
3444
  entries naming packages absent from Crew's runtime cohort are skipped
3445
+ logs prune the Crew diagnostics in the temp directory: prune [keep]
3423
3446
  status read-only report of launcher/installed versions and integrations
3424
3447
  inspect print the machine-readable extension capability/readiness contract
3425
3448
  jobs machine-first job API: list|get|watch|cancel|submit
@@ -3824,7 +3847,7 @@ export async function runNpxCli({
3824
3847
  error(USAGE);
3825
3848
  return 1;
3826
3849
  }
3827
- 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)) {
3828
3851
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
3829
3852
  return 1;
3830
3853
  }
@@ -3835,6 +3858,7 @@ export async function runNpxCli({
3835
3858
  detach: commands.detach ?? npxDetach,
3836
3859
  desktop: commands.desktop ?? npxDesktop,
3837
3860
  config: commands.config ?? npxConfig,
3861
+ logs: commands.logs ?? npxLogs,
3838
3862
  status: commands.status ?? npxStatus,
3839
3863
  inspect: commands.inspect ?? npxInspect,
3840
3864
  jobs: commands.jobs ?? npxJobs,
@@ -3850,6 +3874,7 @@ export async function runNpxCli({
3850
3874
  else if (command === 'update') result = await actions.update({ candidate, log });
3851
3875
  else if (command === 'desktop') result = await actions.desktop({ args, dryRun, log });
3852
3876
  else if (command === 'config') result = await actions.config({ args, dryRun, log });
3877
+ else if (command === 'logs') result = await actions.logs({ args, log });
3853
3878
  else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
3854
3879
  else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
3855
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.2';
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