@ran-sh/dsh-crew 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -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
  /**
@@ -13,6 +13,22 @@ function method(object, name) {
13
13
  }
14
14
  }
15
15
 
16
+ /**
17
+ * Whether one registered agent currently owns a running turn.
18
+ *
19
+ * Registration is not activity: the session controller resumes a session into
20
+ * the registry as soon as it opens it, and leaves it there after the turn ends
21
+ * with `status: 'idle'`. Treating a non-empty registry as "in use" therefore
22
+ * wedged cleanup permanently behind ACTIVE_SESSIONS on a machine whose web UI had
23
+ * ever opened a session. Only a `running` status means work is actually in
24
+ * flight; anything else (idle, or a status an older agent does not expose) is
25
+ * treated as not running, and `creating > 0` remains the independent guard for a
26
+ * creation that has not published yet.
27
+ */
28
+ function isRunning(agent) {
29
+ try { return agent?.status === 'running'; } catch { return false; }
30
+ }
31
+
16
32
  export function installHistoryAdmissionGate(agents, isMaintenancePending) {
17
33
  if (typeof agents?.create !== 'function' || typeof agents?.list !== 'function'
18
34
  || typeof isMaintenancePending !== 'function') throw new Error('HISTORY_ADMISSION_UNAVAILABLE');
@@ -40,7 +56,7 @@ export function installHistoryAdmissionGate(agents, isMaintenancePending) {
40
56
  if (entries.some(entry => method(agents, entry.name) !== entry.wrapper)) return false;
41
57
  try {
42
58
  const live = agents.list();
43
- return Array.isArray(live) && creating === 0 && live.length === 0;
59
+ return Array.isArray(live) && creating === 0 && !live.some(isRunning);
44
60
  } catch { return false; }
45
61
  },
46
62
  dispose() {
@@ -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
 
@@ -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 }));
@@ -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.3.0';
39
+ export const RUNTIME_VERSION = '1.3.2';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([