@ran-sh/dsh-crew 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -0
  2. package/README.zh.md +4 -0
  3. package/codex/AGENTS.md +101 -92
  4. package/docs/ui-surfaces.md +48 -21
  5. package/lib/client.js +235 -34
  6. package/official-web-bridge/lib/client.js +368 -4532
  7. package/official-web-bridge/package.json +1 -2
  8. package/package.json +130 -67
  9. package/scripts/build-client.mjs +30 -15
  10. package/scripts/remove-legacy-official-bridge.ps1 +89 -0
  11. package/scripts/setup.mjs +152 -140
  12. package/scripts/verify-npm-install.mjs +311 -310
  13. package/scripts/verify-official-bridge-e2e.mjs +12 -1
  14. package/src/client/host-readiness.mjs +62 -59
  15. package/src/client/index.tsx +117 -24
  16. package/src/client/quick-entry.tsx +10 -0
  17. package/src/client/quick-panel.tsx +295 -0
  18. package/src/client/surface-detection.mjs +43 -30
  19. package/src/credential-reference.mjs +38 -0
  20. package/src/dsh-cli-runtime.mjs +500 -40
  21. package/src/dsh-cohort.mjs +20 -0
  22. package/src/hub/index.mjs +1602 -1016
  23. package/src/install/npx-lifecycle.mjs +1983 -468
  24. package/src/install/official-web.mjs +36 -73
  25. package/src/jobs.mjs +20 -24
  26. package/src/model-catalog.mjs +8 -1
  27. package/src/official-web-bridge.mjs +329 -249
  28. package/src/provider-delete-adapters.mjs +165 -17
  29. package/src/provider-inventory.mjs +17 -3
  30. package/src/provider-layer-migration-adapters.mjs +759 -0
  31. package/src/provider-layer-migration.mjs +198 -0
  32. package/src/provider-lifecycle-state.mjs +4 -1
  33. package/src/provider-profile-store.mjs +193 -4
  34. package/src/provider-settings-store.mjs +82 -6
  35. package/src/provider-store-lock.mjs +67 -0
  36. package/src/runtime-identity.mjs +24 -1
  37. package/src/supervisor/restart-request.mjs +202 -0
  38. package/windows/start-dsh-crew.ps1 +783 -370
  39. package/worker.cordis.yml +32 -46
  40. package/zcode/AGENTS.md +35 -26
@@ -13,16 +13,26 @@ import {
13
13
  mkdirSync,
14
14
  readFileSync,
15
15
  realpathSync,
16
+ renameSync,
17
+ rmSync,
16
18
  symlinkSync,
17
19
  unlinkSync,
18
20
  writeFileSync,
19
21
  } from 'node:fs';
20
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
- import { homedir } from 'node:os';
22
- import { crewDshHome, crewProfileDir } from './install/install.mjs';
23
- import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
24
-
25
- export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
22
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { crewDshHome, crewProfileDir } from './install/install.mjs';
25
+ import { reconcileProviderDesiredState } from './provider-desired-state.mjs';
26
+ import {
27
+ DSH_CLI_PACKAGE,
28
+ TARGET_DSH_VERSION,
29
+ TARGET_DSH_SPEC,
30
+ RETAINED_RUNTIMES_DIRNAME,
31
+ } from './dsh-cohort.mjs';
32
+
33
+ // Re-exported so existing importers keep working while the cohort value now
34
+ // lives in exactly one place (src/dsh-cohort.mjs).
35
+ export { DSH_CLI_PACKAGE, TARGET_DSH_VERSION, TARGET_DSH_SPEC };
26
36
  export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
27
37
  const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
28
38
  const PROFILE_PATCH_TEMPLATE = '[]\n';
@@ -57,6 +67,10 @@ export function crewDshRuntimeRoot({ home = homedir() } = {}) {
57
67
  return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
58
68
  }
59
69
 
70
+ export function crewDshRuntimeVersionDir({ home = homedir(), version = TARGET_DSH_VERSION } = {}) {
71
+ return join(crewDshHome({ home }), `runtime-${version}`);
72
+ }
73
+
60
74
  export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
61
75
  const suffix = platform === 'win32' ? '.cmd' : '';
62
76
  return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
@@ -190,10 +204,17 @@ export function runResolvedDsh(cli, args = [], {
190
204
  /**
191
205
  * Install a reusable DSH CLI into Crew-owned state. This is the only helper
192
206
  * that may invoke a package manager, and callers must explicitly opt into it.
207
+ *
208
+ * The caller-supplied `version` defaults to the pinned TARGET. Reuse happens
209
+ * only when the live runtime already matches `version`; a mismatch is
210
+ * reported as DSH_RUNTIME_COHORT_MISMATCH and NEVER upgraded in place.
211
+ * Callers that intend an upgrade must interpret that code as needsMigration
212
+ * and run the staged transactional path — there is deliberately no `force`.
193
213
  */
194
214
  export function ensureCrewDshRuntime({
195
215
  home = homedir(),
196
- packageSpec = DSH_CLI_PACKAGE,
216
+ version = TARGET_DSH_VERSION,
217
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
197
218
  npmCommand = null,
198
219
  pnpmCommand = null,
199
220
  findCommand = defaultFindCommand,
@@ -204,7 +225,22 @@ export function ensureCrewDshRuntime({
204
225
  env = process.env,
205
226
  } = {}) {
206
227
  const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
207
- if (existing?.kind === 'crew-runtime') return { ok: true, cli: existing, reused: true };
228
+ // Reuse only when the installed runtime already matches the requested
229
+ // cohort. A stale cohort (e.g. an online 0.1.1-rc.2 runtime) must never be
230
+ // mistaken for the target, and it must never be upgraded in place under a
231
+ // live hub.
232
+ if (existing?.kind === 'crew-runtime' && existing?.version === version) {
233
+ return { ok: true, cli: existing, reused: true };
234
+ }
235
+ if (existing?.kind === 'crew-runtime') {
236
+ return {
237
+ ok: false,
238
+ code: 'DSH_RUNTIME_COHORT_MISMATCH',
239
+ error: `Crew runtime is ${existing.version ?? 'unknown version'} but ${version} is required; refusing in-place upgrade of a live runtime`,
240
+ installed: existing.version ?? null,
241
+ target: version,
242
+ };
243
+ }
208
244
 
209
245
  const pnpm = pnpmCommand ?? findCommand('pnpm');
210
246
  const npm = npmCommand ?? findCommand('npm');
@@ -234,6 +270,17 @@ export function ensureCrewDshRuntime({
234
270
  stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
235
271
  };
236
272
  }
273
+ // Fail closed when the installed cohort drifts from the requested target
274
+ // (registry race, hoisted pollution, partial install).
275
+ if (cli.version !== version) {
276
+ return {
277
+ ok: false,
278
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
279
+ error: `installed Crew runtime is ${cli.version ?? 'unknown version'} but ${version} is required`,
280
+ installed: cli.version ?? null,
281
+ target: version,
282
+ };
283
+ }
237
284
  return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
238
285
  }
239
286
 
@@ -243,6 +290,419 @@ export function describeDshCli(cli) {
243
290
  return `${cli.kind}${version}`;
244
291
  }
245
292
 
293
+ // Shared pnpm/npm install of the pinned DSH cohort into a specific root
294
+ // directory. pnpm materializes ABSOLUTE-path .cmd shims and junctions that
295
+ // point at the install root's own .pnpm store, so the tree must be installed
296
+ // at its FINAL resting path: renaming a pnpm tree breaks every shim. Callers
297
+ // that need an atomic swap must install at the live root only after moving
298
+ // the old tree aside.
299
+ export function installDshInto({
300
+ root,
301
+ version,
302
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
303
+ npmCommand = null,
304
+ pnpmCommand = null,
305
+ findCommand = defaultFindCommand,
306
+ exists = defaultExists,
307
+ runner = spawnSync,
308
+ platform = process.platform,
309
+ comspec = process.env.ComSpec ?? 'cmd.exe',
310
+ env = process.env,
311
+ }) {
312
+ const pnpm = pnpmCommand ?? findCommand('pnpm');
313
+ const npm = npmCommand ?? findCommand('npm');
314
+ if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
315
+ mkdirSync(root, { recursive: true });
316
+ const packageManager = pnpm
317
+ ? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
318
+ : descriptor({ kind: 'npm', command: npm, source: 'npm' });
319
+ const packageArgs = pnpm
320
+ ? ['add', '--dir', root, '--ignore-scripts', packageSpec]
321
+ : ['install', '--prefix', root, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
322
+ const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
323
+ const result = runner(invocation.command, invocation.args, {
324
+ encoding: 'utf8',
325
+ stdio: ['ignore', 'pipe', 'pipe'],
326
+ shell: invocation.shell,
327
+ env: { ...env },
328
+ });
329
+ if (result.status !== 0) {
330
+ return {
331
+ ok: false,
332
+ code: 'DSH_RUNTIME_INSTALL_FAILED',
333
+ error: 'Crew runtime install failed',
334
+ status: result.status ?? -1,
335
+ stderrTail: String(result.stderr || result.stdout || '').trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 300),
336
+ };
337
+ }
338
+ const moduleEntry = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
339
+ if (!exists(moduleEntry)) {
340
+ return { ok: false, code: 'DSH_RUNTIME_INSTALL_INCOMPLETE', error: 'runtime entry missing after install' };
341
+ }
342
+ const installedVersion = packageVersion(moduleEntry);
343
+ if (installedVersion !== version) {
344
+ return {
345
+ ok: false,
346
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
347
+ error: `installed Crew runtime is ${installedVersion ?? 'unknown version'} but ${version} is required`,
348
+ installed: installedVersion ?? null,
349
+ target: version,
350
+ };
351
+ }
352
+ return { ok: true, root, version: installedVersion };
353
+ }
354
+
355
+ // Staged cohort migration: install the target cohort into a versioned
356
+ // directory WITHOUT touching the live runtime/, verify its manifest, then
357
+ // report the staged root for an atomic switch by the caller (stop owned
358
+ // 3210 -> swap directories -> clean restart -> identity check). Never
359
+ // upgrades a live runtime in place.
360
+ //
361
+ // NOTE: pnpm trees embed absolute paths in .cmd shims and junctions, so a
362
+ // staged tree CANNOT be renamed onto the live root afterwards. migrate
363
+ //CrewDshRuntime therefore installs at the live root in place (after parking
364
+ // the old tree); stageCrewDshRuntime remains for callers that consume the
365
+ // stage dir at its fixed versioned path (tests, dry runs, audits).
366
+ export function stageCrewDshRuntime({
367
+ home = homedir(),
368
+ version = TARGET_DSH_VERSION,
369
+ packageSpec = `${DSH_CLI_PACKAGE}@${version}`,
370
+ npmCommand = null,
371
+ pnpmCommand = null,
372
+ findCommand = defaultFindCommand,
373
+ exists = defaultExists,
374
+ runner = spawnSync,
375
+ platform = process.platform,
376
+ comspec = process.env.ComSpec ?? 'cmd.exe',
377
+ env = process.env,
378
+ read = readFileSync,
379
+ } = {}) {
380
+ const stagedRoot = crewDshRuntimeVersionDir({ home, version });
381
+ // Clean/recreate the versioned stage dir: a failed older attempt must
382
+ // never contaminate the next staging.
383
+ try { rmSync(stagedRoot, { recursive: true, force: true }); } catch {}
384
+ const installed = installDshInto({
385
+ root: stagedRoot,
386
+ version,
387
+ packageSpec,
388
+ npmCommand,
389
+ pnpmCommand,
390
+ findCommand,
391
+ exists,
392
+ runner,
393
+ platform,
394
+ comspec,
395
+ env,
396
+ });
397
+ if (!installed.ok) {
398
+ const code = installed.code === 'DSH_RUNTIME_INSTALL_FAILED'
399
+ ? 'DSH_RUNTIME_STAGE_FAILED'
400
+ : installed.code === 'DSH_RUNTIME_INSTALL_INCOMPLETE'
401
+ ? 'DSH_RUNTIME_STAGE_INCOMPLETE'
402
+ : installed.code;
403
+ return { ...installed, code, error: code === 'DSH_RUNTIME_STAGE_FAILED' ? 'staged Crew runtime install failed' : installed.error };
404
+ }
405
+ const stagedModule = join(stagedRoot, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
406
+ if (!exists(stagedModule)) {
407
+ return { ok: false, code: 'DSH_RUNTIME_STAGE_INCOMPLETE', error: 'staged runtime entry missing' };
408
+ }
409
+ const stagedVersion = packageVersion(stagedModule, read);
410
+ if (stagedVersion !== version) {
411
+ return {
412
+ ok: false,
413
+ code: 'DSH_RUNTIME_INSTALL_VERSION_MISMATCH',
414
+ error: `staged Crew runtime is ${stagedVersion ?? 'unknown version'} but ${version} is required`,
415
+ installed: stagedVersion ?? null,
416
+ target: version,
417
+ };
418
+ }
419
+ return { ok: true, stagedRoot, version: stagedVersion };
420
+ }
421
+
422
+ // Full cohort migration transaction (caller holds the update lock):
423
+ // stop owned 3210 -> park the live tree (rename to prev, its shims stay
424
+ // valid because the tree returns to the same path on rollback) -> install
425
+ // the target cohort AT THE LIVE ROOT (pnpm shims are absolute-path, so the
426
+ // tree must be born at its final resting path; a renamed pnpm tree has
427
+ // dangling shims) -> restart -> identity verify -> rollback on any failure.
428
+ // stopOwned/startOwned/verifyOwned have NO defaults: a missing callback
429
+ // fails closed instead of pretending an unverified step succeeded.
430
+ //
431
+ // prepareOnly=true stops + swaps the tree but NEVER starts the process:
432
+ // the caller (a pair-ordered rollback/compensation) will activate the
433
+ // matching Crew payload and start exactly once itself. The parked prior
434
+ // tree is returned as prevRoot for durable retention by the caller.
435
+ export async function migrateCrewDshRuntime({
436
+ home = homedir(),
437
+ version = TARGET_DSH_VERSION,
438
+ stageOptions = {},
439
+ stopOwned,
440
+ startOwned,
441
+ verifyOwned,
442
+ rename = renameSync,
443
+ log = () => {},
444
+ prepareOnly = false,
445
+ } = {}) {
446
+ const liveRoot = crewDshRuntimeRoot({ home });
447
+ if (typeof stopOwned !== 'function') {
448
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
449
+ }
450
+ if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
451
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
452
+ }
453
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
454
+ const prevRoot = join(crewDshHome({ home }), `runtime-prev-${nonce}`);
455
+ const stop = await stopOwned();
456
+ if (!stop.ok) {
457
+ return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
458
+ }
459
+ let liveMoved = false;
460
+ try {
461
+ if (existsSync(liveRoot)) {
462
+ rename(liveRoot, prevRoot);
463
+ liveMoved = true;
464
+ }
465
+ mkdirSync(liveRoot, { recursive: true });
466
+ } catch (error) {
467
+ // Park failed: restore the live tree before reporting.
468
+ let recovery = { ok: false };
469
+ try {
470
+ if (liveMoved && existsSync(prevRoot) && !existsSync(liveRoot)) rename(prevRoot, liveRoot);
471
+ if (!prepareOnly) {
472
+ const restarted = await startOwned();
473
+ recovery = restarted?.ok ? { ok: true } : { ok: false, code: restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED' };
474
+ } else {
475
+ recovery = { ok: true };
476
+ }
477
+ } catch (recoveryError) {
478
+ recovery = { ok: false, code: 'DSH_RUNTIME_SWAP_RECOVERY_FAILED', error: String(recoveryError?.message ?? recoveryError) };
479
+ }
480
+ return { ok: false, code: 'DSH_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), recovery };
481
+ }
482
+ // Install the target cohort AT the live root so every pnpm shim/junction
483
+ // carries the correct final absolute path.
484
+ const installed = installDshInto({ root: liveRoot, version, ...stageOptions });
485
+ if (!installed.ok) {
486
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename, prepareOnly });
487
+ return { ok: false, code: installed.code ?? 'DSH_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', recovery };
488
+ }
489
+ if (prepareOnly) {
490
+ log(`- runtime tree prepared at live root (@${version}); process not started`);
491
+ return { ok: true, version, liveRoot, prevRoot, prepared: true };
492
+ }
493
+ const start = await startOwned();
494
+ if (!start.ok) {
495
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
496
+ return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after install failed', recovery };
497
+ }
498
+ const verified = await verifyOwned();
499
+ if (!verified.ok) {
500
+ const recovery = await rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename });
501
+ return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check failed', recovery };
502
+ }
503
+ // Retain the prior cohort under retained-runtimes/<version> instead of
504
+ // deleting it: a later cross-cohort rollback can restore it offline with
505
+ // no registry round-trip. Retention is best-effort (a failure here must
506
+ // not fail the migration), and an existing retained copy of the same
507
+ // version is replaced so the retained set always holds the newest tree.
508
+ retainPriorRuntime({ home, prevRoot });
509
+ log(`- runtime cohort migrated to ${version}`);
510
+ return { ok: true, version, liveRoot };
511
+ }
512
+
513
+ async function rollbackRuntimeSwap({ liveRoot, prevRoot, stopOwned, startOwned, rename = renameSync, prepareOnly = false }) {
514
+ const recovery = { stoppedCandidate: false, restore: false, restart: false };
515
+ // The failed candidate 3210 may still be running against the tree we are
516
+ // about to replace: stop it FIRST, otherwise the swap races a live
517
+ // process (and on Windows the live handles can fail the rename/delete).
518
+ if (typeof stopOwned === 'function') {
519
+ try {
520
+ const stopped = await stopOwned();
521
+ recovery.stoppedCandidate = stopped?.ok === true;
522
+ if (!recovery.stoppedCandidate) recovery.stopError = stopped?.code ?? stopped?.error ?? 'candidate stop failed';
523
+ } catch (error) {
524
+ recovery.stopError = String(error?.message ?? error);
525
+ }
526
+ if (!recovery.stoppedCandidate) {
527
+ recovery.ok = false;
528
+ return recovery;
529
+ }
530
+ }
531
+ try { rmSync(liveRoot, { recursive: true, force: true }); } catch {}
532
+ try {
533
+ if (existsSync(prevRoot)) { rename(prevRoot, liveRoot); recovery.restore = true; }
534
+ } catch (error) {
535
+ recovery.restoreError = String(error?.message ?? error);
536
+ }
537
+ if (prepareOnly) {
538
+ recovery.ok = recovery.restore === true;
539
+ return recovery;
540
+ }
541
+ try {
542
+ const restarted = await startOwned();
543
+ recovery.restart = restarted?.ok === true;
544
+ if (!recovery.restart) recovery.restartCode = restarted?.code ?? 'DSH_RUNTIME_RESTART_FAILED';
545
+ } catch (error) {
546
+ recovery.restartError = String(error?.message ?? error);
547
+ }
548
+ recovery.ok = recovery.restore === true && recovery.restart === true;
549
+ return recovery;
550
+ }
551
+
552
+ // ---- retained runtime cohorts -------------------------------------------------
553
+
554
+ function retainedRuntimesRoot({ home }) {
555
+ return join(crewDshHome({ home }), RETAINED_RUNTIMES_DIRNAME);
556
+ }
557
+
558
+ function retainedRuntimeDir({ home, version }) {
559
+ return join(retainedRuntimesRoot({ home }), version);
560
+ }
561
+
562
+ // Probe the dsh package version inside a runtime tree (live or prev/retained).
563
+ function runtimeTreeVersion(root, read = readFileSync) {
564
+ if (!root) return null;
565
+ try {
566
+ const file = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json');
567
+ const parsed = JSON.parse(read(file, 'utf8'));
568
+ return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
569
+ } catch { return null; }
570
+ }
571
+
572
+ // Best-effort retention of the swapped-out prior runtime tree. Never throws
573
+ // and never fails the caller: retention is an optimization for offline
574
+ // rollback, not a correctness requirement of the migration itself.
575
+ function retainPriorRuntime({ home, prevRoot, rename = renameSync }) {
576
+ try {
577
+ if (!existsSync(prevRoot)) return { ok: true, retained: false };
578
+ const version = runtimeTreeVersion(prevRoot);
579
+ if (!version) {
580
+ // No version to key retention on; the tree is unreadable junk.
581
+ try { rmSync(prevRoot, { recursive: true, force: true }); } catch {}
582
+ return { ok: true, retained: false, reason: 'prior tree version unreadable; removed' };
583
+ }
584
+ const retainedRoot = retainedRuntimesRoot({ home });
585
+ const target = retainedRuntimeDir({ home, version });
586
+ mkdirSync(retainedRoot, { recursive: true });
587
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
588
+ rename(prevRoot, target);
589
+ return { ok: true, retained: true, version, path: target };
590
+ } catch (error) {
591
+ // A failed retain must not fail an otherwise-successful migration, and it
592
+ // must NOT delete the parked prior tree: that tree is the only offline
593
+ // rollback copy of the previous cohort. Leave it in place (a later
594
+ // operator/GC pass can retry or reap it) and report the failure loudly.
595
+ return { ok: false, retained: false, prevRoot, error: String(error?.message ?? error) };
596
+ }
597
+ }
598
+
599
+ // Locate a usable retained cohort tree. Returns the retained path when one
600
+ // exists and its manifest reports the requested version; otherwise null.
601
+ export function findRetainedRuntime({ home = homedir(), version, exists = existsSync, read = readFileSync } = {}) {
602
+ if (typeof version !== 'string' || version.length === 0) return null;
603
+ const dir = retainedRuntimeDir({ home, version });
604
+ if (!exists(dir)) return null;
605
+ if (runtimeTreeVersion(dir, read) !== version) return null;
606
+ return dir;
607
+ }
608
+
609
+ // Offline cohort restore for cross-cohort rollback: move the retained tree
610
+ // back onto live runtime/. The caller must hold the update lock.
611
+ //
612
+ // Pair-ordering contract: with prepareOnly=false this helper ALSO starts and
613
+ // verifies the 3210, which is only safe when the caller has already
614
+ // activated the matching Crew payload. Callers that must activate the
615
+ // payload FIRST (rollback/compensation pair transition) pass prepareOnly=true
616
+ // to swap the tree WITHOUT starting the process, then activate the payload,
617
+ // then start once and dual-verify. On success the retained copy is consumed
618
+ // (moved), so a subsequent rollback to the same cohort re-stages from the
619
+ // registry.
620
+ export async function restoreRetainedRuntime({
621
+ home = homedir(),
622
+ version,
623
+ stopOwned,
624
+ startOwned,
625
+ verifyOwned,
626
+ rename = renameSync,
627
+ log = () => {},
628
+ prepareOnly = false,
629
+ } = {}) {
630
+ const retained = findRetainedRuntime({ home, version });
631
+ if (!retained) {
632
+ return { ok: false, code: 'DSH_RUNTIME_RETAINED_MISSING', error: `no retained runtime for cohort ${version}` };
633
+ }
634
+ if (typeof stopOwned !== 'function') {
635
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'stop callback is required' };
636
+ }
637
+ if (!prepareOnly && (typeof startOwned !== 'function' || typeof verifyOwned !== 'function')) {
638
+ return { ok: false, code: 'DSH_RUNTIME_MIGRATION_CALLBACKS_MISSING', error: 'start/verify callbacks are required unless prepareOnly' };
639
+ }
640
+ const liveRoot = crewDshRuntimeRoot({ home });
641
+ const stop = await stopOwned();
642
+ if (!stop.ok) {
643
+ return { ok: false, code: stop.code ?? 'DSH_RUNTIME_STOP_FAILED', error: stop.error ?? 'could not stop owned 3210' };
644
+ }
645
+ try {
646
+ if (existsSync(liveRoot)) rmSync(liveRoot, { recursive: true, force: true });
647
+ rename(retained, liveRoot);
648
+ } catch (error) {
649
+ // Restore the pre-existing live tree is impossible (it was replaced only
650
+ // on success above); report and let the caller reconcile.
651
+ return { ok: false, code: 'DSH_RUNTIME_RESTORE_SWAP_FAILED', error: String(error?.message ?? error) };
652
+ }
653
+ if (prepareOnly) {
654
+ log(`- runtime tree prepared offline from retained tree (@${version}); process not started`);
655
+ return { ok: true, version, liveRoot, prepared: true };
656
+ }
657
+ const start = await startOwned();
658
+ if (!start.ok) {
659
+ return { ok: false, code: start.code ?? 'DSH_RUNTIME_START_FAILED', error: start.error ?? 'restart after restore failed', restored: true };
660
+ }
661
+ const verified = await verifyOwned();
662
+ if (!verified.ok) {
663
+ return { ok: false, code: verified.code ?? 'DSH_RUNTIME_VERIFY_FAILED', error: verified.error ?? 'identity check after restore failed', restored: true };
664
+ }
665
+ log(`- runtime cohort restored offline from retained tree (@${version})`);
666
+ return { ok: true, version, liveRoot };
667
+ }
668
+
669
+ // GC retained runtimes that no release pins. A retained cohort is needed only
670
+ // while some managed release (current or retained) declares it as its exact
671
+ // @deepseek-ai/dsh dependency. Best-effort; never throws.
672
+ export function gcRetainedRuntimes({ home = homedir(), releases = [], log = () => {} } = {}) {
673
+ const root = retainedRuntimesRoot({ home });
674
+ if (!existsSync(root)) return [];
675
+ const needed = new Set();
676
+ for (const release of releases) {
677
+ const spec = payloadDshSpec(release);
678
+ if (spec && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) needed.add(spec);
679
+ }
680
+ const removed = [];
681
+ for (const name of readdirSync(root)) {
682
+ const dir = join(root, name);
683
+ if (!needed.has(name)) {
684
+ try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
685
+ }
686
+ }
687
+ return removed;
688
+ }
689
+
690
+ // Read the exact @deepseek-ai/dsh pin from a payload manifest (dependencies
691
+ // first, then peerDependencies). Exact pins only: a range or absence yields
692
+ // null so callers fail closed instead of guessing a cohort.
693
+ function payloadDshSpec(manifest) {
694
+ if (!manifest || typeof manifest !== 'object') return null;
695
+ const direct = manifest.dependencies?.['@deepseek-ai/dsh'];
696
+ if (typeof direct === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(direct)) return direct;
697
+ const peer = manifest.peerDependencies?.['@deepseek-ai/dsh'];
698
+ if (typeof peer === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(peer)) return peer;
699
+ return null;
700
+ }
701
+
702
+ export function payloadDshVersion(manifest) {
703
+ return payloadDshSpec(manifest);
704
+ }
705
+
246
706
  function isObject(value) {
247
707
  return value !== null && typeof value === 'object' && !Array.isArray(value);
248
708
  }
@@ -314,37 +774,37 @@ function readProfileManifestForRegistration(profileRoot, { create = true, defaul
314
774
  return { profileManifest, manifest, created: false };
315
775
  }
316
776
 
317
- function ensureProfileScaffold(profileRoot) {
777
+ function ensureProfileScaffold(profileRoot) {
318
778
  mkdirSync(profileRoot, { recursive: true });
319
779
  let changed = false;
320
780
  const patchFile = join(profileRoot, 'cordis.patch.yml');
321
781
  if (!existsSync(patchFile)) { writeFileSync(patchFile, PROFILE_PATCH_TEMPLATE); changed = true; }
322
782
  const workspaceFile = join(profileRoot, 'pnpm-workspace.yaml');
323
783
  if (!existsSync(workspaceFile)) { writeFileSync(workspaceFile, PROFILE_PNPM_WORKSPACE); changed = true; }
324
- return changed;
325
- }
326
-
327
- function readCrewTombstones(home) {
328
- const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
329
- if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
330
- try {
331
- const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
332
- return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
333
- } catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
334
- }
335
-
336
- function reconcileCrewProfileProviders({ home, profileRoot }) {
337
- const patchFile = join(profileRoot, 'cordis.patch.yml');
338
- if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
339
- const lifecycle = readCrewTombstones(home);
340
- if (!lifecycle.ok) return lifecycle;
341
- const source = readFileSync(patchFile, 'utf8');
342
- if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
343
- const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
344
- if (!reconciled.ok) return reconciled;
345
- if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
346
- return reconciled;
347
- }
784
+ return changed;
785
+ }
786
+
787
+ function readCrewTombstones(home) {
788
+ const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
789
+ if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
790
+ try {
791
+ const state = JSON.parse(readFileSync(lifecycleFile, 'utf8'));
792
+ return { ok: true, tombstones: state?.tombstones && typeof state.tombstones === 'object' && !Array.isArray(state.tombstones) ? state.tombstones : {} };
793
+ } catch { return { ok: false, code: 'PROVIDER_LIFECYCLE_STATE_INVALID' }; }
794
+ }
795
+
796
+ function reconcileCrewProfileProviders({ home, profileRoot }) {
797
+ const patchFile = join(profileRoot, 'cordis.patch.yml');
798
+ if (!existsSync(patchFile)) return { ok: true, changed: false, removed: [] };
799
+ const lifecycle = readCrewTombstones(home);
800
+ if (!lifecycle.ok) return lifecycle;
801
+ const source = readFileSync(patchFile, 'utf8');
802
+ if (source.trim() === '[]') return { ok: true, changed: false, removed: [] };
803
+ const reconciled = reconcileProviderDesiredState(source, { tombstones: lifecycle.tombstones });
804
+ if (!reconciled.ok) return reconciled;
805
+ if (reconciled.changed) writeFileSync(patchFile, reconciled.text);
806
+ return reconciled;
807
+ }
348
808
 
349
809
  function ensureDirectoryLink(linkPath, targetRoot) {
350
810
  let stat;
@@ -431,13 +891,13 @@ export function ensurePluginRegistration({
431
891
  } catch { return { ok: false, code: 'CREW_PLUGIN_NOT_LOADABLE' }; }
432
892
  }
433
893
 
434
- export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
435
- const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
436
- if (!result.ok) return result;
437
- const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
438
- if (!reconciled.ok) return reconciled;
439
- return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
440
- }
894
+ export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
895
+ const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
896
+ if (!result.ok) return result;
897
+ const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
898
+ if (!reconciled.ok) return reconciled;
899
+ return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
900
+ }
441
901
 
442
902
  /**
443
903
  * Remove one Crew plugin registration without invoking a package manager.
@@ -0,0 +1,20 @@
1
+ // Single source of truth for the pinned DeepSeek Harness cohort.
2
+ //
3
+ // The Crew runtime, the SDK client, and the worker composition must all
4
+ // resolve to this exact version. All other modules re-export from here so a
5
+ // cohort bump touches exactly one file (plus package.json's 48 DSH pins).
6
+ export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
7
+ export const TARGET_DSH_VERSION = '0.1.2-rc.1';
8
+ export const TARGET_DSH_SPEC = `${DSH_CLI_PACKAGE}@${TARGET_DSH_VERSION}`;
9
+
10
+ // Retained-cohort support: when a release's manifest pins an older DSH
11
+ // cohort than the current TARGET, the old runtime is retained on disk so a
12
+ // rollback can restore it offline (no registry round-trip).
13
+ export const RETAINED_RUNTIMES_DIRNAME = 'retained-runtimes';
14
+
15
+ // Lifecycle-owned cohort metadata for a managed release. Historical releases
16
+ // (pre-1.0.4) did not pin @deepseek-ai/dsh in their manifest; this sidecar
17
+ // records the runtime cohort fact the lifecycle observed for them WITHOUT
18
+ // mutating the immutable release manifest. Resolution priority:
19
+ // manifest exact pin -> sidecar -> (legacy discovery) -> fail closed.
20
+ export const RELEASE_COHORT_FILENAME = 'release-cohort.json';