@ran-sh/dsh-crew 1.2.0-rc.6 → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "1.2.0-rc.6",
3
+ "version": "1.3.1",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -90,6 +90,36 @@ pnpm run build:client
90
90
  npm pack --dry-run
91
91
  ```
92
92
 
93
+ ## Harness CLI selection
94
+
95
+ The Crew launcher and the 3210 Hub boot from one Crew-managed Harness entry,
96
+ chosen in this order:
97
+
98
+ 1. `DSH_CREW_DSH_CLI`, when set to an explicit `@deepseek-ai/dsh` entry.
99
+ 2. The Crew-managed npm runtime at `<crew home>/runtime` (the normal install
100
+ path, written by `dsh-crew update`). It carries whatever cohort the installed
101
+ Crew release pinned; the launcher never substitutes a different version.
102
+ 3. A Crew-managed source cohort, discovered through a
103
+ `<crew home>/runtime-source-<label>.json` sidecar. The sidecar's recorded
104
+ `version` must equal the checkout's own `apps/cli/package.json`, so a stale
105
+ sidecar can never pin a half-updated tree.
106
+
107
+ For the npm runtime the launcher uses the Crew home as `DSH_HOME`; a source
108
+ cohort is its own home, because its profiles, sessions and settings live inside
109
+ that tree. The launcher then derives `dsh_version` from the verified
110
+ `@deepseek-ai/dsh` manifest and requires the Hub to report that same version, so
111
+ a process left over from a previous cohort is never treated as healthy.
112
+
113
+ Only override `DSH_CREW_DSH_CLI` deliberately — it wins over the managed runtime.
114
+ Never point it at the official `~/.dsh` tree, which the Crew launcher treats as a
115
+ read-only boundary.
116
+
117
+ To build a source cohort instead of the npm runtime (for example to test an
118
+ unpublished tag), clone the tag into `<crew home>/runtime-source-<label>`, build
119
+ its CLI, and write the matching sidecar. On Windows a source install needs the
120
+ native toolchain for its `fs-ext` dependency; a failed native build is reported
121
+ as a runtime failure rather than silently falling back to another cohort.
122
+
93
123
  ## Rollback vs uninstall
94
124
 
95
125
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.2.0-rc.6",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -41,30 +41,30 @@
41
41
  "./package.json": "./package.json"
42
42
  },
43
43
  "peerDependencies": {
44
- "@deepseek-ai/dsh": "0.1.2-rc.1",
45
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
46
- "@deepseek-ai/dsh-agent-loop": "0.1.2-rc.1",
47
- "@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
48
- "@deepseek-ai/dsh-bash-local": "0.1.2-rc.1",
49
- "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
50
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
51
- "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
52
- "@deepseek-ai/dsh-compaction-basic": "0.1.2-rc.1",
53
- "@deepseek-ai/dsh-fs-local": "0.1.2-rc.1",
54
- "@deepseek-ai/dsh-fs-observation-policy": "0.1.2-rc.1",
55
- "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
56
- "@deepseek-ai/dsh-llm-deepseek": "0.1.2-rc.1",
57
- "@deepseek-ai/dsh-sandbox-local": "0.1.2-rc.1",
58
- "@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
59
- "@deepseek-ai/dsh-sdk-client": "0.1.2-rc.1",
60
- "@deepseek-ai/dsh-sdk-jsonrpc-server": "0.1.2-rc.1",
61
- "@deepseek-ai/dsh-sdk-minimal": "0.1.2-rc.1",
62
- "@deepseek-ai/dsh-session": "0.1.2-rc.1",
63
- "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.2-rc.1",
64
- "@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
65
- "@deepseek-ai/dsh-token-meter": "0.1.2-rc.1",
66
- "@deepseek-ai/dsh-tool-fs": "0.1.2-rc.1",
67
- "@deepseek-ai/dsh-tool-todo": "0.1.2-rc.1",
44
+ "@deepseek-ai/dsh": "0.1.5-rc.2",
45
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
46
+ "@deepseek-ai/dsh-agent-loop": "0.1.5-rc.2",
47
+ "@deepseek-ai/dsh-agent-presets": "0.1.5-rc.2",
48
+ "@deepseek-ai/dsh-bash-local": "0.1.5-rc.2",
49
+ "@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
50
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.2",
51
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
52
+ "@deepseek-ai/dsh-compaction-basic": "0.1.5-rc.2",
53
+ "@deepseek-ai/dsh-fs-local": "0.1.5-rc.2",
54
+ "@deepseek-ai/dsh-fs-observation-policy": "0.1.5-rc.2",
55
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
56
+ "@deepseek-ai/dsh-llm-deepseek": "0.1.5-rc.2",
57
+ "@deepseek-ai/dsh-sandbox-local": "0.1.5-rc.2",
58
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.5-rc.2",
59
+ "@deepseek-ai/dsh-sdk-client": "0.1.5-rc.2",
60
+ "@deepseek-ai/dsh-sdk-jsonrpc-server": "0.1.5-rc.2",
61
+ "@deepseek-ai/dsh-sdk-minimal": "0.1.5-rc.2",
62
+ "@deepseek-ai/dsh-session": "0.1.5-rc.2",
63
+ "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.5-rc.2",
64
+ "@deepseek-ai/dsh-subprocess-local": "0.1.5-rc.2",
65
+ "@deepseek-ai/dsh-token-meter": "0.1.5-rc.2",
66
+ "@deepseek-ai/dsh-tool-fs": "0.1.5-rc.2",
67
+ "@deepseek-ai/dsh-tool-todo": "0.1.5-rc.2",
68
68
  "react": "^18.0.0",
69
69
  "react-dom": "^18.0.0"
70
70
  },
@@ -149,30 +149,30 @@
149
149
  }
150
150
  },
151
151
  "devDependencies": {
152
- "@deepseek-ai/dsh": "0.1.2-rc.1",
153
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
154
- "@deepseek-ai/dsh-agent-loop": "0.1.2-rc.1",
155
- "@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
156
- "@deepseek-ai/dsh-bash-local": "0.1.2-rc.1",
157
- "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
158
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
159
- "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1",
160
- "@deepseek-ai/dsh-compaction-basic": "0.1.2-rc.1",
161
- "@deepseek-ai/dsh-fs-local": "0.1.2-rc.1",
162
- "@deepseek-ai/dsh-fs-observation-policy": "0.1.2-rc.1",
163
- "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
164
- "@deepseek-ai/dsh-llm-deepseek": "0.1.2-rc.1",
165
- "@deepseek-ai/dsh-sandbox-local": "0.1.2-rc.1",
166
- "@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
167
- "@deepseek-ai/dsh-sdk-client": "0.1.2-rc.1",
168
- "@deepseek-ai/dsh-sdk-jsonrpc-server": "0.1.2-rc.1",
169
- "@deepseek-ai/dsh-sdk-minimal": "0.1.2-rc.1",
170
- "@deepseek-ai/dsh-session": "0.1.2-rc.1",
171
- "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.2-rc.1",
172
- "@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
173
- "@deepseek-ai/dsh-token-meter": "0.1.2-rc.1",
174
- "@deepseek-ai/dsh-tool-fs": "0.1.2-rc.1",
175
- "@deepseek-ai/dsh-tool-todo": "0.1.2-rc.1",
152
+ "@deepseek-ai/dsh": "0.1.5-rc.2",
153
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
154
+ "@deepseek-ai/dsh-agent-loop": "0.1.5-rc.2",
155
+ "@deepseek-ai/dsh-agent-presets": "0.1.5-rc.2",
156
+ "@deepseek-ai/dsh-bash-local": "0.1.5-rc.2",
157
+ "@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
158
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.2",
159
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
160
+ "@deepseek-ai/dsh-compaction-basic": "0.1.5-rc.2",
161
+ "@deepseek-ai/dsh-fs-local": "0.1.5-rc.2",
162
+ "@deepseek-ai/dsh-fs-observation-policy": "0.1.5-rc.2",
163
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
164
+ "@deepseek-ai/dsh-llm-deepseek": "0.1.5-rc.2",
165
+ "@deepseek-ai/dsh-sandbox-local": "0.1.5-rc.2",
166
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.5-rc.2",
167
+ "@deepseek-ai/dsh-sdk-client": "0.1.5-rc.2",
168
+ "@deepseek-ai/dsh-sdk-jsonrpc-server": "0.1.5-rc.2",
169
+ "@deepseek-ai/dsh-sdk-minimal": "0.1.5-rc.2",
170
+ "@deepseek-ai/dsh-session": "0.1.5-rc.2",
171
+ "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.5-rc.2",
172
+ "@deepseek-ai/dsh-subprocess-local": "0.1.5-rc.2",
173
+ "@deepseek-ai/dsh-token-meter": "0.1.5-rc.2",
174
+ "@deepseek-ai/dsh-tool-fs": "0.1.5-rc.2",
175
+ "@deepseek-ai/dsh-tool-todo": "0.1.5-rc.2",
176
176
  "@types/react": "^18.3.0",
177
177
  "react": "^18.3.1",
178
178
  "react-dom": "^18.3.1",
@@ -17,6 +17,7 @@ import { createServer } from 'node:net';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { npxInstall, npxIntegrate } from '../src/install/npx-lifecycle.mjs';
20
+ import { TARGET_DSH_VERSION } from '../src/dsh-cohort.mjs';
20
21
 
21
22
  // RETIRED as a release gate: the legacy 3080 -> 3210 bridge is retired and
22
23
  // npxIntegrate()/npxDetach() always return OFFICIAL_WEB_PROFILE_READ_ONLY, so
@@ -139,7 +140,7 @@ try {
139
140
  const target = join(harnessHome, 'runtime');
140
141
  mkdirSync(dirname(target), { recursive: true });
141
142
  symlinkSync(realpathSync(runtimeRoot), target, process.platform === 'win32' ? 'junction' : 'dir');
142
- return { ok: true, version: '0.1.2-rc.1' };
143
+ return { ok: true, version: TARGET_DSH_VERSION };
143
144
  },
144
145
  });
145
146
  assert(installed.ok, `disposable install failed: ${logs.join(' | ')}`);
@@ -784,6 +784,75 @@ function ensureProfileScaffold(profileRoot) {
784
784
  return changed;
785
785
  }
786
786
 
787
+ function readPackageVersionAt(dir, read = readFileSync) {
788
+ try {
789
+ const parsed = JSON.parse(read(join(dir, 'package.json'), 'utf8'));
790
+ return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
791
+ } catch { return null; }
792
+ }
793
+
794
+ // Nearest `<dir>/node_modules/<name>` walking up from `dir`, mirroring the
795
+ // resolution a require() from that directory would take.
796
+ export function resolveNearestBundleVersion(dir, name, { exists = defaultExists, read = readFileSync } = {}) {
797
+ let cursor = dir;
798
+ for (let depth = 0; depth < 12; depth += 1) {
799
+ const candidate = join(cursor, 'node_modules', ...name.split('/'));
800
+ if (exists(candidate)) return readPackageVersionAt(candidate, read);
801
+ const parent = dirname(cursor);
802
+ if (parent === cursor) break;
803
+ cursor = parent;
804
+ }
805
+ return null;
806
+ }
807
+
808
+ /**
809
+ * Stop a profile from resolving its bundle packages out of a foreign cohort.
810
+ *
811
+ * A profile first installed against a source checkout records `dsh-base` and
812
+ * `dsh-web-app` as links into that checkout. Switching the CLI to another
813
+ * cohort leaves those links behind, and because they sit in the profile's own
814
+ * node_modules they shadow the matching packages installed beside the profile.
815
+ * The host then composes the web app of one cohort while the plugin table comes
816
+ * from another; the client module table is built per cohort, so the mismatched
817
+ * plugin dies at require time with "missed the module table".
818
+ *
819
+ * Only an entry whose fallthrough provably resolves the target cohort is
820
+ * removed. A profile that deliberately tracks a different cohort keeps its
821
+ * links and is reported as skipped, never as an error.
822
+ */
823
+ export function reconcileProfileBundleCohort({
824
+ home = homedir(),
825
+ profileRoot,
826
+ version = TARGET_DSH_VERSION,
827
+ bundles = CREW_PROFILE_DEFAULT_BUNDLES,
828
+ exists = defaultExists,
829
+ read = readFileSync,
830
+ remove = (path) => rmSync(path, { recursive: true, force: true }),
831
+ } = {}) {
832
+ const root = profileRoot ?? crewProfileDir({ home });
833
+ if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
834
+ const modulesDir = join(root, 'node_modules');
835
+ const parentDir = dirname(root);
836
+ const unshadowed = [];
837
+ const skipped = [];
838
+ const removeBundle = (path) => remove(path);
839
+ for (const name of bundles) {
840
+ const shadowPath = join(modulesDir, ...name.split('/'));
841
+ if (!exists(shadowPath)) continue;
842
+ const shadowed = readPackageVersionAt(shadowPath, read);
843
+ if (shadowed === version) continue;
844
+ const fallthrough = resolveNearestBundleVersion(parentDir, name, { exists, read });
845
+ if (fallthrough !== version) {
846
+ skipped.push({ bundle: name, shadowed: shadowed ?? null, fallthrough: fallthrough ?? null });
847
+ continue;
848
+ }
849
+ try { removeBundle(shadowPath); } catch { return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name }; }
850
+ if (exists(shadowPath)) return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name };
851
+ unshadowed.push({ bundle: name, shadowed: shadowed ?? null });
852
+ }
853
+ return { ok: true, changed: unshadowed.length > 0, profileRoot: root, unshadowed, skipped };
854
+ }
855
+
787
856
  function readCrewTombstones(home) {
788
857
  const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
789
858
  if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
@@ -894,9 +963,16 @@ export function ensurePluginRegistration({
894
963
  export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
895
964
  const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
896
965
  if (!result.ok) return result;
966
+ const reconciliation = reconcileProfileBundleCohort({ home, profileRoot: result.profileRoot });
967
+ if (!reconciliation.ok) return reconciliation;
897
968
  const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
898
969
  if (!reconciled.ok) return reconciled;
899
- return { ...result, changed: result.changed || reconciled.changed, provider_reconciliation: reconciled.removed ?? [] };
970
+ return {
971
+ ...result,
972
+ changed: result.changed || reconciled.changed || reconciliation.changed,
973
+ provider_reconciliation: reconciled.removed ?? [],
974
+ bundle_reconciliation: reconciliation.unshadowed ?? [],
975
+ };
900
976
  }
901
977
 
902
978
  /**
@@ -4,7 +4,7 @@
4
4
  // resolve to this exact version. All other modules re-export from here so a
5
5
  // cohort bump touches exactly one file (plus package.json's 48 DSH pins).
6
6
  export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
7
- export const TARGET_DSH_VERSION = '0.1.2-rc.1';
7
+ export const TARGET_DSH_VERSION = '0.1.5-rc.2';
8
8
  export const TARGET_DSH_SPEC = `${DSH_CLI_PACKAGE}@${TARGET_DSH_VERSION}`;
9
9
 
10
10
  // Retained-cohort support: when a release's manifest pins an older DSH
@@ -26,10 +26,21 @@ function pathInside(root, relativePath) {
26
26
  return current;
27
27
  }
28
28
 
29
+ // One canonical session artifact path. The jsonl backend keeps one immutable
30
+ // file per Session format generation: `session.jsonl` for version 0 and
31
+ // `session.vN.jsonl` (N >= 1) after that, each optionally zstd-compressed.
32
+ // Sharing the shape between the inventory and the archive guard keeps both in
33
+ // step when the backend's naming changes.
34
+ export const SESSION_ARTIFACT_PATTERN = /^harness\/sessions\/[^/]+\/[^/]+\/session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/;
35
+
36
+ export function isSessionArtifactPath(relativePath, sessionId) {
37
+ return typeof relativePath === 'string'
38
+ && SESSION_ARTIFACT_PATTERN.test(relativePath)
39
+ && relativePath.split('/')[3] === sessionId;
40
+ }
41
+
29
42
  function artifactPath(root, file) {
30
- if (!validId(file?.sessionId) || typeof file.relativePath !== 'string'
31
- || !/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(file.relativePath)
32
- || file.relativePath.split('/')[3] !== file.sessionId) fail('INVALID_ARTIFACT');
43
+ if (!validId(file?.sessionId) || !isSessionArtifactPath(file.relativePath, file.sessionId)) fail('INVALID_ARTIFACT');
33
44
  return pathInside(root, file.relativePath);
34
45
  }
35
46
 
@@ -6,6 +6,7 @@ import { acquireUpdateLock, releaseUpdateLock, createCrewSupervisor } from '../i
6
6
  import { readMaintenanceSession } from '../supervisor/restart-request.mjs';
7
7
  import { runHistoryOperation } from './operation.mjs';
8
8
  import { readHistoryState } from './state.mjs';
9
+ import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
9
10
 
10
11
  async function portFree() {
11
12
  return new Promise(resolve => {
@@ -40,7 +41,7 @@ export async function runProductionHistory({ id, recover = false } = {}) {
40
41
  const runtime = await response.json();
41
42
  return response.ok && runtime.ok === true && runtime.service === 'dsh-crew-hub' && runtime.profile === 'dsh-crew'
42
43
  && runtime.execution_plane === 'hub-3210' && runtime.listen_port === 3210 && runtime.protocol_version === 1
43
- && runtime.dsh_version === '0.1.2-rc.1' && typeof runtime.runtime_id === 'string' && runtime.runtime_id !== s.runtimeId;
44
+ && runtime.dsh_version === TARGET_DSH_VERSION && typeof runtime.runtime_id === 'string' && runtime.runtime_id !== s.runtimeId;
44
45
  } catch { return false; }
45
46
  },
46
47
  });
@@ -3,13 +3,14 @@ import { join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
6
+ import { TARGET_DSH_VERSION } from '../dsh-cohort.mjs';
6
7
  import { createHistoryService } from './service.mjs';
7
8
  import { registerHistoryHttp } from './http.mjs';
8
9
 
9
10
  export function registerRuntimeHistory(ctx) {
10
11
  const crewRoot = join(homedir(), '.config', 'dsh-crew');
11
12
  const runtime = getHubRuntimeIdentity();
12
- const owned = process.platform === 'win32' && runtime.dsh_version === '0.1.2-rc.1'
13
+ const owned = process.platform === 'win32' && runtime.dsh_version === TARGET_DSH_VERSION
13
14
  && resolve(process.env.DSH_HOME ?? '').toLowerCase() === resolve(join(crewRoot, 'harness')).toLowerCase();
14
15
  if (!owned) return () => {};
15
16
  return ctx.inject(['webServer', 'sessionPersistence', 'agents'], host => {
@@ -3,36 +3,65 @@ import { existsSync, readdirSync } from 'node:fs';
3
3
  import { relative } from 'node:path';
4
4
  import { planHistoryCleanup } from './cleanup-plan.mjs';
5
5
  import { installHistoryAdmissionGate } from './admission-gate.mjs';
6
- import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest } from './archive-store.mjs';
6
+ import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
7
7
  import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
8
8
 
9
9
  export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
10
10
  const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
11
11
  const plans = new Map();
12
12
  let entering = false;
13
+ // One row per materialized session: the header to archive and the artifact
14
+ // location the backend reported. The 0.1.5 jsonl backend resolves each
15
+ // session's current immutable generation itself and hands back a concrete
16
+ // path through `listArtifacts()`. The earlier backend listed snapshots and
17
+ // only hinted at a location, leaving the encoding to be probed below.
18
+ async function readInventory() {
19
+ if (typeof persistence?.listArtifacts === 'function') {
20
+ const artifacts = await persistence.listArtifacts();
21
+ if (!Array.isArray(artifacts)) throw Error('HISTORY_INVENTORY_INVALID');
22
+ return artifacts.map(({ header, path }) => {
23
+ if (typeof path !== 'string') throw Error('HISTORY_STORAGE_UNSUPPORTED');
24
+ return { header, located: path, resolved: true };
25
+ });
26
+ }
27
+ if (typeof persistence?.listSnapshots === 'function') {
28
+ const rows = await persistence.listSnapshots();
29
+ if (!Array.isArray(rows)) throw Error('HISTORY_INVENTORY_INVALID');
30
+ return rows.map(({ header }) => {
31
+ const location = persistence.locate(header);
32
+ if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
33
+ return { header, located: location.path, resolved: false };
34
+ });
35
+ }
36
+ throw Error('HISTORY_STORAGE_UNSUPPORTED');
37
+ }
13
38
  async function snapshot(options) {
14
- if (typeof persistence?.listSnapshots !== 'function' || typeof persistence?.locate !== 'function'
15
- || persistence.supportsRawArtifacts !== true) throw Error('HISTORY_STORAGE_UNSUPPORTED');
39
+ if (typeof persistence?.locate !== 'function') throw Error('HISTORY_STORAGE_UNSUPPORTED');
40
+ // Refuse only a backend that explicitly declares it exposes no per-session
41
+ // raw artifact. The 0.1.5 backend dropped the flag rather than setting it
42
+ // false, and the artifact path itself is validated below either way.
43
+ if (persistence.supportsRawArtifacts === false) throw Error('HISTORY_STORAGE_UNSUPPORTED');
16
44
  const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
17
45
  const store = decodeWorkspaceStore(bytes);
18
- const listed = await persistence.listSnapshots();
19
- if (!Array.isArray(listed) || listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
46
+ const listed = await readInventory();
47
+ if (listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
20
48
  let total = 0;
21
- const sessions = listed.map(({ header, revision }) => {
49
+ const sessions = listed.map(({ header, located, resolved }) => {
22
50
  if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
23
- const location = persistence.locate(header);
24
- if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
25
- let path = relative(crewRoot, location.path).replaceAll('\\', '/');
26
- if (!/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(path)
27
- || path.split('/')[3] !== header.id) throw Error('HISTORY_STORAGE_UNSUPPORTED');
28
- const plain = path.replace(/\.zstd$/, '');
29
- const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
30
- if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
31
- path = existing[0];
51
+ let path = relative(crewRoot, located).replaceAll('\\', '/');
52
+ if (!isSessionArtifactPath(path, header.id)) throw Error('HISTORY_STORAGE_UNSUPPORTED');
53
+ if (!resolved) {
54
+ // An unresolved location is a hint that may not name the encoding that
55
+ // actually exists, so exactly one candidate must be present on disk.
56
+ const plain = path.replace(/\.zstd$/, '');
57
+ const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
58
+ if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
59
+ path = existing[0];
60
+ }
32
61
  const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
33
62
  if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
34
63
  return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
35
- revision: JSON.stringify([revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
64
+ revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
36
65
  });
37
66
  // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
38
67
  const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
@@ -5,7 +5,7 @@
5
5
  // - HUB_PROTOCOL_VERSION changes only when Hub <-> MCP wire semantics become
6
6
  // incompatible.
7
7
  //
8
- // The Harness/DSH cohort version (e.g. 0.1.2-rc.1) is a THIRD, independent
8
+ // The Harness/DSH cohort version (e.g. 0.1.5-rc.2) is a THIRD, independent
9
9
  // domain: it identifies the installed @deepseek-ai/dsh package generation,
10
10
  // never the Crew release. Verifiers must compare the cohort against
11
11
  // dsh_version, never against runtime_version.
@@ -16,7 +16,7 @@
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { existsSync, readFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
- import { join } from 'node:path';
19
+ import { dirname, extname, join, resolve } from 'node:path';
20
20
  import {
21
21
  PRODUCTION_EXECUTION_PLANE,
22
22
  PRODUCTION_PROFILE,
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.2.0-rc.6';
39
+ export const RUNTIME_VERSION = '1.3.1';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -92,6 +92,43 @@ function normalizedCapabilities(value) {
92
92
  return [...new Set(value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim()))];
93
93
  }
94
94
 
95
+ function readVersionManifest(file) {
96
+ try {
97
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
98
+ if (parsed?.name !== '@deepseek-ai/dsh') return null;
99
+ return typeof parsed.version === 'string' && parsed.version.trim() ? parsed.version.trim() : null;
100
+ } catch { return null; }
101
+ }
102
+
103
+ /**
104
+ * Resolve the @deepseek-ai/dsh manifest belonging to an explicit CLI entry.
105
+ *
106
+ * This intentionally accepts only a package manifest whose name is the
107
+ * official Harness package. It lets a Crew-owned source checkout (for example
108
+ * the GitHub-only dsh-v0.1.3-alpha.1 tag) report its real cohort without ever
109
+ * reading the official ~/.dsh profile or trusting an arbitrary package.json.
110
+ */
111
+ function readExplicitCliVersion(entry) {
112
+ if (typeof entry !== 'string' || !entry.trim()) return null;
113
+ let cursor;
114
+ try { cursor = resolve(entry); } catch { return null; }
115
+ if (extname(cursor).toLowerCase() !== '.js') {
116
+ // A Windows shim normally lives at <root>/node_modules/.bin/dsh.cmd.
117
+ // Starting at its parent still finds the sibling @deepseek-ai/dsh tree.
118
+ cursor = dirname(cursor);
119
+ }
120
+ for (let depth = 0; depth < 12; depth += 1) {
121
+ const direct = readVersionManifest(join(cursor, 'package.json'));
122
+ if (direct) return direct;
123
+ const nested = readVersionManifest(join(cursor, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
124
+ if (nested) return nested;
125
+ const parent = dirname(cursor);
126
+ if (parent === cursor) break;
127
+ cursor = parent;
128
+ }
129
+ return null;
130
+ }
131
+
95
132
  export function getHubRuntimeIdentity() {
96
133
  return {
97
134
  service: 'dsh-crew-hub',
@@ -109,10 +146,15 @@ export function getHubRuntimeIdentity() {
109
146
  }
110
147
 
111
148
  // Installed Harness cohort version (the @deepseek-ai/dsh package under the
112
- // Crew-owned DSH home). Null when unreadable: callers treat null as
113
- // "unknown cohort", never as a match. This is the ONLY field cohort
114
- // verifiers may compare against TARGET_DSH_VERSION.
115
- export function readCrewDshVersion({ home = homedir() } = {}) {
149
+ // Crew-owned DSH home, or an explicit Crew-owned CLI source checkout). Null
150
+ // when unreadable: callers treat null as "unknown cohort", never as a match.
151
+ // This is the ONLY field cohort verifiers may compare against TARGET_DSH_VERSION.
152
+ export function readCrewDshVersion({ home = homedir(), env = process.env } = {}) {
153
+ // Only the Crew-scoped override is trusted here. A generic DSH_CLI may
154
+ // point at the official web profile and must never become Crew provenance.
155
+ const explicit = env?.DSH_CREW_DSH_CLI;
156
+ const explicitVersion = readExplicitCliVersion(explicit);
157
+ if (explicitVersion) return explicitVersion;
116
158
  try {
117
159
  const file = join(home, '.config', 'dsh-crew', 'harness', 'runtime', 'node_modules', '@deepseek-ai', 'dsh', 'package.json');
118
160
  if (!existsSync(file)) return null;
@@ -9,7 +9,95 @@ Set-StrictMode -Version Latest
9
9
  $ErrorActionPreference = 'Stop'
10
10
 
11
11
  $crewHome = Join-Path $env:USERPROFILE '.config\dsh-crew\harness'
12
- $dshCli = Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
12
+ $configuredDshCli = [string] $env:DSH_CREW_DSH_CLI
13
+ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { $configuredDshCli = '' }
14
+ if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
15
+ # Crew-managed npm runtime: the normal install path, written by dsh-crew
16
+ # update and carrying whatever cohort that release pinned. An explicit
17
+ # DSH_CREW_DSH_CLI always wins so an operator can still pin another entry.
18
+ $runtimeEntry = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\lib\bin.js'
19
+ if (Test-Path -LiteralPath $runtimeEntry -PathType Leaf) {
20
+ $configuredDshCli = $runtimeEntry
21
+ }
22
+ }
23
+ if (-not $configuredDshCli.Trim() -and $env:DSH_CREW_LAUNCHER_TEST_IMPORT -ne '1') {
24
+ # Crew-managed source cohort: a runtime-source-<label>.json sidecar names the
25
+ # CLI entry. The sidecar's recorded version must equal the checkout's own
26
+ # manifest, so a stale sidecar can never pin a half-updated tree.
27
+ foreach ($sidecar in @(Get-ChildItem -LiteralPath $crewHome -Filter 'runtime-source-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
28
+ try {
29
+ $metadata = Get-Content -LiteralPath $sidecar.FullName -Raw | ConvertFrom-Json
30
+ if ($metadata.managed_by -ne 'dsh-crew') { continue }
31
+ $candidate = [string] $metadata.cli_entry
32
+ if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
33
+ $sourceRoot = [IO.Path]::GetFullPath((Join-Path $crewHome ([IO.Path]::GetFileNameWithoutExtension($sidecar.Name))))
34
+ $candidateFull = [IO.Path]::GetFullPath($candidate)
35
+ $sourcePrefix = $sourceRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
36
+ $sourceManifest = Join-Path $sourceRoot 'apps\cli\package.json'
37
+ $sourceVersion = if (Test-Path -LiteralPath $sourceManifest -PathType Leaf) {
38
+ [string] (Get-Content -LiteralPath $sourceManifest -Raw | ConvertFrom-Json).version
39
+ } else { $null }
40
+ $candidateValid = $sourceVersion -and $sourceVersion -eq [string] $metadata.version `
41
+ -and $candidateFull.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase) `
42
+ -and (Test-Path -LiteralPath $candidateFull -PathType Leaf)
43
+ if ($candidateValid) {
44
+ $configuredDshCli = $candidateFull
45
+ break
46
+ }
47
+ } catch { }
48
+ }
49
+ }
50
+ $dshCli = if ($configuredDshCli.Trim()) {
51
+ try { [IO.Path]::GetFullPath($configuredDshCli) } catch { $configuredDshCli }
52
+ } else {
53
+ Join-Path $crewHome 'runtime\node_modules\.bin\dsh.cmd'
54
+ }
55
+ if ($configuredDshCli.Trim()) { $env:DSH_CREW_DSH_CLI = $dshCli }
56
+ # DSH_HOME that owns the selected CLI. The managed npm runtime shares the Crew
57
+ # home; a Crew-managed source cohort is its own home (its profiles, sessions and
58
+ # settings live inside that tree). Derived from the selected entry so an explicit
59
+ # DSH_CREW_DSH_CLI override resolves correctly too.
60
+ $dshHome = $crewHome
61
+ $sourceRootMatch = [regex]::Match($dshCli, '^(?<root>.+?[\\/]runtime-source-[^\\/]+)[\\/]')
62
+ if ($sourceRootMatch.Success) {
63
+ $candidateRoot = $sourceRootMatch.Groups['root'].Value
64
+ if (Test-Path -LiteralPath (Join-Path $candidateRoot 'profiles') -PathType Container) { $dshHome = $candidateRoot }
65
+ }
66
+ $dshCliIsNodeEntry = [IO.Path]::GetExtension($dshCli).ToLowerInvariant() -eq '.js'
67
+ $dshCommand = if ($dshCliIsNodeEntry) {
68
+ (Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
69
+ } else { $dshCli }
70
+
71
+ function Get-CrewDshManifest {
72
+ param([string] $Entry)
73
+ if (-not $Entry) { return $null }
74
+ try { $cursor = [IO.Path]::GetFullPath($Entry) } catch { return $null }
75
+ if ([IO.Path]::GetExtension($cursor).ToLowerInvariant() -eq '.js') {
76
+ $cursor = Split-Path -Parent $cursor
77
+ } else {
78
+ $cursor = Split-Path -Parent $cursor
79
+ }
80
+ for ($depth = 0; $depth -lt 12 -and $cursor; $depth += 1) {
81
+ $candidates = @(
82
+ (Join-Path $cursor 'package.json'),
83
+ (Join-Path $cursor 'node_modules\@deepseek-ai\dsh\package.json')
84
+ )
85
+ foreach ($candidate in $candidates) {
86
+ if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
87
+ try {
88
+ $manifest = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json
89
+ $knownOfficial = -not $configuredDshCli.Trim() -or $manifest.name -eq '@deepseek-ai/dsh'
90
+ if ($knownOfficial -and [string] $manifest.version) { return $manifest }
91
+ } catch { }
92
+ }
93
+ $parent = Split-Path -Parent $cursor
94
+ if ($parent -eq $cursor) { break }
95
+ $cursor = $parent
96
+ }
97
+ return $null
98
+ }
99
+
100
+ $dshManifest = Get-CrewDshManifest -Entry $dshCli
13
101
  $logRoot = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
14
102
  $launcherLog = Join-Path $logRoot 'dsh-crew-launcher.log'
15
103
  $startedAt = Get-Date
@@ -25,7 +113,7 @@ try {
25
113
  } finally { $hashStream.Dispose() }
26
114
  } catch { $launcherHelperHash = $null }
27
115
  $services = @(
28
- [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $crewHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
116
+ [pscustomobject]@{ Name = 'Crew backend'; Profile = 'dsh-crew'; Home = $dshHome; Port = 3210; Url = 'http://127.0.0.1:3210'; CrewOwned = $true; State = 'pending'; Process = $null; RootPid = $null; RootStartedAtUtcTicks = $null; ListenerPid = $null; ListenerStartedAtUtcTicks = $null; ConsecutiveFailures = 0; LastError = $null }
29
117
  )
30
118
 
31
119
  function Write-LaunchLog {
@@ -58,7 +146,7 @@ function Resolve-OfficialHarnessCommand {
58
146
  }
59
147
 
60
148
  function Test-OfficialHarnessListener {
61
- param([int] $OwnerPid, [pscustomobject] $Official)
149
+ param([int] $OwnerPid, [pscustomobject] $Official, [string] $Profile = 'web')
62
150
  if ($OwnerPid -le 0) { return $false }
63
151
  try {
64
152
  $owner = Get-CimInstance Win32_Process -Filter ('ProcessId={0}' -f $OwnerPid) -ErrorAction Stop
@@ -66,11 +154,154 @@ function Test-OfficialHarnessListener {
66
154
  $line = ([string] $owner.CommandLine).Replace('/', '\')
67
155
  $entry = [regex]::Escape($Official.Entry.Replace('/', '\'))
68
156
  $node = [regex]::Escape($Official.NodePath.Replace('/', '\'))
69
- $pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?web"?|--profile\s+"?web"?)(?:\s|$)'
157
+ $profileToken = [regex]::Escape($Profile)
158
+ $pattern = '^\s*(?:"?' + $node + '"?|"?node(?:\.exe)?"?)\s+"?' + $entry + '"?\s+(?:"?' + $profileToken + '"?|--profile\s+"?' + $profileToken + '"?)(?:\s|$)'
70
159
  return $line -match $pattern
71
160
  } catch { return $false }
72
161
  }
73
162
 
163
+ function Test-CrewDshCliPreflight {
164
+ if ($null -eq $dshManifest -or [string]::IsNullOrWhiteSpace([string] $dshManifest.version)) {
165
+ throw "The DSH CLI at $dshCli is not a verified @deepseek-ai/dsh package entry."
166
+ }
167
+ if ($dshCliIsNodeEntry -and -not (Test-Path -LiteralPath (Join-Path (Join-Path $dshHome 'profiles') 'web\package.json') -PathType Leaf)) {
168
+ throw ('The Harness web profile is missing under {0}. Run: dsh-crew update' -f $dshHome)
169
+ }
170
+ $versionArgs = if ($dshCliIsNodeEntry) { @($dshCli, '--version') } else { @('--version') }
171
+ $reported = try { (& $dshCommand @versionArgs 2>&1 | Out-String).Trim() } catch { '' }
172
+ if ($LASTEXITCODE -ne 0 -or $reported -ne [string] $dshManifest.version) {
173
+ throw ('DSH CLI preflight failed: manifest={0}, reported={1}' -f $dshManifest.version, ($reported -replace '\s+', ' '))
174
+ }
175
+ }
176
+
177
+ function Get-CrewWebSessionUrl {
178
+ param([string] $OutputLog = $null)
179
+ $logs = if ($OutputLog) {
180
+ @(Get-Item -LiteralPath $OutputLog -ErrorAction SilentlyContinue)
181
+ } else {
182
+ @(Get-ChildItem -LiteralPath $logRoot -Filter 'dsh-crew-web-*.out.log' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
183
+ }
184
+ foreach ($log in $logs) {
185
+ try {
186
+ $text = Get-Content -LiteralPath $log.FullName -Raw -ErrorAction Stop
187
+ $match = [regex]::Match($text, 'https?://127\.0\.0\.1:3080/\?token=[^\s\r\n]+')
188
+ if ($match.Success) { return $match.Value }
189
+ } catch { }
190
+ }
191
+ return $null
192
+ }
193
+
194
+ function Open-CrewBrowserUrl {
195
+ param([Parameter(Mandatory = $true)] [string] $Url)
196
+ $edge = Get-Command msedge.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
197
+ if (-not $edge) {
198
+ $edgeCandidates = @(
199
+ @(
200
+ (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
201
+ (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'),
202
+ (Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe')
203
+ ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) }
204
+ )
205
+ if ($edgeCandidates.Count -gt 0) { $edge = [pscustomobject]@{ Source = $edgeCandidates[0] } }
206
+ }
207
+ if ($edge) {
208
+ Start-Process -FilePath $edge.Source -ArgumentList @('--new-window', $Url) | Out-Null
209
+ return
210
+ }
211
+ Start-Process $Url | Out-Null
212
+ }
213
+
214
+ function Test-CrewWebSessionUrl {
215
+ param([string] $Url)
216
+ if (-not $Url) { return $false }
217
+ try {
218
+ # The token endpoint intentionally answers with a 303 + Set-Cookie and
219
+ # redirects to `/`; do not follow it here or PowerShell loses the cookie
220
+ # and falsely reports the authenticated page as 404.
221
+ $response = Invoke-WebRequest -UseBasicParsing -MaximumRedirection 0 -Uri $Url -TimeoutSec 3
222
+ return [int] $response.StatusCode -ge 200 -and [int] $response.StatusCode -lt 400
223
+ } catch {
224
+ $status = if ($_.Exception.Response) { [int] $_.Exception.Response.StatusCode } else { 0 }
225
+ return $status -in @(301, 302, 303, 307, 308)
226
+ }
227
+ }
228
+
229
+ function Open-CrewManagedFrontend {
230
+ param([int] $TimeoutSeconds = 90)
231
+ # The 3080 frontend boots from the same Crew-managed Harness entry as 3210,
232
+ # against that entry's own DSH_HOME (npm runtime -> Crew home, source cohort
233
+ # -> its own tree). No official ~/.dsh state is read or written here.
234
+ $managedHome = $dshHome
235
+ $managedProfile = 'web'
236
+ $profileManifest = Join-Path (Join-Path $managedHome 'profiles') ('{0}\package.json' -f $managedProfile)
237
+ if (-not $dshCliIsNodeEntry -or -not (Test-Path -LiteralPath $profileManifest -PathType Leaf)) {
238
+ return $false
239
+ }
240
+ $official = [pscustomobject]@{ NodePath = $dshCommand; Entry = $dshCli; Profile = $managedProfile }
241
+ $frontend = Get-OfficialFrontendOverlay
242
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
243
+ $locked = $false
244
+ try {
245
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
246
+ if (-not $locked) { throw 'Another desktop launch is still starting the Crew-managed Harness frontend.' }
247
+ $port = Get-PortState -Port 3080
248
+ if ($port.State -eq 'unknown') { throw $port.Error }
249
+ if ($port.State -eq 'occupied') {
250
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
251
+ throw 'Port 3080 is occupied by a non-Crew-managed process; it was left untouched.'
252
+ }
253
+ if (-not (Test-OfficialWebReady)) { throw 'The Crew-managed 3080 frontend is not ready; its process was left running.' }
254
+ $existingUrl = Get-CrewWebSessionUrl
255
+ if ($existingUrl -and (Test-CrewWebSessionUrl -Url $existingUrl)) {
256
+ Open-CrewBrowserUrl -Url $existingUrl
257
+ Write-LaunchLog 'Opened the existing Crew-managed Harness frontend on 3080 with its session URL.'
258
+ return $true
259
+ }
260
+ Write-LaunchLog 'The existing Crew-managed 3080 session URL is stale; restarting the verified listener.' 'WARN'
261
+ Stop-Process -Id $port.Pid -Force -ErrorAction Stop
262
+ $freeDeadline = (Get-Date).AddSeconds(5)
263
+ do { Start-Sleep -Milliseconds 250; $port = Get-PortState -Port 3080 } while ($port.State -ne 'free' -and (Get-Date) -lt $freeDeadline)
264
+ if ($port.State -ne 'free') { throw 'The stale Crew-managed 3080 listener did not release the port.' }
265
+ }
266
+
267
+ $stamp = '{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmssfff'), $PID
268
+ $stdout = Join-Path $logRoot ('dsh-crew-web-{0}.out.log' -f $stamp)
269
+ $stderr = Join-Path $logRoot ('dsh-crew-web-{0}.err.log' -f $stamp)
270
+ $previousHome = $env:DSH_HOME
271
+ $previousCli = $env:DSH_CREW_DSH_CLI
272
+ try {
273
+ $env:DSH_HOME = $managedHome
274
+ $env:DSH_CREW_DSH_CLI = $dshCli
275
+ $arguments = @($dshCli, '--profile', $managedProfile, '--patch', ('"{0}"' -f $frontend.Path), '--host', '127.0.0.1', '--port', '3080')
276
+ $process = Start-Process -FilePath $dshCommand -ArgumentList $arguments -WindowStyle Hidden -PassThru `
277
+ -RedirectStandardOutput $stdout -RedirectStandardError $stderr
278
+ } finally {
279
+ $env:DSH_HOME = $previousHome
280
+ $env:DSH_CREW_DSH_CLI = $previousCli
281
+ }
282
+ Write-LaunchLog ('Started Crew-managed Harness on 3080; PID={0}. Logs: {1}, {2}' -f $process.Id, $stdout, $stderr)
283
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
284
+ do {
285
+ $port = Get-PortState -Port 3080
286
+ if ($port.State -eq 'occupied') {
287
+ if (-not (Test-OfficialHarnessListener -OwnerPid $port.Pid -Official $official -Profile $managedProfile)) {
288
+ throw 'Port 3080 became occupied by an unverified process; it was left untouched.'
289
+ }
290
+ if (Test-OfficialWebReady) {
291
+ $startedUrl = Get-CrewWebSessionUrl -OutputLog $stdout
292
+ if ($startedUrl) { Open-CrewBrowserUrl -Url $startedUrl; return $true }
293
+ }
294
+ }
295
+ if ($process.HasExited) { throw ('Crew-managed Harness exited before readiness. Diagnostic log: {0}' -f $stderr) }
296
+ Start-Sleep -Milliseconds 500
297
+ } while ((Get-Date) -lt $deadline)
298
+ throw ('The Crew-managed Harness did not become ready within {0}s; inspect {1}.' -f $TimeoutSeconds, $stderr)
299
+ } finally {
300
+ if ($locked) { $mutex.ReleaseMutex() }
301
+ $mutex.Dispose()
302
+ }
303
+ }
304
+
74
305
  function Test-OfficialWebReady {
75
306
  try {
76
307
  $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:3080/' -TimeoutSec 2
@@ -82,28 +313,28 @@ function Test-OfficialWebReady {
82
313
  }
83
314
  }
84
315
 
85
- function Get-OfficialFrontendOverlay {
86
- $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
87
- $path = Join-Path $frontendRoot 'official-web.patch.json'
88
- if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
89
- # Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
90
- # direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
91
- # instead of indexing the JSON property. Flatten both PowerShell generations
92
- # explicitly and read properties through PSObject so StrictMode reports a
93
- # useful validation error instead of "property id cannot be found".
94
- $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
95
- $patch = @($parsed | ForEach-Object { $_ })
96
- $root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
97
- $insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
98
- $insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
99
- $inserts = @($insertValue | ForEach-Object { $_ })
100
- $bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
101
- $idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
102
- $nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
103
- if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
104
- throw 'Crew frontend overlay has an unexpected structure.'
105
- }
106
- $uri = [Uri] ([string] $nameProperty.Value)
316
+ function Get-OfficialFrontendOverlay {
317
+ $frontendRoot = Join-Path $env:USERPROFILE '.config\dsh-crew\frontend'
318
+ $path = Join-Path $frontendRoot 'official-web.patch.json'
319
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'Crew frontend overlay is missing. Run dsh-crew update with the current GitHub candidate.' }
320
+ # Windows PowerShell 5.1 keeps a JSON root array as one pipeline object;
321
+ # direct `$patch[0].insert[0]` therefore resolves `insert` as IList.Insert
322
+ # instead of indexing the JSON property. Flatten both PowerShell generations
323
+ # explicitly and read properties through PSObject so StrictMode reports a
324
+ # useful validation error instead of "property id cannot be found".
325
+ $parsed = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
326
+ $patch = @($parsed | ForEach-Object { $_ })
327
+ $root = if ($patch.Count -eq 1) { $patch[0] } else { $null }
328
+ $insertProperty = if ($null -ne $root) { $root.PSObject.Properties['insert'] } else { $null }
329
+ $insertValue = if ($null -ne $insertProperty) { $insertProperty.Value } else { $null }
330
+ $inserts = @($insertValue | ForEach-Object { $_ })
331
+ $bridge = if ($inserts.Count -eq 1) { $inserts[0] } else { $null }
332
+ $idProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['id'] } else { $null }
333
+ $nameProperty = if ($null -ne $bridge) { $bridge.PSObject.Properties['name'] } else { $null }
334
+ if ($patch.Count -ne 1 -or $inserts.Count -ne 1 -or $null -eq $idProperty -or [string] $idProperty.Value -ne 'dsh-crew-official-web-bridge' -or $null -eq $nameProperty) {
335
+ throw 'Crew frontend overlay has an unexpected structure.'
336
+ }
337
+ $uri = [Uri] ([string] $nameProperty.Value)
107
338
  if (-not $uri.IsFile) { throw 'Crew frontend must load from its local snapshot.' }
108
339
  $entry = [IO.Path]::GetFullPath($uri.LocalPath)
109
340
  $prefix = [IO.Path]::GetFullPath((Join-Path $frontendRoot 'revisions')) + [IO.Path]::DirectorySeparatorChar
@@ -126,6 +357,10 @@ function Test-OfficialFrontendAttached {
126
357
 
127
358
  function Open-OfficialFrontend {
128
359
  param([int] $TimeoutSeconds = 90)
360
+ # Preferred path: the Crew-managed Harness serves 3080 with the Crew panel
361
+ # already loaded. The legacy fallback below (separate official install in
362
+ # ~/.dsh) is only reached when no Crew-managed frontend is available.
363
+ if (Open-CrewManagedFrontend -TimeoutSeconds $TimeoutSeconds) { return }
129
364
  $official = Resolve-OfficialHarnessCommand
130
365
  $frontend = Get-OfficialFrontendOverlay
131
366
  $mutex = New-Object System.Threading.Mutex($false, 'Local\DSHCrewOfficialFrontendLauncher')
@@ -196,12 +431,10 @@ function Get-HealthState {
196
431
  # the process cannot be proven to match the tree it will next boot.
197
432
  $expectedDshVersion = $null
198
433
  $diskReadable = $false
199
- $runtimeManifest = Join-Path $crewHome 'runtime\node_modules\@deepseek-ai\dsh\package.json'
200
- if (Test-Path -LiteralPath $runtimeManifest -PathType Leaf) {
201
- try {
202
- $expectedDshVersion = (Get-Content -LiteralPath $runtimeManifest -Raw | ConvertFrom-Json).version
203
- if ($expectedDshVersion) { $diskReadable = $true }
204
- } catch { $expectedDshVersion = $null }
434
+ $activeDshManifest = Get-CrewDshManifest -Entry $dshCli
435
+ if ($null -ne $activeDshManifest -and [string] $activeDshManifest.version) {
436
+ $expectedDshVersion = [string] $activeDshManifest.version
437
+ $diskReadable = $true
205
438
  }
206
439
  $cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
207
440
  if ($response.ok -eq $true -and $version -and $cohortMatches) {
@@ -814,7 +1047,8 @@ function Start-CrewService {
814
1047
  try {
815
1048
  $env:DSH_HOME = $Service.Home
816
1049
  $arguments = @('--profile', $Service.Profile, '--host', '127.0.0.1', '--port', [string] $Service.Port, '--no-open')
817
- $process = Start-Process -FilePath $dshCli -ArgumentList $arguments -WindowStyle Hidden -PassThru `
1050
+ $launchArguments = if ($dshCliIsNodeEntry) { @($dshCli) + $arguments } else { $arguments }
1051
+ $process = Start-Process -FilePath $dshCommand -ArgumentList $launchArguments -WindowStyle Hidden -PassThru `
818
1052
  -RedirectStandardOutput $stdout -RedirectStandardError $stderr
819
1053
  $Service.Process = $process
820
1054
  $Service.RootPid = $process.Id
@@ -1034,14 +1268,14 @@ if ($env:DSH_CREW_LAUNCHER_TEST_IMPORT -eq '1') { return }
1034
1268
  try {
1035
1269
  New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
1036
1270
  Write-LaunchLog ('Launcher started; mode={0}; user={1}' -f $Mode, $env:USERNAME)
1037
- if ($Mode -eq 'open') { Open-OfficialFrontend }
1038
-
1039
1271
  if (-not (Test-Path -LiteralPath $dshCli -PathType Leaf)) {
1040
- throw "DSH CLI was not found at $dshCli. Run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
1272
+ throw "DSH CLI was not found at $dshCli. Configure DSH_CREW_DSH_CLI to a Crew-owned official CLI entry, or run: npm install -g @ran-sh/dsh-crew@latest; dsh-crew update"
1041
1273
  }
1274
+ Test-CrewDshCliPreflight
1042
1275
  if (-not (Test-Path -LiteralPath (Join-Path $crewHome 'profiles\dsh-crew\package.json') -PathType Leaf)) {
1043
1276
  throw "The isolated dsh-crew profile is missing under $crewHome. Run: dsh-crew update"
1044
1277
  }
1278
+ if ($Mode -eq 'open') { Open-OfficialFrontend }
1045
1279
  # Background/watch modes remain independent of the official frontend.
1046
1280
 
1047
1281
  if ($Mode -eq 'watch') {
package/worker.cordis.yml CHANGED
@@ -20,15 +20,11 @@
20
20
  mode: !!js process.env.DSH_SANDBOX_MODE ?? 'workspace-write'
21
21
  workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd()
22
22
 
23
- - id: fs-local
24
- config:
25
- cwd: !!js process.env.DSH_CWD ?? process.cwd()
26
-
27
23
  - id: system-prompt
28
24
  config:
29
25
  includeHarnessIdentity: false
30
26
  includeRuntimeContext: false
31
- persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a focused coding worker. Complete the assigned task, then summarize what you did in your final message.'
27
+ personaPrefix: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a focused coding worker. Complete the assigned task, then summarize what you did in your final message.'
32
28
 
33
29
  - id: agent-loop
34
30
  config:
@@ -40,6 +36,14 @@
40
36
  compression: zstd
41
37
 
42
38
  - insert:
39
+ # sdk-minimal dropped its own fs-local row in the 0.1.5 cohort, but the
40
+ # Crew tool suite still injects the `fs` service, so Crew owns the row and
41
+ # keeps the worker's editor rooted at the task workspace.
42
+ - id: fs-local
43
+ name: '@deepseek-ai/dsh-fs-local'
44
+ config:
45
+ cwd: !!js process.env.DSH_CWD ?? process.cwd()
46
+
43
47
  - id: fs-observation-policy
44
48
  name: '@deepseek-ai/dsh-fs-observation-policy'
45
49