@ran-sh/dsh-crew 2.2.0 → 2.2.2

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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/official-web-bridge/lib/client.js +1 -1
  3. package/official-web-bridge/overlay-entry.mjs +5 -1
  4. package/official-web-bridge/package.json +1 -1
  5. package/package.json +1 -1
  6. package/scripts/setup.mjs +39 -33
  7. package/scripts/verify-npm-install.mjs +2 -2
  8. package/src/adaptive-routing.mjs +2 -2
  9. package/src/ci-evidence.mjs +1 -1
  10. package/src/client/quick-panel.tsx +6 -2
  11. package/src/credential-lifecycle.mjs +0 -3
  12. package/src/credential-purge-state.mjs +1 -5
  13. package/src/credential-reference.mjs +0 -8
  14. package/src/crew-workspaces.mjs +1 -1
  15. package/src/delivery.mjs +2 -2
  16. package/src/dsh-cli-runtime.mjs +227 -30
  17. package/src/extension-contract.mjs +1 -1
  18. package/src/failure-classification.mjs +0 -10
  19. package/src/hub/entry.mjs +1 -1
  20. package/src/hub/index.mjs +57 -7
  21. package/src/i18n.mjs +0 -2
  22. package/src/install/crew-paths.mjs +16 -11
  23. package/src/install/crew-skill.mjs +1 -1
  24. package/src/install/desktop-profile.mjs +168 -0
  25. package/src/install/harness-config-import.mjs +204 -0
  26. package/src/install/install-legacy.mjs +3 -3
  27. package/src/install/npx-lifecycle.mjs +210 -125
  28. package/src/install/official-web.mjs +1 -1
  29. package/src/install/windows-startup.mjs +5 -5
  30. package/src/install/windows-supervisor-adapter.mjs +3 -2
  31. package/src/install/windows-supervisor-lifecycle.mjs +2 -2
  32. package/src/install/zcode.mjs +0 -2
  33. package/src/job-contracts.mjs +1 -1
  34. package/src/job-identity.mjs +2 -2
  35. package/src/jobs.mjs +3 -4
  36. package/src/model-routing.mjs +3 -4
  37. package/src/model-schedule.mjs +0 -1
  38. package/src/official-web-bridge.mjs +3 -1
  39. package/src/policy-legacy.mjs +10 -12
  40. package/src/provider-inventory.mjs +1 -1
  41. package/src/provider-layer-migration.mjs +0 -2
  42. package/src/provider-settings-store.mjs +19 -1
  43. package/src/readiness-matrix.mjs +1 -1
  44. package/src/release-in-use.mjs +1 -1
  45. package/src/role-profiles.mjs +2 -2
  46. package/src/runtime-controls.mjs +1 -1
  47. package/src/runtime-identity.mjs +1 -1
  48. package/src/status-shard.mjs +1 -1
  49. package/src/supervisor/restart-request.mjs +4 -4
  50. package/src/workflow-runtime.mjs +1 -2
  51. package/src/workspace-context.mjs +2 -2
  52. package/src/workspace-isolation.mjs +4 -4
  53. package/src/workspace-lock.mjs +2 -3
  54. package/scripts/policy-probe.mjs +0 -101
  55. package/scripts/verify-crew-ui-polish.mjs +0 -145
  56. package/scripts/verify-history-ui.mjs +0 -98
  57. package/scripts/verify-installer-fix.mjs +0 -26
@@ -42,11 +42,15 @@ 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, claudeIntegrationLine } from './install.mjs';
45
+ import { integrationRoot } from './crew-paths.mjs';
45
46
  import { releaseClaimsState } from '../release-in-use.mjs';
46
47
  import { compareProcessToken, processStartToken } from '../process-identity.mjs';
48
+ import { RELEASE_COHORT_FILENAME } from '../dsh-cohort.mjs';
47
49
  import { renameTree } from './tree-move.mjs';
48
50
  import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
49
- import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
51
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, ensureCrewWebProfile, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, gcRetainedRuntimes, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
52
+ import { desktopAttach, desktopDetach, desktopStatus } from './desktop-profile.mjs';
53
+ import { mirrorOfficialHarnessConfig, officialConfigMirrorStatus } from './harness-config-import.mjs';
50
54
  import {
51
55
  ensureOfficialWebIntegration,
52
56
  officialWebIntegrationStatus,
@@ -61,15 +65,15 @@ import {
61
65
  } from './windows-supervisor-adapter.mjs';
62
66
 
63
67
  export const CREW_APP_DIRNAME = 'app';
64
- export const RELEASES_DIRNAME = 'releases';
65
- export const CURRENT_POINTER_FILENAME = 'current.json';
66
- export const KEEP_RELEASES = 2;
68
+ const RELEASES_DIRNAME = 'releases';
69
+ const CURRENT_POINTER_FILENAME = 'current.json';
70
+ const KEEP_RELEASES = 2;
67
71
  export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
68
72
  const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
69
73
  // Exact DSH cohort version: dotted numeric with optional -prerelease suffix.
70
74
  // Anything else (ranges, "../..", paths) is NOT an authorized cohort value
71
75
  // and must never reach rename/rmSync authority via retained-runtimes keys.
72
- export const EXACT_DSH_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
76
+ const EXACT_DSH_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
73
77
 
74
78
  export function npmCliInvocation(args, {
75
79
  platform = process.platform,
@@ -242,8 +246,8 @@ function writeCurrentPointer({ home, name, version, path }) {
242
246
  return pointer;
243
247
  }
244
248
 
245
- export const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
246
- export const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
249
+ const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
250
+ const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
247
251
 
248
252
  export function updateJournalFile({ home = homedir() } = {}) {
249
253
  return join(crewAppRoot({ home }), UPDATE_JOURNAL_FILENAME);
@@ -453,7 +457,7 @@ function writeUpdateJournal({ home, stage, prior = null, candidate = null, verif
453
457
  // identity matches the expected stage/prior/candidate. A crash between
454
458
  // verification success and this write leaves the journal unverified, so
455
459
  // reconcile refuses to finalize it.
456
- export function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
460
+ function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
457
461
  const current = readUpdateJournal({ home });
458
462
  if (!current || current.malformed) return { ok: false, code: 'JOURNAL_NOT_FOUND' };
459
463
  const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
@@ -481,82 +485,6 @@ function compensateActivationSync({ home, prior, manifest, log, installer }) {
481
485
  return { ok: true, version: manifest.version, path: prior.path };
482
486
  }
483
487
 
484
- // Undo a first-install candidate's activation surfaces: remove its profile
485
- // registration (dependency + bundle + junction) ONLY when each surface
486
- // still references THIS journal's candidate. The dependency must resolve
487
- // to exactly link:<candidateRealPath> (normalized slashes); a later
488
- // legitimate registration pointing elsewhere is never removed. Mixed or
489
- // unjudgeable state fails closed with the journal retained. When the
490
- // profile manifest is missing/unreadable the junction is still handled
491
- // independently so a "junction created, manifest never written" crash
492
- // cannot leave a dangling junction.
493
- function undoCandidateActivationSync({ home, candidateDir, candidateName }) {
494
- if (!candidateDir || !candidateName) return { ok: true, undone: false };
495
- const profileRoot = crewProfileDir({ home });
496
- const profileFile = join(profileRoot, 'package.json');
497
- let manifest = null;
498
- let manifestReadable = true;
499
- try { manifest = JSON.parse(readFileSync(profileFile, 'utf8')); } catch { manifest = null; manifestReadable = false; }
500
- const linkPath = join(profileRoot, 'node_modules', ...candidateName.split('/'));
501
- let linked = null;
502
- try {
503
- if (lstatSync(linkPath).isSymbolicLink()) linked = realpathSync(linkPath);
504
- } catch { linked = null; }
505
- let candidateReal = null;
506
- try { candidateReal = realpathSync(candidateDir); } catch { candidateReal = candidateDir; }
507
- const expectedDep = `link:${String(candidateReal).replace(/\\/g, '/')}`;
508
- const rawDep = manifest?.dependencies?.[candidateName];
509
- const depPointsAtCandidate = typeof rawDep === 'string'
510
- && rawDep.replace(/\\/g, '/') === expectedDep;
511
- const bundleNamesCandidate = Array.isArray(manifest?.dsh?.profile?.bundles) && manifest.dsh.profile.bundles.includes(candidateName);
512
- const linkPointsAtCandidate = linked !== null && linked === candidateReal;
513
- // Bundle carries no path: it NEVER grants deletion authority by itself.
514
- // It is removed only when dependency or junction proves THIS journal's
515
- // candidate still owns the registration. A re-pointed dep/junction with
516
- // a leftover same-name bundle fails closed (journal retained).
517
- const identityEvidence = depPointsAtCandidate || linkPointsAtCandidate;
518
- const bundlePointsAtCandidate = bundleNamesCandidate && identityEvidence;
519
- // Manifest unreadable but junction dangles at candidate: remove junction.
520
- if (!manifestReadable) {
521
- if (!linkPointsAtCandidate) return { ok: true, undone: false };
522
- try { rmSync(linkPath, { force: true }); } catch (error) {
523
- return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
524
- }
525
- return { ok: true, undone: true };
526
- }
527
- if (!manifest) return { ok: true, undone: false };
528
- // Same-name package re-pointed elsewhere (dep or junction references a
529
- // different target) while the bundle still names it: mixed state that
530
- // cannot prove THIS candidate owns the registration. Fail closed,
531
- // retain the journal for operator inspection.
532
- const depPointsElsewhere = typeof rawDep === 'string' && !depPointsAtCandidate;
533
- const linkPointsElsewhere = linked !== null && !linkPointsAtCandidate;
534
- if ((depPointsElsewhere || linkPointsElsewhere) && bundleNamesCandidate) {
535
- return { ok: false, code: 'CANDIDATE_UNDO_AMBIGUOUS', error: 'same-name package re-pointed elsewhere; refusing bundle removal' };
536
- }
537
- if (!depPointsAtCandidate && !bundlePointsAtCandidate && !linkPointsAtCandidate) {
538
- return { ok: true, undone: false };
539
- }
540
- // Partial registration: only undo the surfaces that reference THIS
541
- // candidate; leave anything already re-pointed elsewhere untouched.
542
- try {
543
- const next = { ...manifest };
544
- if (depPointsAtCandidate) {
545
- next.dependencies = { ...manifest.dependencies };
546
- delete next.dependencies[candidateName];
547
- if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
548
- }
549
- if (bundlePointsAtCandidate) {
550
- next.dsh = { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: manifest.dsh.profile.bundles.filter((b) => b !== candidateName) } };
551
- }
552
- writeFileAtomic(profileFile, JSON.stringify(next, null, 2) + '\n');
553
- if (linkPointsAtCandidate) rmSync(linkPath, { force: true });
554
- } catch (error) {
555
- return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
556
- }
557
- return { ok: true, undone: true };
558
- }
559
-
560
488
  // Canonical containment validation for a journal's runtime segment. Recovery
561
489
  // may rename/recursively-delete these roots, so liveRoot and retainedRoot
562
490
  // must be EXACTLY the canonical Crew-owned paths (resolved, never a suffix
@@ -760,6 +688,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
760
688
  }
761
689
  clearUpdateJournal({ home });
762
690
  gcOldReleases({ home, protect: journal.prior?.path ?? null });
691
+ gcRetainedCohorts({ home });
763
692
  log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} already committed, finalized`);
764
693
  return { ok: true, reconciled: true, stage: journal.stage, committed: true };
765
694
  }
@@ -803,6 +732,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
803
732
  writeCurrentPointer({ home, name: candidateIdent.name, version: candidateIdent.version, path: candidateDir });
804
733
  clearUpdateJournal({ home });
805
734
  gcOldReleases({ home, protect: journal.prior?.path ?? null });
735
+ gcRetainedCohorts({ home });
806
736
  log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} was verified and started, so the commit was completed`);
807
737
  return { ok: true, reconciled: true, stage: journal.stage, committed: true };
808
738
  }
@@ -1043,6 +973,28 @@ function defaultNpmInstaller(stageRoot, log) {
1043
973
  return true;
1044
974
  }
1045
975
 
976
+ // Exact-version specs only (the pinned-cohort shape). Ranges stay with normal
977
+ // resolution: a range override would both fight the resolver and violate the
978
+ // verbatim-spec rule npm applies when a direct dependency shares the name.
979
+ const EXACT_SPEC_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
980
+
981
+ /**
982
+ * Lock every exact-pinned dependency to its own version across the whole
983
+ * staged tree. The npm fallback install has no flag channel (sanitized env,
984
+ * fixed args), so a drifting transitive peer — e.g. a projection package
985
+ * peer-requiring a newer cohort than the payload's pin — otherwise aborts
986
+ * staging with ERESOLVE. npm v11 rejects an override whose spec differs from
987
+ * the direct dependency, so each override reuses the dependency's exact spec
988
+ * verbatim.
989
+ */
990
+ export function exactSpecOverrides(dependencies) {
991
+ const overrides = {};
992
+ for (const [name, spec] of Object.entries(dependencies ?? {})) {
993
+ if (typeof spec === 'string' && EXACT_SPEC_RE.test(spec)) overrides[name] = spec;
994
+ }
995
+ return Object.keys(overrides).length > 0 ? overrides : null;
996
+ }
997
+
1046
998
  // ---- payload staging ---------------------------------------------------------
1047
999
 
1048
1000
  /**
@@ -1102,6 +1054,8 @@ export function stageCandidatePayload({
1102
1054
  ...(manifest.peerDependencies ?? {}),
1103
1055
  ...(manifest.dependencies ?? {}),
1104
1056
  };
1057
+ const overrides = exactSpecOverrides(stagedManifest.dependencies);
1058
+ if (overrides) stagedManifest.overrides = overrides;
1105
1059
  writeFileSync(join(stageDir, 'package.json'), JSON.stringify(stagedManifest, null, 2) + '\n');
1106
1060
 
1107
1061
  // Materialize dependencies: prefer replicating the exact bits the candidate
@@ -1304,15 +1258,48 @@ export function commitActivatedRelease({ stageDir, manifest, home, prior = null
1304
1258
  writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
1305
1259
  clearUpdateJournal({ home });
1306
1260
  gcOldReleases({ home, protect: prior?.path ?? null });
1261
+ gcRetainedCohorts({ home });
1307
1262
  return stageDir;
1308
1263
  }
1309
1264
 
1310
- function commitStagedRelease({ stageDir, manifest, home, prior = null }) {
1311
- return commitActivatedRelease({ stageDir, manifest, home, prior });
1312
- }
1313
-
1314
1265
  const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
1315
1266
 
1267
+ // A retained runtime cohort is needed only while some surviving release
1268
+ // resolves to it, so this runs next to release pruning at the commit points
1269
+ // that call gcOldReleases: dropping a release is what stops its cohort being
1270
+ // pinned. Resolution goes through resolveReleaseCohort, the same chain every
1271
+ // other caller uses (manifest pin, then the release-cohort.json sidecar), so a
1272
+ // release that names its cohort only in the sidecar keeps its runtime.
1273
+ //
1274
+ // Fail closed: the cohort set is trusted only when EVERY directory under
1275
+ // releases/ resolved, because "nothing pins it" and "could not tell" must not
1276
+ // prune the same way. Pruning is best-effort on top of that — it must never
1277
+ // turn an update that already committed its pointer into a failed one.
1278
+ //
1279
+ // performCoordinatedCohortUpdate commits without gcOldReleases and is
1280
+ // deliberately not covered here either; its prior tree is still the rollback
1281
+ // target at that point.
1282
+ function gcRetainedCohorts({ home }) {
1283
+ try {
1284
+ const releasesDir = crewReleasesDir({ home });
1285
+ if (!existsSync(releasesDir)) return [];
1286
+ const entries = readdirSync(releasesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
1287
+ const cohorts = [];
1288
+ for (const entry of entries) {
1289
+ const resolved = resolveReleaseCohort({ releaseDir: join(releasesDir, entry.name) });
1290
+ if (!resolved.ok) {
1291
+ process.emitWarning(`dsh-crew: release "${entry.name}" does not resolve to an exact cohort (${resolved.code ?? 'unknown'}); skipping retained-runtime pruning this pass`);
1292
+ return [];
1293
+ }
1294
+ cohorts.push(resolved.dshVersion);
1295
+ }
1296
+ return gcRetainedRuntimes({ home, cohorts });
1297
+ } catch (error) {
1298
+ process.emitWarning(`dsh-crew: retained-runtime pruning skipped: ${error?.message ?? error}`);
1299
+ return [];
1300
+ }
1301
+ }
1302
+
1316
1303
  function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1317
1304
  const pointer = readCurrentPointer({ home });
1318
1305
  const releasesDir = crewReleasesDir({ home });
@@ -1362,7 +1349,7 @@ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1362
1349
  return removed;
1363
1350
  }
1364
1351
 
1365
- export function listManagedReleases({ home = homedir() } = {}) {
1352
+ function listManagedReleases({ home = homedir() } = {}) {
1366
1353
  const pointer = readCurrentPointer({ home });
1367
1354
  const root = crewReleasesDir({ home });
1368
1355
  if (!existsSync(root)) return [];
@@ -1390,7 +1377,7 @@ export function listManagedReleases({ home = homedir() } = {}) {
1390
1377
  // Windows supervisor executes it and writes a VERIFIED result. This polls
1391
1378
  // until the result arrives (or the timeout elapses). NEVER talks to the
1392
1379
  // legacy 3080 supervisor endpoint.
1393
- export async function requestCrewRuntimeRestart({
1380
+ async function requestCrewRuntimeRestart({
1394
1381
  reason = null,
1395
1382
  fetchImpl = globalThis.fetch,
1396
1383
  pollIntervalMs = 1_000,
@@ -1442,18 +1429,6 @@ export async function requestCrewRuntimeRestart({
1442
1429
  }
1443
1430
  }
1444
1431
 
1445
- async function restartOwnedRuntime(fetchImpl = globalThis.fetch) {
1446
- return requestCrewRuntimeRestart({ fetchImpl, reason: 'lifecycle restart' });
1447
- }
1448
-
1449
- async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
1450
- const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/runtime', { headers: { accept: 'application/json' } });
1451
- const body = await response.json();
1452
- return response.ok && body?.ok === true && body.runtime_version === version
1453
- ? { ok: true, runtime_version: body.runtime_version }
1454
- : { ok: false, code: 'RUNTIME_VERSION_MISMATCH' };
1455
- }
1456
-
1457
1432
  // Cohort verifier: compares the TARGET DSH cohort against the Hub's
1458
1433
  // dsh_version domain (the installed @deepseek-ai/dsh package), NEVER
1459
1434
  // against Crew's own runtime_version (the dsh-crew release). A null
@@ -1509,7 +1484,7 @@ export async function verifyCrewRuntimeIdentity(version, fetchImpl = globalThis.
1509
1484
  // AND the expected DSH cohort (dsh_version domain), alongside full 3210
1510
1485
  // identity. A stale old-Crew process on the right cohort (or vice versa)
1511
1486
  // fails closed instead of reporting rollback success.
1512
- export async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
1487
+ async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
1513
1488
  let body = null;
1514
1489
  try {
1515
1490
  const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
@@ -1910,10 +1885,10 @@ async function finishLifecycleAfterUpdateLock({
1910
1885
  // Lifecycle-owned cohort sidecar for a managed release directory. Historical
1911
1886
  // releases (pre-1.0.4) do not pin @deepseek-ai/dsh in their immutable
1912
1887
  // manifest; this records the cohort fact the lifecycle observed for them.
1913
- const cohortFile = (releaseDir) => join(releaseDir, 'release-cohort.json');
1888
+ const cohortFile = (releaseDir) => join(releaseDir, RELEASE_COHORT_FILENAME);
1914
1889
 
1915
1890
  // Exact DSH cohort version helper: delegates to the module-level regex.
1916
- export function isExactDshVersion(value) {
1891
+ function isExactDshVersion(value) {
1917
1892
  return typeof value === 'string' && EXACT_DSH_VERSION_RE.test(value);
1918
1893
  }
1919
1894
 
@@ -2242,7 +2217,7 @@ function registrationHealthy({ home, name, releaseDir }) {
2242
2217
  * config resolution. Package-manager children must see the user's real
2243
2218
  * environment, not our execution context.
2244
2219
  */
2245
- export function sanitizedPackageManagerEnv(baseEnv = process.env) {
2220
+ function sanitizedPackageManagerEnv(baseEnv = process.env) {
2246
2221
  const env = {};
2247
2222
  for (const [key, value] of Object.entries(baseEnv)) {
2248
2223
  if (/^npm_(config_|lifecycle|package_|execpath$|node_execpath$)/i.test(key)) continue;
@@ -2269,7 +2244,7 @@ export function buildProductionMigration({ home, log = () => {}, stopOwned, star
2269
2244
  });
2270
2245
  }
2271
2246
 
2272
- async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
2247
+ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, ensureWebProfile, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
2273
2248
  const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
2274
2249
  if (!r.ok && r.stderrTail) {
2275
2250
  log(` (runtime installer said: ${r.stderrTail})`);
@@ -2277,17 +2252,37 @@ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, sto
2277
2252
  if (!r.ok && r.code === 'DSH_RUNTIME_COHORT_MISMATCH') {
2278
2253
  return { ok: false, code: r.code, error: r.error, installed: r.installed, target: r.target, needsMigration: true };
2279
2254
  }
2280
- return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
2255
+ return r.ok
2256
+ ? { ok: true, version: r.cli?.version ?? null, cohortRepaired: r.cohortRepaired === true }
2257
+ : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
2281
2258
  });
2282
2259
  // Production migration wiring: ONE supervisor instance per migration,
2283
2260
  // reused by stop/start/verify/rollback so child identity stays coherent.
2284
2261
  // No legacy bridge involved. Missing callbacks fail closed inside
2285
2262
  // migrateCrewDshRuntime.
2286
2263
  const migrate = migrateRuntime ?? buildProductionMigration({ home, log, stopOwned, startOwned, verifyOwned, supervisorFactory });
2264
+ // The runtime alone cannot boot a managed 3210/3080: the launcher preflight
2265
+ // and the supervisor require <DSH_HOME>/profiles/web. Materialize it here —
2266
+ // before any supervisor convergence, on every path that ends in "runtime
2267
+ // ready" — so a fresh install cannot deadlock on a heartbeat that a
2268
+ // profile-less service can never publish.
2269
+ const convergeWebProfile = () => {
2270
+ const profile = (ensureWebProfile ?? ensureCrewWebProfile)({ home });
2271
+ if (!profile.ok) {
2272
+ log(`✗ Crew web profile bootstrap failed (${profile.code ?? 'unknown error'})`);
2273
+ return false;
2274
+ }
2275
+ if (profile.created) log(`✓ Crew web profile scaffold created (${profile.profileRoot})`);
2276
+ return true;
2277
+ };
2287
2278
  const r = await ensure({ home });
2288
2279
  if (r?.ok) {
2289
2280
  log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
2290
- return true;
2281
+ // The install locks the whole family when the registry had published a newer
2282
+ // prerelease than the pinned cohort; say so, because a silent repair is
2283
+ // indistinguishable from a registry that never drifted.
2284
+ if (r.cohortRepaired) log(' the DSH family had drifted from the pinned cohort; installed the pinned family instead');
2285
+ return convergeWebProfile();
2291
2286
  }
2292
2287
  if (r?.needsMigration === true) {
2293
2288
  log(`- runtime cohort stale (${r.installed} -> ${r.target}); migrating via staged transaction`);
@@ -2297,7 +2292,7 @@ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, sto
2297
2292
  return false;
2298
2293
  }
2299
2294
  log(`✓ runtime cohort migrated (@${m.version})`);
2300
- return true;
2295
+ return convergeWebProfile();
2301
2296
  }
2302
2297
  log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
2303
2298
  return false;
@@ -2676,9 +2671,18 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2676
2671
  // leave four configurations naming a directory that is gone. Writing the
2677
2672
  // release path directly is what made crash recovery unable to delete a
2678
2673
  // candidate without breaking Codex, ZCode and Claude Code.
2679
- const integrationRoot = registration.linkPath ?? releaseDir;
2680
-
2681
- const codex = installer.installCodex({ home, root: integrationRoot });
2674
+ //
2675
+ // The root is derived through the SAME integrationRoot() the readiness
2676
+ // checks use, not blindly from registration.linkPath: write and validate
2677
+ // must be one function of (home, releaseDir, name). In the healthy case the
2678
+ // gate passes right after registration re-pointed the link, so this is
2679
+ // still the link path; when the gate fails, both sides fall back to the
2680
+ // release directory together instead of the writer using the link while the
2681
+ // validator reports the release — the disagreement that made every clean
2682
+ // install read back as "needs repair".
2683
+ const integrationWriteRoot = integrationRoot({ home, root: releaseDir, name: manifest.name });
2684
+
2685
+ const codex = installer.installCodex({ home, root: integrationWriteRoot });
2682
2686
  if (codex.ok === false) {
2683
2687
  log(`✗ Codex Desktop integration failed: ${(codex.actions ?? []).join('; ')}`);
2684
2688
  return false;
@@ -2686,7 +2690,7 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2686
2690
  log('✓ Codex Desktop integration');
2687
2691
 
2688
2692
  if (installer.installZCode) {
2689
- const zcode = installer.installZCode({ home, root: integrationRoot });
2693
+ const zcode = installer.installZCode({ home, root: integrationWriteRoot });
2690
2694
  if (zcode.ok === false) {
2691
2695
  log(`✗ ZCode integration failed (${zcode.code ?? 'unknown'})`);
2692
2696
  return false;
@@ -2701,7 +2705,7 @@ async function activateRelease({ home, releaseDir, manifest, log, installer, sup
2701
2705
  }
2702
2706
  if (startup?.supported) log('✓ Windows login startup');
2703
2707
 
2704
- const claude = await installer.installClaudeCode({ home, root: integrationRoot });
2708
+ const claude = await installer.installClaudeCode({ home, root: integrationWriteRoot });
2705
2709
  if (claude.ok === false) {
2706
2710
  log(claudeIntegrationLine(claude));
2707
2711
  return false;
@@ -2740,6 +2744,59 @@ export async function npxDetach({ home = homedir(), log = console.log } = {}) {
2740
2744
  return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
2741
2745
  }
2742
2746
 
2747
+ // Desktop-client integration. The official desktop app boots its Harness with the
2748
+ // app-exclusive `desktop` profile, so the Crew panel can only reach it through
2749
+ // that profile's own patch layer — the one official-home write Crew performs, and
2750
+ // only because an operator asked for it by name. It never runs during install,
2751
+ // update or supervision.
2752
+ export async function npxDesktop({ home = homedir(), args = [], dryRun = false, log = console.log } = {}) {
2753
+ const action = args[0] ?? 'status';
2754
+ if (!['attach', 'detach', 'status'].includes(action)) {
2755
+ log(`✗ unknown desktop action: ${action}`);
2756
+ return { ok: false, error: 'usage: dsh-crew desktop <attach|detach|status> [--dry-run]' };
2757
+ }
2758
+ if (action === 'status') {
2759
+ const status = desktopStatus({ home });
2760
+ if (!status.file_present) log(`- desktop patch layer: absent (${status.path})`);
2761
+ else if (!status.attached) log(`- desktop patch layer: present, no Crew bridge (${status.path})`);
2762
+ else if (status.current) log(`✓ desktop patch layer carries the current Crew bridge (revision ${status.bridge?.revision})`);
2763
+ else log(`! desktop patch layer carries a stale Crew bridge (current revision ${status.bridge?.revision ?? 'unavailable'})`);
2764
+ if (!status.bridge) log(` (${status.bridge_error})`);
2765
+ log(JSON.stringify(status, null, 2));
2766
+ return { ok: true, status };
2767
+ }
2768
+ const result = action === 'attach'
2769
+ ? desktopAttach({ home, dryRun, log })
2770
+ : desktopDetach({ home, dryRun, log });
2771
+ if (!result.ok) log(`✗ desktop ${action} failed: ${result.error ?? result.code}`);
2772
+ return result;
2773
+ }
2774
+
2775
+ // Bring the operator's user-level DSH configuration across from the official home
2776
+ // into Crew's home: the Crew hub then runs the same models, providers, policy and
2777
+ // MCP servers as the desktop app. Read-only on ~/.dsh, no secrets copied (provider
2778
+ // entries keep their apiKeyEnv references, and those variables are inherited).
2779
+ export async function npxConfig({ home = homedir(), args = [], dryRun = false, log = console.log } = {}) {
2780
+ const action = args[0] ?? 'status';
2781
+ if (!['import', 'status'].includes(action)) {
2782
+ log(`✗ unknown config action: ${action}`);
2783
+ return { ok: false, error: 'usage: dsh-crew config <import|status> [--dry-run]' };
2784
+ }
2785
+ if (action === 'status') {
2786
+ const status = officialConfigMirrorStatus({ home });
2787
+ for (const layer of [status.home_layer, status.profile_layer]) {
2788
+ log(`- ${layer.label}: ${!layer.source_present ? `no official source (${layer.source})` : layer.mirrored ? 'mirrored' : 'not imported yet (run: dsh-crew config import)'}`);
2789
+ }
2790
+ log(JSON.stringify(status, null, 2));
2791
+ return { ok: true, status };
2792
+ }
2793
+ const result = mirrorOfficialHarnessConfig({ home, dryRun, log });
2794
+ if (result.ok && result.changed && !dryRun) {
2795
+ log(' the Crew harness hot-watches its patch layers; the hub picks the imported configuration up without a restart');
2796
+ }
2797
+ return result;
2798
+ }
2799
+
2743
2800
  export async function npxInstall({
2744
2801
  home = homedir(),
2745
2802
  log = console.log,
@@ -2818,15 +2875,15 @@ async function npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime
2818
2875
  return { ok: true, version: manifest.version, path: releaseDir };
2819
2876
  }
2820
2877
 
2821
- export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
2822
- export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
2878
+ const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
2879
+ const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
2823
2880
 
2824
2881
  /**
2825
2882
  * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
2826
2883
  * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
2827
2884
  * at `package/`.
2828
2885
  */
2829
- export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
2886
+ function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
2830
2887
  mkdirSync(destDir, { recursive: true });
2831
2888
  // GNU tar interprets a `C:\...` argument as a remote rsh host ("Cannot
2832
2889
  // connect to C:"), so anchor the invocation inside destDir and pass the
@@ -3248,6 +3305,16 @@ export function npxStatus({
3248
3305
  }
3249
3306
  log(`DSH plugin: ${dshPlugin} (dedicated dsh-crew profile on 3210)`);
3250
3307
  log(`Official 3080 UI bridge: ${officialWeb}`);
3308
+ // The desktop app carries its own bridge entry, which pins a revision: after a
3309
+ // payload update that entry is stale until it is re-pointed, so say which of the
3310
+ // three states the machine is in instead of leaving the panel silently old.
3311
+ const desktop = desktopStatus({ home });
3312
+ log(`Desktop app integration: ${
3313
+ !desktop.file_present ? 'not attached (run: dsh-crew desktop attach)'
3314
+ : desktop.attached && desktop.current ? 'attached'
3315
+ : desktop.attached ? 'attached to a stale bridge revision (run: dsh-crew desktop attach)'
3316
+ : 'not attached (run: dsh-crew desktop attach)'
3317
+ }`);
3251
3318
  log(`Codex Desktop integration: ${codex}`);
3252
3319
  log(`ZCode integration: ${zcode}`);
3253
3320
  log(`Claude Code integration: ${claude}`);
@@ -3343,6 +3410,16 @@ Commands:
3343
3410
  install persist the candidate package into Crew-owned state and register it
3344
3411
  integrate disabled: the official web profile is read-only (legacy bridge retired)
3345
3412
  detach disabled: the official web profile is read-only (legacy bridge retired)
3413
+ desktop the official DSH desktop app: attach|detach|status the Crew panel in its
3414
+ own patch layer (~/.dsh/profiles/desktop/cordis.patch.yml, hot-watched
3415
+ by the Harness). The only official-home write Crew performs, and only
3416
+ when this command is run; attach is idempotent and follows the current
3417
+ bridge revision, detach removes exactly the block it wrote
3418
+ config import|status the operator's user-level DSH configuration (models,
3419
+ providers, policy, MCP servers) from the official home into Crew's own
3420
+ home, so the Crew hub matches the desktop app. Read-only on ~/.dsh; no
3421
+ secrets are copied (provider entries keep their apiKeyEnv references);
3422
+ entries naming packages absent from Crew's runtime cohort are skipped
3346
3423
  status read-only report of launcher/installed versions and integrations
3347
3424
  inspect print the machine-readable extension capability/readiness contract
3348
3425
  jobs machine-first job API: list|get|watch|cancel|submit
@@ -3364,6 +3441,7 @@ Options:
3364
3441
  --replacement-default <id> with providers delete-plan: replacement Harness Default provider
3365
3442
  --confirm with providers delete: confirm the destructive mutation
3366
3443
  --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
3444
+ --dry-run with desktop attach|detach: report the change without writing
3367
3445
  --help show this help
3368
3446
 
3369
3447
  Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
@@ -3449,9 +3527,9 @@ const CREDENTIAL_CAPABILITY_REQUIREMENTS = Object.freeze({
3449
3527
  'purge-plan': Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
3450
3528
  purge: Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
3451
3529
  });
3452
- export const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
3530
+ const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
3453
3531
 
3454
- export async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
3532
+ async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
3455
3533
  if (hubUrl !== PRODUCTION_HUB_URL) throw new Error(`${purpose} requires the isolated 3210 Crew Hub`);
3456
3534
  const response = await fetchImpl(`${hubUrl}${CREW_ROUTE_BASE}/runtime`, { headers: { accept: 'application/json' } });
3457
3535
  let body;
@@ -3474,7 +3552,7 @@ export async function assertProductionHub({ hubUrl, requiredCapabilities = [], p
3474
3552
  * surface before sending a provider request. This prevents a same-port stale
3475
3553
  * Hub from turning a later 404 into an ambiguous destructive failure.
3476
3554
  */
3477
- export async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
3555
+ async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
3478
3556
  return assertProductionHub({
3479
3557
  hubUrl,
3480
3558
  requiredCapabilities: PROVIDER_CAPABILITY_REQUIREMENTS[action] ?? PROVIDER_CAPABILITY_REQUIREMENTS.list,
@@ -3674,6 +3752,7 @@ function normalizeCommand(argv) {
3674
3752
  let expectedRevision;
3675
3753
  let replacementDefault;
3676
3754
  let confirm = false;
3755
+ let dryRun = false;
3677
3756
  let purgeOrphanCredentials = false;
3678
3757
  for (let index = 0; index < flags.length; index += 1) {
3679
3758
  if (flags[index] === '--candidate') {
@@ -3713,16 +3792,18 @@ function normalizeCommand(argv) {
3713
3792
  replacementDefault = flags[index].slice('--replacement-default='.length); flags.splice(index, 1); index -= 1;
3714
3793
  } else if (flags[index] === '--confirm') {
3715
3794
  confirm = true; flags.splice(index, 1); index -= 1;
3795
+ } else if (flags[index] === '--dry-run') {
3796
+ dryRun = true; flags.splice(index, 1); index -= 1;
3716
3797
  } else if (flags[index] === '--purge-orphan-credentials') {
3717
3798
  purgeOrphanCredentials = true; flags.splice(index, 1); index -= 1;
3718
3799
  }
3719
3800
  }
3720
- const knownFlags = new Set(['--purge']);
3801
+ const knownFlags = new Set(['--purge', '--dry-run']);
3721
3802
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
3722
3803
  const args = flags.filter((f) => !f.startsWith('--'));
3723
3804
  if (!Number.isInteger(after) || after < 0) unknown.push('--after');
3724
3805
  if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
3725
- return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown };
3806
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, dryRun, purgeOrphanCredentials, args, unknown };
3726
3807
  }
3727
3808
 
3728
3809
  /**
@@ -3734,7 +3815,7 @@ export async function runNpxCli({
3734
3815
  error = console.error,
3735
3816
  commands = {},
3736
3817
  } = {}) {
3737
- const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
3818
+ const { command, purge, candidate, after, detail, request, planId, expectedRevision, replacementDefault, confirm, dryRun, purgeOrphanCredentials, args, unknown } = normalizeCommand(argv);
3738
3819
  if (command === '--help' || command === '-h' || command === 'help') {
3739
3820
  log(USAGE);
3740
3821
  return 0;
@@ -3743,7 +3824,7 @@ export async function runNpxCli({
3743
3824
  error(USAGE);
3744
3825
  return 1;
3745
3826
  }
3746
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3827
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'desktop', 'config', 'status', 'inspect', 'jobs', 'providers', 'credentials', 'releases', 'rollback', 'update', 'uninstall'].includes(command)) {
3747
3828
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
3748
3829
  return 1;
3749
3830
  }
@@ -3752,6 +3833,8 @@ export async function runNpxCli({
3752
3833
  install: commands.install ?? npxInstall,
3753
3834
  integrate: commands.integrate ?? npxIntegrate,
3754
3835
  detach: commands.detach ?? npxDetach,
3836
+ desktop: commands.desktop ?? npxDesktop,
3837
+ config: commands.config ?? npxConfig,
3755
3838
  status: commands.status ?? npxStatus,
3756
3839
  inspect: commands.inspect ?? npxInspect,
3757
3840
  jobs: commands.jobs ?? npxJobs,
@@ -3765,6 +3848,8 @@ export async function runNpxCli({
3765
3848
  let result;
3766
3849
  if (command === 'uninstall') result = await actions.uninstall({ purge, log });
3767
3850
  else if (command === 'update') result = await actions.update({ candidate, log });
3851
+ else if (command === 'desktop') result = await actions.desktop({ args, dryRun, log });
3852
+ else if (command === 'config') result = await actions.config({ args, dryRun, log });
3768
3853
  else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
3769
3854
  else if (command === 'providers') result = await actions.providers({ args, planId, expectedRevision, replacementDefault, confirm, purgeOrphanCredentials, log });
3770
3855
  else if (command === 'credentials') result = await actions.credentials({ args, planId, expectedRevision, confirm, log });
@@ -10,7 +10,7 @@ import { join } from 'node:path';
10
10
  export const OFFICIAL_BRIDGE_PACKAGE = '@ran-sh/dsh-crew-web-bridge';
11
11
  const STATE_FILENAME = 'official-web.json';
12
12
 
13
- export function officialWebProfileDir({ home = homedir() } = {}) {
13
+ function officialWebProfileDir({ home = homedir() } = {}) {
14
14
  return join(home, '.dsh', 'profiles', 'web');
15
15
  }
16
16
 
@@ -12,11 +12,11 @@ import { dirname, join, resolve } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { installOfficialFrontendAssets, officialFrontendAssetsReady } from './official-frontend-assets.mjs';
14
14
 
15
- export const WINDOWS_STARTUP_FILENAME = 'DSH Crew.vbs';
16
- export const WINDOWS_LAUNCHER_FILENAME = 'start-dsh-crew.cmd';
17
- export const WINDOWS_HELPER_FILENAME = 'start-dsh-crew.ps1';
18
- export const WINDOWS_CONTROL_FILENAME = 'supervisor-control.ps1';
19
- export const WINDOWS_SUPERVISOR_ASSET_MANIFEST = 'supervisor-assets.json';
15
+ const WINDOWS_STARTUP_FILENAME = 'DSH Crew.vbs';
16
+ const WINDOWS_LAUNCHER_FILENAME = 'start-dsh-crew.cmd';
17
+ const WINDOWS_HELPER_FILENAME = 'start-dsh-crew.ps1';
18
+ const WINDOWS_CONTROL_FILENAME = 'supervisor-control.ps1';
19
+ const WINDOWS_SUPERVISOR_ASSET_MANIFEST = 'supervisor-assets.json';
20
20
 
21
21
  function defaultStartupDir({ home, env }) {
22
22
  if (home === homedir() && env.APPDATA) {