@ran-sh/dsh-crew 2.2.8 → 2.2.10

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": "2.2.8",
3
+ "version": "2.2.10",
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": "2.2.8",
3
+ "version": "2.2.10",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -1247,26 +1247,35 @@ export function installHudSegment({ home = homedir() } = {}) {
1247
1247
  return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), 'statusLine: claude-hud now runs worker-segment.sh via --extra-cmd'] };
1248
1248
  }
1249
1249
 
1250
- // Crew-owned directories, identified by path SEGMENT rather than by substring: the
1251
- // payload tree lives under `.../dsh-crew/...` and the pre-rename identity used
1252
- // `dsh-workers`. A user path that merely contains those words (`my-dsh-crew-notes`)
1253
- // is not Crew's, and nothing here may treat it as if it were.
1254
- function crewOwnedDirectory(value) {
1255
- if (typeof value !== 'string' || !value.trim()) return false;
1256
- return value.split(/[\\/]+/).filter(Boolean).some((segment) => {
1257
- const lower = segment.toLowerCase();
1258
- return lower === 'dsh-crew' || lower === 'dsh-workers';
1259
- });
1250
+ // The paths Crew has recorded as its own in this Claude host: the marketplace records
1251
+ // it wrote (under the current and pre-rename names, in settings and in the CLI's own
1252
+ // cache) and the constant an older installer used. Only these are grounds for removing
1253
+ // anything: a directory that merely happens to be named `dsh-crew` belongs to whoever
1254
+ // made it, the module's own checkout is not evidence (an uninstall may run from a
1255
+ // checkout this host never recorded), and a path that is not recorded here is left
1256
+ // alone.
1257
+ function crewOwnedRoots({ home, settings, markets, installed }) {
1258
+ const roots = [];
1259
+ const push = (value) => { if (typeof value === 'string' && value.trim() && !roots.includes(value.trim())) roots.push(value.trim()); };
1260
+ for (const name of CREW_CLAUDE_MARKETPLACES) {
1261
+ push(settings?.extraKnownMarketplaces?.[name]?.source?.path);
1262
+ push(markets?.[name]?.source?.path);
1263
+ push(markets?.[name]?.installLocation);
1264
+ }
1265
+ for (const key of CREW_CLAUDE_PLUGIN_KEYS) {
1266
+ const entries = Array.isArray(installed?.plugins?.[key]) ? installed.plugins[key] : [installed?.plugins?.[key]];
1267
+ for (const entry of entries) push(entry?.installPath);
1268
+ }
1269
+ push(join(home, '.config', 'dsh-crew', 'marketplace'));
1270
+ return roots;
1260
1271
  }
1261
1272
 
1262
- // Only the status line `--statusline` installs is Crew's to remove: it points at
1263
- // Crew's own script, in Crew's own directory, in the exact shape the installer
1264
- // writes (`bash <root>/statusline/statusline.sh`). A command that merely mentions
1265
- // these words belongs to whoever wrote it.
1266
- function crewOwnedStatusLine(value) {
1273
+ function crewOwnedStatusLine(value, roots) {
1267
1274
  const command = typeof value?.command === 'string' ? value.command.trim() : '';
1268
- const match = /^bash\s+(.+[\\/])statusline[\\/]statusline\.sh$/i.exec(command);
1269
- return match ? crewOwnedDirectory(match[1]) : false;
1275
+ const match = /^bash\s+(.+)$/i.exec(command);
1276
+ if (!match) return false;
1277
+ const path = normalizedPath(match[1].trim());
1278
+ return path !== null && roots.some((root) => normalizedPath(join(root, 'statusline', 'statusline.sh')) === path);
1270
1279
  }
1271
1280
 
1272
1281
  // The claude CLI records, per marketplace and plugin, what it materialized, in
@@ -1304,16 +1313,19 @@ function removeCrewClaudeCacheRecords({ home, actions }) {
1304
1313
  export function uninstallClaudeCode({ home = homedir() } = {}) {
1305
1314
  const actions = [];
1306
1315
  const settingsFile = join(home, '.claude', 'settings.json');
1316
+ const cacheDir = join(home, '.claude', 'plugins');
1307
1317
  const settings = readJson(settingsFile, null);
1318
+ const markets = readJson(join(cacheDir, 'known_marketplaces.json'), null);
1319
+ const installed = readJson(join(cacheDir, 'installed_plugins.json'), null);
1320
+ const roots = crewOwnedRoots({ home, settings, markets, installed });
1308
1321
  if (settings) {
1309
1322
  backup(settingsFile);
1310
- const mpDir = join(home, '.config', 'dsh-crew', 'marketplace');
1311
1323
  if (Array.isArray(settings.extraKnownMarketplaces)) {
1312
- // Legacy array shape: entries are `{ path }` records this installer wrote under
1313
- // one of its own names, including the pre-rename one. Anything else in that
1314
- // array belongs to another plugin and stays.
1324
+ // Legacy array shape: an entry is removed only when it IS one of the paths Crew
1325
+ // recorded as its own. What the path is spelled with is not evidence — a
1326
+ // marketplace under someone else's `dsh-crew` directory stays.
1315
1327
  settings.extraKnownMarketplaces = settings.extraKnownMarketplaces
1316
- .filter((entry) => !crewOwnedDirectory(entry?.path) && entry?.path !== mpDir);
1328
+ .filter((entry) => !roots.some((root) => normalizedPath(entry?.path) === normalizedPath(root)));
1317
1329
  } else if (settings.extraKnownMarketplaces) {
1318
1330
  for (const name of CREW_CLAUDE_MARKETPLACES) delete settings.extraKnownMarketplaces[name];
1319
1331
  }
@@ -1326,7 +1338,7 @@ export function uninstallClaudeCode({ home = homedir() } = {}) {
1326
1338
  settings.permissions.allow = settings.permissions.allow.filter((rule) => typeof rule !== 'string'
1327
1339
  || !CREW_CLAUDE_PERMISSION_PREFIXES.some((prefix) => rule.startsWith(prefix)));
1328
1340
  }
1329
- if (crewOwnedStatusLine(settings.statusLine)) {
1341
+ if (crewOwnedStatusLine(settings.statusLine, roots)) {
1330
1342
  delete settings.statusLine;
1331
1343
  actions.push('statusLine: removed (Crew-installed)');
1332
1344
  }
@@ -2785,6 +2785,9 @@ export async function npxLogs({ args = [], log = console.log } = {}) {
2785
2785
  const keep = Number(args[1]);
2786
2786
  const result = pruneCrewTempLogs(Number.isInteger(keep) && keep > 0 ? { keepRuns: keep } : {});
2787
2787
  log(`✓ Crew diagnostics pruned: removed ${result.removed.length} file(s); kept ${result.kept.length} run(s)`);
2788
+ if (result.failed?.length) {
2789
+ log(`- ${result.failed.length} file(s) could not be deleted (a running process may hold them); they are counted in neither number and the next prune retries`);
2790
+ }
2788
2791
  return { ok: true, ...result };
2789
2792
  }
2790
2793
 
package/src/log-prune.mjs CHANGED
@@ -2,9 +2,12 @@
2
2
  // new dsh-crew-dsh-crew-3210-<stamp>.out/.err.log pair, every desktop launch a
3
3
  // dsh-crew-web-*, every official launch a dsh-official-web-*, and the launcher
4
4
  // appends to dsh-crew-launcher.log forever. They are the only post-mortem evidence
5
- // for a crash-before-readiness, so the rule keeps the most recent runs instead of
6
- // deleting everything: per family, the newest `keep` runs survive, plus anything
7
- // written within the recency guard (a running process owns its own log files).
5
+ // for a crash-before-readiness, so the rule bounds them instead of deleting them all.
6
+ // A run survives when it is BOTH within its family's newest `keep` AND younger than
7
+ // the age bound; exceeding either one removes it, unless it was written within the
8
+ // recency guard (a running process owns its own log files). The newest `keep` runs
9
+ // therefore do not survive unconditionally — only a run that is also inside the age
10
+ // bound does, which is what keeps a burst of starts from filling the directory.
8
11
 
9
12
  import { readdirSync, rmSync, statSync } from 'node:fs';
10
13
  import { join } from 'node:path';
@@ -23,7 +26,14 @@ const LOG_FAMILIES = [
23
26
  { prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log', keep: 10 },
24
27
  { prefix: 'dsh-crew-web-', suffix: '.out.log', keep: 20 },
25
28
  { prefix: 'dsh-official-web-', suffix: '.out.log', keep: 20 },
26
- ];
29
+ ].map((family) => ({
30
+ ...family,
31
+ // An `.err.log` whose `.out.log` sibling is gone (or was never written) is still a
32
+ // Crew diagnostic, so each family recognizes its error files as prunable runs too.
33
+ errStamp: (name) => name.startsWith(family.prefix) && name.endsWith('.err.log')
34
+ ? name.slice(family.prefix.length, name.length - '.err.log'.length) || null
35
+ : null,
36
+ }));
27
37
 
28
38
  // `dsh-crew-<profile>-<port>-<stamp>.out.log` -> the stamp identifies one run, and
29
39
  // its .err.log sibling must live or die with it.
@@ -48,14 +58,28 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
48
58
  const runs = new Map();
49
59
  for (const name of names) {
50
60
  const stamp = runStamp(name, family);
51
- if (!stamp) continue;
61
+ if (stamp) {
62
+ const file = join(tempDir, name);
63
+ let mtimeMs = 0;
64
+ try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
65
+ const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
66
+ run.files.push(name);
67
+ run.newest = Math.max(run.newest, mtimeMs);
68
+ runs.set(stamp, run);
69
+ continue;
70
+ }
71
+ // An orphan `.err.log` (its `.out.log` sibling already pruned or never written)
72
+ // is still a Crew diagnostic: prune it on its own stamp so a half-written run
73
+ // cannot outlive every bound.
74
+ const errStamp = family.errStamp(name);
75
+ if (!errStamp) continue;
52
76
  const file = join(tempDir, name);
53
77
  let mtimeMs = 0;
54
78
  try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
55
- const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
79
+ const run = runs.get(errStamp) ?? { stamp: errStamp, files: [], newest: 0 };
56
80
  run.files.push(name);
57
81
  run.newest = Math.max(run.newest, mtimeMs);
58
- runs.set(stamp, run);
82
+ runs.set(errStamp, run);
59
83
  }
60
84
  const ordered = [...runs.values()].sort((left, right) => right.newest - left.newest);
61
85
  const keep = keepOverride ?? family.keep;
@@ -70,15 +94,23 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
70
94
  // bound exists to stop. A burst of eleven young runs still keeps ten.
71
95
  if ((tooOld || tooMany) && !tooRecentToTouch) {
72
96
  for (const name of run.files) removed.push(name);
73
- const errName = run.files[0].replace(/\.out\.log$/, '.err.log');
74
- if (names.includes(errName)) removed.push(errName);
75
97
  } else {
76
98
  kept.push(run.stamp);
77
99
  }
78
100
  });
79
101
  }
80
- for (const name of removed) {
81
- try { rmSync(join(tempDir, name), { force: true }); } catch { /* a locked file stays until the next pass */ }
102
+ // `removed` lists what is actually gone. A locked file (Windows holds a log open
103
+ // while its process runs) is reported in `failed` instead of pretending a deletion
104
+ // happened: the next pass tries again, and the operator is not told a number that
105
+ // is not true.
106
+ const failed = [];
107
+ for (const name of [...new Set(removed)]) {
108
+ try { rmSync(join(tempDir, name), { force: true }); } catch { failed.push(name); }
82
109
  }
83
- return { ok: true, removed: [...new Set(removed)].sort(), kept: kept.sort() };
110
+ return {
111
+ ok: failed.length === 0 ? true : 'partial',
112
+ removed: removed.filter((name) => !failed.includes(name)).sort(),
113
+ kept: kept.sort(),
114
+ ...(failed.length ? { failed: failed.sort() } : {}),
115
+ };
84
116
  }
@@ -76,8 +76,17 @@ function normalizeEvidence(row, evidence) {
76
76
  // flip the row is how "this route is unused" would turn into "this route passed".
77
77
  // What was reported is kept as metadata rather than thrown away.
78
78
  if (row.status === 'NOT_APPLICABLE') {
79
- if (typeof evidence.status !== 'string') return row;
80
- return { ...row, reported_evidence: { status: evidence.status, ...(typeof evidence.reason_code === 'string' && evidence.reason_code.trim() ? { reason_code: evidence.reason_code.trim() } : {}), ...(typeof evidence.evidence_source === 'string' && evidence.evidence_source.trim() ? { evidence_source: evidence.evidence_source.trim() } : {}) } };
79
+ // The same sanitized fields an applied record keeps, so "the observation is not
80
+ // lost" is a statement the code supports rather than a claim about three of five.
81
+ const source = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
82
+ const reported = {
83
+ status: evidence.status,
84
+ ...(source(evidence.reason_code) ? { reason_code: source(evidence.reason_code) } : {}),
85
+ ...(source(evidence.evidence_source) ? { evidence_source: source(evidence.evidence_source) } : {}),
86
+ ...(source(evidence.evidence_ref) ? { evidence_ref: source(evidence.evidence_ref) } : {}),
87
+ ...(source(evidence.detail_code) && /^[A-Z][A-Z0-9_]{0,127}$/u.test(evidence.detail_code) ? { detail_code: evidence.detail_code } : {}),
88
+ };
89
+ return { ...row, reported_evidence: reported };
81
90
  }
82
91
  const reason = typeof evidence.reason_code === 'string' && evidence.reason_code.trim()
83
92
  ? evidence.reason_code.trim()
@@ -195,7 +204,7 @@ export function buildReadinessMatrix({
195
204
  const selectionKnown = typeof workerSelection?.provider === 'string' && workerSelection.provider.length > 0;
196
205
  const deepseekRouteSelected = workerProviderMode === 'deepseek-official'
197
206
  || workerSelection?.provider === 'deepseek-official';
198
- if (workerProviderMode !== null && selectionKnown && !deepseekRouteSelected) {
207
+ if (workerProviderMode === 'follow-dsh' && selectionKnown && !deepseekRouteSelected) {
199
208
  for (const id of POLICY_NOT_APPLICABLE_ROWS) {
200
209
  rows[id] = baseRow(id, 'real-execution', 'NOT_APPLICABLE', READINESS_REASON_CODES.WORKER_PROVIDER_FOLLOWS_DSH, 'runtime-policy');
201
210
  }
@@ -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 = '2.2.8';
39
+ export const RUNTIME_VERSION = '2.2.10';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -108,12 +108,14 @@ function projectModelRole({ role, selected, health, jobs, runtime, now, executio
108
108
  if (healthCurrent && matchingHealth.state === 'callable') {
109
109
  return { state: 'CALLABLE', reason_code: matchingHealth.reason_code ?? 'PROVIDER_CALLABLE', selected, observed_at: matchingHealth.observed_at ?? null, expires_at: expiresAt, source: 'provider_health', last_success: null };
110
110
  }
111
- // What proves the route ran is a provider response, not a passing task contract:
112
- // a job that reached `done` with its execution completed has demonstrably called
113
- // the selected model even when the task itself came back `partial`. Failed and
114
- // cancelled work never reaches this predicate, and neither does a job that errored
115
- // before the model answered (its execution_status is `failed`, not `completed`).
116
- const responded = (job) => job?.task_status === 'success' || job?.execution_status === 'completed';
111
+ // Callability asks for positive proof that the provider answered, so the ledger
112
+ // fact that proves it is the one that says the execution ended normally: a job the
113
+ // worker recorded as `done` whose `execution_status` is `completed`. The task-level
114
+ // verdict is deliberately NOT part of this — a `partial` task still had a provider
115
+ // response — and a job whose execution failed or was never recorded is not evidence
116
+ // of a response, however its task was later classified. A recovery probe or a real
117
+ // run is what a route without such a job needs.
118
+ const responded = (job) => job?.execution_status === 'completed';
117
119
  const completed = (Array.isArray(jobs) ? jobs : [])
118
120
  .filter((job) => job?.role === role && job?.provider === selected.provider && job?.model === selected.model
119
121
  && job?.status === 'done' && responded(job)
@@ -249,7 +251,12 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
249
251
  const roles = {};
250
252
  for (const role of ['worker', 'reviewer']) {
251
253
  if (enabled_roles?.[role] === false) {
252
- roles[role] = { state: 'NOT_APPLICABLE', reason_code: 'ROLE_DISABLED', selected: null, last_success: null };
254
+ // The hub may still record a selected route for a role this session disables;
255
+ // keeping that route here (rather than nulling it) is what lets the canonical
256
+ // validation below accept the reprojection. Nulling it manufactured a
257
+ // selection mismatch against the unchanged outer snapshot and read the whole
258
+ // contract as DEGRADED for an intentionally disabled role.
259
+ roles[role] = { state: 'NOT_APPLICABLE', reason_code: 'ROLE_DISABLED', selected: snapshot[role]?.selected ?? null, last_success: null };
253
260
  } else if (sourceProjection.enabled_roles?.[role] === true) {
254
261
  roles[role] = structuredClone(sourceProjection.roles[role]);
255
262
  } else {
@@ -273,9 +280,14 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
273
280
  projection: projected,
274
281
  runtime: snapshot.runtime,
275
282
  expectedEnabledRoles: projected.enabled_roles,
283
+ // Expected selections are the routes the snapshot RECORDED, for disabled roles
284
+ // too: the canonical rule is "a disabled role keeps its route", and expecting
285
+ // null here would contradict the very projection this function just built — the
286
+ // mismatch then discarded a healthy worker's evidence and read the session as
287
+ // degraded for a role that was intentionally switched off.
276
288
  expectedSelections: {
277
- worker: enabled_roles.worker === true ? snapshot.worker?.selected ?? null : null,
278
- reviewer: enabled_roles.reviewer === true ? snapshot.reviewer?.selected ?? null : null,
289
+ worker: snapshot.worker?.selected ?? null,
290
+ reviewer: snapshot.reviewer?.selected ?? null,
279
291
  },
280
292
  now,
281
293
  });