@ran-sh/dsh-crew 2.1.8 → 2.2.1

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 (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/docs/job-contracts.md +14 -1
  3. package/docs/readiness-matrix.md +20 -1
  4. package/lib/client.js +9 -8
  5. package/package.json +1 -1
  6. package/scripts/setup.mjs +6 -27
  7. package/scripts/verify-npm-install.mjs +2 -2
  8. package/skills/dsh-crew/SKILL.md +24 -15
  9. package/src/adaptive-routing.mjs +2 -2
  10. package/src/ci-evidence.mjs +1 -1
  11. package/src/client/history-panel.tsx +6 -5
  12. package/src/client/quick-panel.tsx +0 -1
  13. package/src/credential-lifecycle.mjs +0 -3
  14. package/src/credential-purge-state.mjs +1 -5
  15. package/src/credential-reference.mjs +0 -8
  16. package/src/crew-workspaces.mjs +136 -0
  17. package/src/delivery.mjs +2 -2
  18. package/src/dsh-cli-runtime.mjs +18 -22
  19. package/src/extension-contract.mjs +1 -1
  20. package/src/failure-classification.mjs +0 -10
  21. package/src/history/cleanup-plan.mjs +15 -5
  22. package/src/hub/entry.mjs +1 -1
  23. package/src/hub/index.mjs +45 -19
  24. package/src/i18n.mjs +0 -2
  25. package/src/install/crew-skill.mjs +1 -1
  26. package/src/install/install-legacy.mjs +3 -3
  27. package/src/install/npx-lifecycle.mjs +60 -112
  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 +13 -6
  35. package/src/jobs.mjs +3 -4
  36. package/src/mcp-runtime.mjs +17 -12
  37. package/src/model-routing.mjs +3 -4
  38. package/src/model-schedule.mjs +0 -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/readiness-matrix.mjs +1 -1
  43. package/src/release-in-use.mjs +1 -1
  44. package/src/role-profiles.mjs +2 -2
  45. package/src/runtime-controls.mjs +1 -1
  46. package/src/runtime-identity.mjs +1 -1
  47. package/src/status-shard.mjs +1 -1
  48. package/src/supervisor/restart-request.mjs +4 -4
  49. package/src/workflow-runtime.mjs +1 -2
  50. package/src/workspace-context.mjs +2 -2
  51. package/src/workspace-isolation.mjs +71 -4
  52. package/src/workspace-lock.mjs +134 -0
  53. package/scripts/policy-probe.mjs +0 -101
  54. package/scripts/verify-crew-ui-polish.mjs +0 -145
  55. package/scripts/verify-history-ui.mjs +0 -98
  56. package/scripts/verify-installer-fix.mjs +0 -26
@@ -35,7 +35,7 @@ import {
35
35
  // Re-exported so existing importers keep working while the cohort value now
36
36
  // lives in exactly one place (src/dsh-cohort.mjs).
37
37
  export { DSH_CLI_PACKAGE, TARGET_DSH_VERSION, TARGET_DSH_SPEC };
38
- export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
38
+ const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
39
39
  const CREW_PROFILE_DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'];
40
40
  const PROFILE_PATCH_TEMPLATE = '[]\n';
41
41
  const PROFILE_PNPM_WORKSPACE = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n';
@@ -73,11 +73,6 @@ export function crewDshRuntimeVersionDir({ home = homedir(), version = TARGET_DS
73
73
  return join(crewDshHome({ home }), `runtime-${version}`);
74
74
  }
75
75
 
76
- export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
77
- const suffix = platform === 'win32' ? '.cmd' : '';
78
- return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
79
- }
80
-
81
76
  export function crewDshRuntimeModule({ home = homedir() } = {}) {
82
77
  return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
83
78
  }
@@ -736,35 +731,36 @@ export async function restoreRetainedRuntime({
736
731
  }
737
732
 
738
733
  // GC retained runtimes that no release pins. A retained cohort is needed only
739
- // while some managed release (current or retained) declares it as its exact
740
- // @deepseek-ai/dsh dependency. Best-effort; never throws.
741
- export function gcRetainedRuntimes({ home = homedir(), releases = [], log = () => {} } = {}) {
734
+ // while some managed release resolves to it, so the caller passes the resolved
735
+ // cohort versions (see resolveReleaseCohort: manifest pin, then the
736
+ // release-cohort.json sidecar). Best-effort; never throws — a root that is
737
+ // missing, unreadable or not a directory prunes nothing rather than failing
738
+ // the update that just committed.
739
+ export function gcRetainedRuntimes({ home = homedir(), cohorts = [], log = () => {} } = {}) {
742
740
  const root = retainedRuntimesRoot({ home });
743
- if (!existsSync(root)) return [];
744
- const needed = new Set();
745
- for (const release of releases) {
746
- const spec = payloadDshSpec(release);
747
- if (spec && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) needed.add(spec);
748
- }
741
+ const needed = new Set(cohorts.filter((spec) => typeof spec === 'string' && EXACT_COHORT_RE.test(spec)));
742
+ let names;
743
+ try { names = readdirSync(root); } catch { return []; }
749
744
  const removed = [];
750
- for (const name of readdirSync(root)) {
745
+ for (const name of names) {
746
+ if (needed.has(name)) continue;
751
747
  const dir = join(root, name);
752
- if (!needed.has(name)) {
753
- try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
754
- }
748
+ try { rmSync(dir, { recursive: true, force: true }); removed.push(name); } catch { /* best effort */ }
755
749
  }
756
750
  return removed;
757
751
  }
758
752
 
753
+ const EXACT_COHORT_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
754
+
759
755
  // Read the exact @deepseek-ai/dsh pin from a payload manifest (dependencies
760
756
  // first, then peerDependencies). Exact pins only: a range or absence yields
761
757
  // null so callers fail closed instead of guessing a cohort.
762
758
  function payloadDshSpec(manifest) {
763
759
  if (!manifest || typeof manifest !== 'object') return null;
764
760
  const direct = manifest.dependencies?.['@deepseek-ai/dsh'];
765
- if (typeof direct === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(direct)) return direct;
761
+ if (typeof direct === 'string' && EXACT_COHORT_RE.test(direct)) return direct;
766
762
  const peer = manifest.peerDependencies?.['@deepseek-ai/dsh'];
767
- if (typeof peer === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(peer)) return peer;
763
+ if (typeof peer === 'string' && EXACT_COHORT_RE.test(peer)) return peer;
768
764
  return null;
769
765
  }
770
766
 
@@ -964,7 +960,7 @@ function ensureDirectoryLink(linkPath, targetRoot) {
964
960
  * command. The profile manifest and one loader-visible directory link are the
965
961
  * only state changed; no dependency resolution or policy bypass is attempted.
966
962
  */
967
- export function ensurePluginRegistration({
963
+ function ensurePluginRegistration({
968
964
  profileRoot,
969
965
  root,
970
966
  name,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { validateModelCallabilityV2 } from './model-callability-contract.mjs';
6
6
 
7
- export const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
7
+ const EXTENSION_CONTRACT_SCHEMA_VERSION = 1;
8
8
 
9
9
  function row(matrix, id) {
10
10
  return Array.isArray(matrix?.rows) ? matrix.rows.find((entry) => entry?.id === id) : undefined;
@@ -3,16 +3,6 @@
3
3
  // Classification consumes structured status/codes/outcomes only. It never
4
4
  // parses raw exception text, provider responses, credentials, quotas or logs.
5
5
 
6
- export const FAILURE_CATEGORIES = Object.freeze([
7
- 'none',
8
- 'policy',
9
- 'compatibility',
10
- 'provider',
11
- 'runtime',
12
- 'verification',
13
- 'cancelled',
14
- ]);
15
-
16
6
  export const FAILURE_REASON_CODES = Object.freeze({
17
7
  NONE: 'NONE',
18
8
  CANCELLED: 'CANCELLED',
@@ -31,14 +31,24 @@ function records(values) {
31
31
  /**
32
32
  * Pure preview only. No filesystem or project-path operations are performed.
33
33
  *
34
- * `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
35
- * one range that cannot remove sessions the operator opened themselves. Widening
36
- * to `all` is always an explicit choice.
34
+ * `scope` defaults to `worktree` — Crew's own workspaces — because that is the
35
+ * range an operator is actually asking for when they clear a Crew project out,
36
+ * and it still cannot remove a session the operator opened themselves or one
37
+ * Crew ran in someone else's directory. Widening to `all` is always explicit.
38
+ *
39
+ * The value is named `worktree` for compatibility and now means workspaces: the
40
+ * pair a project's jobs run in, plus the per-job trees earlier releases left
41
+ * behind, so those stay cleanable instead of being stranded by the change.
37
42
  */
38
- export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
43
+ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'worktree', before } = {}) {
39
44
  if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
40
- const cutoff = scope === 'before' ? instant(before) : null;
45
+ // `worktree` takes the same time window as `before`, but optionally: the panel
46
+ // supplies one when the operator picks a date, and its absence means "everything
47
+ // in these workspaces" rather than an error. A date that was supplied and is
48
+ // malformed still fails, so a typo cannot silently widen the range.
49
+ const cutoff = (scope === 'before' || scope === 'worktree') ? instant(before) : null;
41
50
  if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
51
+ if (scope === 'worktree' && before !== undefined && cutoff === null) throw new Error('HISTORY_INVALID_CUTOFF');
42
52
  const workspaces = records(snapshot.workspaces);
43
53
  const sessions = records(snapshot.sessions);
44
54
  const active = ids(snapshot.activeSessionIds);
package/src/hub/entry.mjs CHANGED
@@ -68,7 +68,7 @@ export function recordAdaptiveJobOutcome(job, store = getProcessAdaptiveHealthSt
68
68
  * a chained promise preserves all legacy completion/waiter behavior and runs
69
69
  * this observer only after the Hub's own finalizer populated endedAt/status.
70
70
  */
71
- export function installAdaptiveHealthObserver() {
71
+ function installAdaptiveHealthObserver() {
72
72
  if (WorkerRegistry.prototype[ADAPTIVE_OBSERVER_INSTALLED] === true) return false;
73
73
  const originalSpawn = WorkerRegistry.prototype.spawn;
74
74
  Object.defineProperty(WorkerRegistry.prototype, ADAPTIVE_OBSERVER_INSTALLED, {
package/src/hub/index.mjs CHANGED
@@ -27,10 +27,11 @@ import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
27
27
  import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
28
28
  import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
29
29
  import { loadCiEvidence } from '../ci-evidence.mjs';
30
+ import { ensureCrewWorkspace, releaseCrewWorkspace } from '../crew-workspaces.mjs';
30
31
  import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
31
32
  import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
32
33
  import { buildExtensionContract } from '../extension-contract.mjs';
33
- import { cleanupIsolatedWorkspace, createIsolatedWorkspace, isCrewWorktreeName } from '../workspace-isolation.mjs';
34
+ import { cleanupIsolatedWorkspace, isCrewWorktreeName } from '../workspace-isolation.mjs';
34
35
  import { jobDisplayName } from '../job-identity.mjs';
35
36
  import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
36
37
  import { buildConfigReadinessMatrix } from '../config-readiness.mjs';
@@ -118,7 +119,6 @@ export const QUICK_CONFIG_KEYS = Object.freeze([
118
119
  const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro' };
119
120
  // Local copy (the hub must not import jobs.mjs, which pulls the DSH SDK into
120
121
  // the profile realm): a valid dispatch role set.
121
- const ROLES = { worker: true, reviewer: true };
122
122
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
123
123
  const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
124
124
  const RECOVERY_ACTION_PATTERN = /^[a-f0-9]{32}$/u;
@@ -861,26 +861,45 @@ export class WorkerRegistry { constructor(ctx) {
861
861
  let executionCwd = cwd;
862
862
  let isolatedWorkspace = null;
863
863
  if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
864
- const created = await createIsolatedWorkspace({ cwd, jobId: id, purpose: jobRole, baseRevision: workspace_branch });
864
+ // This project's stable worker/reviewer workspace, reset to the job's base
865
+ // revision and held for the job's duration, so the Harness groups these
866
+ // sessions into two entries instead of one per job.
867
+ const created = await ensureCrewWorkspace({ cwd, role: jobRole, baseRevision: workspace_branch });
865
868
  if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
866
869
  executionCwd = created.worktreePath;
867
- isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
870
+ isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot, stable: true, release: created.release };
868
871
  }
869
872
  // The Harness titles the session from the opening words of the prompt the
870
- // agent receives, so the prompt opens with the job's Crew name: that makes the
871
- // conversation, the worktree directory and the name an operator types the same
872
- // string. An allocated worktree's name wins, so a collision suffix stays
873
- // consistent between the two.
874
- const jobName = isCrewWorktreeName(basename(executionCwd))
875
- ? basename(executionCwd)
876
- : jobDisplayName({ purpose: jobRole });
877
- const workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role: jobRole }), { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
878
-
879
- const sessionId = `session-${randomUUID()}`;
880
- // Record provenance while it is still knowable: a session header carries no
881
- // field naming who asked for the session, so a later Crew-scoped cleanup can
882
- // only tell Crew's own work apart from the operator's by this ledger.
883
- appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
873
+ // agent receives, so the prompt opens with the job's Crew name. An allocated
874
+ // worktree's name wins, so a collision suffix stays consistent between the
875
+ // two. A stable workspace deliberately does *not* win: its directory name is
876
+ // shared by every job that uses it, so naming jobs after it would give them
877
+ // all one title and leave an operator unable to tell them apart. The
878
+ // workspace groups; the job name distinguishes.
879
+ let jobName;
880
+ let workerPrompt;
881
+ let sessionId;
882
+ try {
883
+ jobName = isCrewWorktreeName(basename(executionCwd))
884
+ ? basename(executionCwd)
885
+ : jobDisplayName({ purpose: jobRole });
886
+ workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role: jobRole }), { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
887
+
888
+ sessionId = `session-${randomUUID()}`;
889
+ // Record provenance while it is still knowable: a session header carries no
890
+ // field naming who asked for the session, so a later Crew-scoped cleanup can
891
+ // only tell Crew's own work apart from the operator's by this ledger.
892
+ appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
893
+ } catch (error) {
894
+ // Everything below this point runs inside the job's own lifecycle, whose
895
+ // `finally` releases the workspace lock. A throw here happens before that
896
+ // job exists, so nothing would release a lock already taken — and it would
897
+ // then block the workspace until the age backstop, which is hours. The
898
+ // window is small (`appendSessionOrigin` writes a file) but the cost of
899
+ // missing it is a workspace no job can use.
900
+ releaseCrewWorkspace(isolatedWorkspace);
901
+ throw error;
902
+ }
884
903
  const job = {
885
904
  id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
886
905
  selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
@@ -1158,7 +1177,14 @@ export class WorkerRegistry { constructor(ctx) {
1158
1177
  review: job.review,
1159
1178
  workspaceDiff: job.workspaceDiff,
1160
1179
  });
1161
- if (job.isolatedWorkspace) {
1180
+ if (job.isolatedWorkspace?.stable === true) {
1181
+ // A stable workspace is not disposed of — it is the directory the next
1182
+ // job and every session it produced belong to. Only the lock is dropped.
1183
+ const released = releaseCrewWorkspace(job.isolatedWorkspace);
1184
+ job.workspace_retained = false;
1185
+ const workspaceCleanupWarning = released ? null : 'workspace lock was not held at release';
1186
+ job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
1187
+ } else if (job.isolatedWorkspace) {
1162
1188
  const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
1163
1189
  job.workspace_retained = cleanup.ok !== true;
1164
1190
  const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
package/src/i18n.mjs CHANGED
@@ -13,7 +13,5 @@ export function setLang(lang) {
13
13
  return LANG;
14
14
  }
15
15
 
16
- export function getLang() { return LANG; }
17
-
18
16
  /** Pick the string for the active locale. Both branches are cheap literals. */
19
17
  export function tr(zh, en) { return LANG === 'zh' ? zh : en; }
@@ -42,7 +42,7 @@ export function crewSkillFiles({ home = homedir(), env = process.env } = {}) {
42
42
  }
43
43
 
44
44
  /** The skill template shipped in the payload. */
45
- export function crewSkillSource({ root }) {
45
+ function crewSkillSource({ root }) {
46
46
  return join(root, 'skills', CREW_SKILL_NAME, 'SKILL.md');
47
47
  }
48
48
 
@@ -57,7 +57,7 @@ const REGEX_MAY_FOLLOW = new Set([
57
57
  * from the raw text, so a keyword inside a comment or a string is not a
58
58
  * declaration and a quote inside a regex is not a string.
59
59
  */
60
- export function tokenizeModuleSource(source) {
60
+ function tokenizeModuleSource(source) {
61
61
  const tokens = [];
62
62
  const text = String(source ?? '');
63
63
  let i = 0;
@@ -748,7 +748,7 @@ export function pickClaudeCommand(candidates, { platform = process.platform } =
748
748
  ?? list[0];
749
749
  }
750
750
 
751
- export function resolveClaudeCommand({ platform = process.platform } = {}) {
751
+ function resolveClaudeCommand({ platform = process.platform } = {}) {
752
752
  const probe = spawnSync(platform === 'win32' ? 'where' : 'which', ['claude'], {
753
753
  encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
754
754
  });
@@ -1019,7 +1019,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
1019
1019
  }
1020
1020
 
1021
1021
  // Keep CLI transaction ordering testable without changing the operator's home.
1022
- export async function refreshClaudePlugin({ home, root, actions = [], claudeCommand, runStep = runClaudeStep }) {
1022
+ async function refreshClaudePlugin({ home, root, actions = [], claudeCommand, runStep = runClaudeStep }) {
1023
1023
  // Each step runs to completion — including ending the whole process tree on a
1024
1024
  // timeout — before the next one starts, and the settle window is the widest any
1025
1025
  // step asks for. Two things this replaces: an `uninstall` left running past its
@@ -44,9 +44,10 @@ import { samePayloadContent, capturePayloadContent } from './payload-content.mjs
44
44
  import { crewDshHome, crewProfileDir, claudeIntegrationLine } from './install.mjs';
45
45
  import { releaseClaimsState } from '../release-in-use.mjs';
46
46
  import { compareProcessToken, processStartToken } from '../process-identity.mjs';
47
+ import { RELEASE_COHORT_FILENAME } from '../dsh-cohort.mjs';
47
48
  import { renameTree } from './tree-move.mjs';
48
49
  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';
50
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, gcRetainedRuntimes, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
50
51
  import {
51
52
  ensureOfficialWebIntegration,
52
53
  officialWebIntegrationStatus,
@@ -61,15 +62,15 @@ import {
61
62
  } from './windows-supervisor-adapter.mjs';
62
63
 
63
64
  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;
65
+ const RELEASES_DIRNAME = 'releases';
66
+ const CURRENT_POINTER_FILENAME = 'current.json';
67
+ const KEEP_RELEASES = 2;
67
68
  export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
68
69
  const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
69
70
  // Exact DSH cohort version: dotted numeric with optional -prerelease suffix.
70
71
  // Anything else (ranges, "../..", paths) is NOT an authorized cohort value
71
72
  // 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.-]+)?$/;
73
+ const EXACT_DSH_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
73
74
 
74
75
  export function npmCliInvocation(args, {
75
76
  platform = process.platform,
@@ -242,8 +243,8 @@ function writeCurrentPointer({ home, name, version, path }) {
242
243
  return pointer;
243
244
  }
244
245
 
245
- export const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
246
- export const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
246
+ const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
247
+ const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
247
248
 
248
249
  export function updateJournalFile({ home = homedir() } = {}) {
249
250
  return join(crewAppRoot({ home }), UPDATE_JOURNAL_FILENAME);
@@ -453,7 +454,7 @@ function writeUpdateJournal({ home, stage, prior = null, candidate = null, verif
453
454
  // identity matches the expected stage/prior/candidate. A crash between
454
455
  // verification success and this write leaves the journal unverified, so
455
456
  // reconcile refuses to finalize it.
456
- export function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
457
+ function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
457
458
  const current = readUpdateJournal({ home });
458
459
  if (!current || current.malformed) return { ok: false, code: 'JOURNAL_NOT_FOUND' };
459
460
  const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
@@ -481,82 +482,6 @@ function compensateActivationSync({ home, prior, manifest, log, installer }) {
481
482
  return { ok: true, version: manifest.version, path: prior.path };
482
483
  }
483
484
 
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
485
  // Canonical containment validation for a journal's runtime segment. Recovery
561
486
  // may rename/recursively-delete these roots, so liveRoot and retainedRoot
562
487
  // must be EXACTLY the canonical Crew-owned paths (resolved, never a suffix
@@ -760,6 +685,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
760
685
  }
761
686
  clearUpdateJournal({ home });
762
687
  gcOldReleases({ home, protect: journal.prior?.path ?? null });
688
+ gcRetainedCohorts({ home });
763
689
  log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} already committed, finalized`);
764
690
  return { ok: true, reconciled: true, stage: journal.stage, committed: true };
765
691
  }
@@ -803,6 +729,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
803
729
  writeCurrentPointer({ home, name: candidateIdent.name, version: candidateIdent.version, path: candidateDir });
804
730
  clearUpdateJournal({ home });
805
731
  gcOldReleases({ home, protect: journal.prior?.path ?? null });
732
+ gcRetainedCohorts({ home });
806
733
  log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} was verified and started, so the commit was completed`);
807
734
  return { ok: true, reconciled: true, stage: journal.stage, committed: true };
808
735
  }
@@ -1304,15 +1231,48 @@ export function commitActivatedRelease({ stageDir, manifest, home, prior = null
1304
1231
  writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
1305
1232
  clearUpdateJournal({ home });
1306
1233
  gcOldReleases({ home, protect: prior?.path ?? null });
1234
+ gcRetainedCohorts({ home });
1307
1235
  return stageDir;
1308
1236
  }
1309
1237
 
1310
- function commitStagedRelease({ stageDir, manifest, home, prior = null }) {
1311
- return commitActivatedRelease({ stageDir, manifest, home, prior });
1312
- }
1313
-
1314
1238
  const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
1315
1239
 
1240
+ // A retained runtime cohort is needed only while some surviving release
1241
+ // resolves to it, so this runs next to release pruning at the commit points
1242
+ // that call gcOldReleases: dropping a release is what stops its cohort being
1243
+ // pinned. Resolution goes through resolveReleaseCohort, the same chain every
1244
+ // other caller uses (manifest pin, then the release-cohort.json sidecar), so a
1245
+ // release that names its cohort only in the sidecar keeps its runtime.
1246
+ //
1247
+ // Fail closed: the cohort set is trusted only when EVERY directory under
1248
+ // releases/ resolved, because "nothing pins it" and "could not tell" must not
1249
+ // prune the same way. Pruning is best-effort on top of that — it must never
1250
+ // turn an update that already committed its pointer into a failed one.
1251
+ //
1252
+ // performCoordinatedCohortUpdate commits without gcOldReleases and is
1253
+ // deliberately not covered here either; its prior tree is still the rollback
1254
+ // target at that point.
1255
+ function gcRetainedCohorts({ home }) {
1256
+ try {
1257
+ const releasesDir = crewReleasesDir({ home });
1258
+ if (!existsSync(releasesDir)) return [];
1259
+ const entries = readdirSync(releasesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
1260
+ const cohorts = [];
1261
+ for (const entry of entries) {
1262
+ const resolved = resolveReleaseCohort({ releaseDir: join(releasesDir, entry.name) });
1263
+ if (!resolved.ok) {
1264
+ process.emitWarning(`dsh-crew: release "${entry.name}" does not resolve to an exact cohort (${resolved.code ?? 'unknown'}); skipping retained-runtime pruning this pass`);
1265
+ return [];
1266
+ }
1267
+ cohorts.push(resolved.dshVersion);
1268
+ }
1269
+ return gcRetainedRuntimes({ home, cohorts });
1270
+ } catch (error) {
1271
+ process.emitWarning(`dsh-crew: retained-runtime pruning skipped: ${error?.message ?? error}`);
1272
+ return [];
1273
+ }
1274
+ }
1275
+
1316
1276
  function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1317
1277
  const pointer = readCurrentPointer({ home });
1318
1278
  const releasesDir = crewReleasesDir({ home });
@@ -1362,7 +1322,7 @@ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1362
1322
  return removed;
1363
1323
  }
1364
1324
 
1365
- export function listManagedReleases({ home = homedir() } = {}) {
1325
+ function listManagedReleases({ home = homedir() } = {}) {
1366
1326
  const pointer = readCurrentPointer({ home });
1367
1327
  const root = crewReleasesDir({ home });
1368
1328
  if (!existsSync(root)) return [];
@@ -1390,7 +1350,7 @@ export function listManagedReleases({ home = homedir() } = {}) {
1390
1350
  // Windows supervisor executes it and writes a VERIFIED result. This polls
1391
1351
  // until the result arrives (or the timeout elapses). NEVER talks to the
1392
1352
  // legacy 3080 supervisor endpoint.
1393
- export async function requestCrewRuntimeRestart({
1353
+ async function requestCrewRuntimeRestart({
1394
1354
  reason = null,
1395
1355
  fetchImpl = globalThis.fetch,
1396
1356
  pollIntervalMs = 1_000,
@@ -1442,18 +1402,6 @@ export async function requestCrewRuntimeRestart({
1442
1402
  }
1443
1403
  }
1444
1404
 
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
1405
  // Cohort verifier: compares the TARGET DSH cohort against the Hub's
1458
1406
  // dsh_version domain (the installed @deepseek-ai/dsh package), NEVER
1459
1407
  // against Crew's own runtime_version (the dsh-crew release). A null
@@ -1509,7 +1457,7 @@ export async function verifyCrewRuntimeIdentity(version, fetchImpl = globalThis.
1509
1457
  // AND the expected DSH cohort (dsh_version domain), alongside full 3210
1510
1458
  // identity. A stale old-Crew process on the right cohort (or vice versa)
1511
1459
  // fails closed instead of reporting rollback success.
1512
- export async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
1460
+ async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
1513
1461
  let body = null;
1514
1462
  try {
1515
1463
  const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
@@ -1910,10 +1858,10 @@ async function finishLifecycleAfterUpdateLock({
1910
1858
  // Lifecycle-owned cohort sidecar for a managed release directory. Historical
1911
1859
  // releases (pre-1.0.4) do not pin @deepseek-ai/dsh in their immutable
1912
1860
  // manifest; this records the cohort fact the lifecycle observed for them.
1913
- const cohortFile = (releaseDir) => join(releaseDir, 'release-cohort.json');
1861
+ const cohortFile = (releaseDir) => join(releaseDir, RELEASE_COHORT_FILENAME);
1914
1862
 
1915
1863
  // Exact DSH cohort version helper: delegates to the module-level regex.
1916
- export function isExactDshVersion(value) {
1864
+ function isExactDshVersion(value) {
1917
1865
  return typeof value === 'string' && EXACT_DSH_VERSION_RE.test(value);
1918
1866
  }
1919
1867
 
@@ -2242,7 +2190,7 @@ function registrationHealthy({ home, name, releaseDir }) {
2242
2190
  * config resolution. Package-manager children must see the user's real
2243
2191
  * environment, not our execution context.
2244
2192
  */
2245
- export function sanitizedPackageManagerEnv(baseEnv = process.env) {
2193
+ function sanitizedPackageManagerEnv(baseEnv = process.env) {
2246
2194
  const env = {};
2247
2195
  for (const [key, value] of Object.entries(baseEnv)) {
2248
2196
  if (/^npm_(config_|lifecycle|package_|execpath$|node_execpath$)/i.test(key)) continue;
@@ -2818,15 +2766,15 @@ async function npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime
2818
2766
  return { ok: true, version: manifest.version, path: releaseDir };
2819
2767
  }
2820
2768
 
2821
- export const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
2822
- export const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
2769
+ const UPDATE_PACKAGE_NAME = '@ran-sh/dsh-crew';
2770
+ const UPDATE_DEFAULT_SPEC = `${UPDATE_PACKAGE_NAME}@latest`;
2823
2771
 
2824
2772
  /**
2825
2773
  * Extract a packed npm tarball with the platform `tar` binary (bsdtar ships
2826
2774
  * with Windows 10+, macOS, and Linux) into destDir; npm tarballs always root
2827
2775
  * at `package/`.
2828
2776
  */
2829
- export function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
2777
+ function extractPackageTarball(tgzPath, destDir, { runner = spawnSync } = {}) {
2830
2778
  mkdirSync(destDir, { recursive: true });
2831
2779
  // GNU tar interprets a `C:\...` argument as a remote rsh host ("Cannot
2832
2780
  // connect to C:"), so anchor the invocation inside destDir and pass the
@@ -3449,9 +3397,9 @@ const CREDENTIAL_CAPABILITY_REQUIREMENTS = Object.freeze({
3449
3397
  'purge-plan': Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
3450
3398
  purge: Object.freeze(['credential-reference-inventory-v1', 'credential-purge-v1']),
3451
3399
  });
3452
- export const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
3400
+ const PRODUCTION_HUB_URL = 'http://127.0.0.1:3210';
3453
3401
 
3454
- export async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
3402
+ async function assertProductionHub({ hubUrl, requiredCapabilities = [], purpose = 'command', fetchImpl = globalThis.fetch } = {}) {
3455
3403
  if (hubUrl !== PRODUCTION_HUB_URL) throw new Error(`${purpose} requires the isolated 3210 Crew Hub`);
3456
3404
  const response = await fetchImpl(`${hubUrl}${CREW_ROUTE_BASE}/runtime`, { headers: { accept: 'application/json' } });
3457
3405
  let body;
@@ -3474,7 +3422,7 @@ export async function assertProductionHub({ hubUrl, requiredCapabilities = [], p
3474
3422
  * surface before sending a provider request. This prevents a same-port stale
3475
3423
  * Hub from turning a later 404 into an ambiguous destructive failure.
3476
3424
  */
3477
- export async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
3425
+ async function assertProviderHubCapabilities({ hubUrl, action, fetchImpl = globalThis.fetch } = {}) {
3478
3426
  return assertProductionHub({
3479
3427
  hubUrl,
3480
3428
  requiredCapabilities: PROVIDER_CAPABILITY_REQUIREMENTS[action] ?? PROVIDER_CAPABILITY_REQUIREMENTS.list,
@@ -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) {