@ran-sh/dsh-crew 2.0.9 → 2.0.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.0.9",
3
+ "version": "2.0.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.0.9",
3
+ "version": "2.0.10",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -23,6 +23,11 @@ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
23
23
  const CLAUDE_PLUGIN_TIMEOUT_MS = 300_000;
24
24
  const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
25
25
  const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
26
+ // The one scope this installer writes, and therefore the only scope whose record
27
+ // means "the integration is installed". Accepting any scope let a project-scope
28
+ // record stand in for a missing user-scope snapshot, so a failed install read as
29
+ // a current one.
30
+ const CLAUDE_PLUGIN_SCOPE = 'user';
26
31
  const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
27
32
  const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
28
33
 
@@ -200,7 +205,14 @@ function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 *
200
205
  try {
201
206
  add(join(root, '.claude-plugin', 'plugin.json'), join('.claude-plugin', 'plugin.json'));
202
207
  if (isFile(join(root, 'package.json'))) add(join(root, 'package.json'), 'package.json');
203
- for (const directory of ['agents', 'commands', 'src']) walk(join(root, directory), directory);
208
+ // What the plugin loads, not just what names it. `worker.cordis.yml` is
209
+ // required before any dispatch (`src/jobs.mjs` throws without it) and the
210
+ // statusline scripts are named by the settings this installer writes, so a
211
+ // snapshot missing either is not this release even when the manifest it came
212
+ // from is byte-identical — which is exactly how a stale snapshot used to read
213
+ // as current.
214
+ if (isFile(join(root, 'worker.cordis.yml'))) add(join(root, 'worker.cordis.yml'), 'worker.cordis.yml');
215
+ for (const directory of ['agents', 'commands', 'skills', 'src', 'statusline']) walk(join(root, directory), directory);
204
216
  return files;
205
217
  } catch { return null; }
206
218
  }
@@ -212,11 +224,12 @@ function sameManagedClaudeFiles(expectedRoot, snapshotRoot) {
212
224
  return expected !== null && snapshot !== null && JSON.stringify(snapshot) === JSON.stringify(expected);
213
225
  }
214
226
 
215
- function claudeSnapshotReady(home, root) {
227
+ function claudeSnapshotReady(home, root, { scope = null } = {}) {
216
228
  const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {});
217
229
  const record = installed?.plugins?.[PLUGIN_KEY];
218
230
  const entries = Array.isArray(record) ? record : [record];
219
- return entries.some((entry) => sameManagedClaudeFiles(root, entry?.installPath));
231
+ return entries.some((entry) => (scope === null || entry?.scope === scope)
232
+ && sameManagedClaudeFiles(root, entry?.installPath));
220
233
  }
221
234
 
222
235
  function claudePermissionsReady(settings) {
@@ -399,7 +412,10 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
399
412
  const claudeComponents = {
400
413
  enabled: claudeInstalled,
401
414
  marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
402
- snapshot: claudeSnapshotReady(home, effectiveRoot),
415
+ // User scope only: this installer writes that scope, so it is the one whose
416
+ // record means the integration is installed. Accepting any scope let a
417
+ // project-scope record make a missing user-scope snapshot read as present.
418
+ snapshot: claudeSnapshotReady(home, effectiveRoot, { scope: CLAUDE_PLUGIN_SCOPE }),
403
419
  permissions: claudePermissionsReady(settings),
404
420
  };
405
421
  const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
@@ -512,6 +528,9 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
512
528
  */
513
529
  export function claudeIntegrationLine(result) {
514
530
  if (result?.ok === false) return '✗ Claude Code integration failed';
531
+ if (result?.detected === false) {
532
+ return '- Claude Code not detected; settings registered, CLI step skipped';
533
+ }
515
534
  if (result?.degraded === true) {
516
535
  return `- Claude Code integration registered, but not loaded: ${result.reason ?? 'plugin snapshot not refreshed'}`;
517
536
  }
@@ -530,8 +549,10 @@ export function claudeIntegrationLine(result) {
530
549
  * without Claude Code, to learn nothing it did not already know.
531
550
  */
532
551
  export function claudeSnapshotSettleMs(err) {
533
- const timedOut = err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM';
534
- return timedOut ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
552
+ // Only the shell's own timeout can leave a copy running. Matching on the signal
553
+ // as well spent the whole window on an `ENOBUFS` (output over maxBuffer) child
554
+ // that had already stopped and could never make the snapshot current.
555
+ return err?.code === 'ETIMEDOUT' ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
535
556
  }
536
557
 
537
558
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
@@ -548,58 +569,66 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
548
569
 
549
570
  // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
550
571
  const settingsFile = join(home, '.claude', 'settings.json');
551
- mkdirSync(dirname(settingsFile), { recursive: true });
552
- const bak = backup(settingsFile);
553
- if (bak) actions.push(`backup: ${bak}`);
554
- const settings = readJson(settingsFile, {});
555
-
556
- // Both fields are records (see json.schemastore.org/claude-code-settings.json).
557
- // Older versions of this installer wrote arrays, which Claude Code ignores
558
- // with a warning — migrate those in place.
559
- const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
560
- && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
561
- if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
562
- markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
563
- if (markets['dsh-workers']) {
564
- delete markets['dsh-workers'];
565
- actions.push('removed pre-rename dsh-workers marketplace entry');
566
- }
567
- settings.extraKnownMarketplaces = markets;
572
+ try {
573
+ mkdirSync(dirname(settingsFile), { recursive: true });
574
+ const bak = backup(settingsFile);
575
+ if (bak) actions.push(`backup: ${bak}`);
576
+ const settings = readJson(settingsFile, {});
577
+
578
+ // Both fields are records (see json.schemastore.org/claude-code-settings.json).
579
+ // Older versions of this installer wrote arrays, which Claude Code ignores
580
+ // with a warning — migrate those in place.
581
+ const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
582
+ && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
583
+ if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
584
+ markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
585
+ if (markets['dsh-workers']) {
586
+ delete markets['dsh-workers'];
587
+ actions.push('removed pre-rename dsh-workers marketplace entry');
588
+ }
589
+ settings.extraKnownMarketplaces = markets;
568
590
 
569
- const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
570
- && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
571
- if (Array.isArray(settings.enabledPlugins)) {
572
- for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
573
- actions.push('migrated legacy enabledPlugins array');
574
- }
575
- enabled[PLUGIN_KEY] = true;
576
- if (enabled['dsh-workers@dsh-workers']) {
577
- delete enabled['dsh-workers@dsh-workers'];
578
- actions.push('removed pre-rename dsh-workers plugin entry');
579
- }
580
- settings.enabledPlugins = enabled;
581
-
582
- settings.permissions = settings.permissions ?? {};
583
- let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
584
- const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
585
- if (preRename.length) {
586
- allow = allow.filter((r) => !preRename.includes(r));
587
- actions.push(`removed ${preRename.length} pre-rename permission rules`);
588
- }
589
- for (const tool of MCP_TOOLS) {
590
- const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
591
- if (!allow.includes(rule)) allow.push(rule);
592
- }
593
- settings.permissions.allow = allow;
591
+ const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
592
+ && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
593
+ if (Array.isArray(settings.enabledPlugins)) {
594
+ for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
595
+ actions.push('migrated legacy enabledPlugins array');
596
+ }
597
+ enabled[PLUGIN_KEY] = true;
598
+ if (enabled['dsh-workers@dsh-workers']) {
599
+ delete enabled['dsh-workers@dsh-workers'];
600
+ actions.push('removed pre-rename dsh-workers plugin entry');
601
+ }
602
+ settings.enabledPlugins = enabled;
603
+
604
+ settings.permissions = settings.permissions ?? {};
605
+ let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
606
+ const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
607
+ if (preRename.length) {
608
+ allow = allow.filter((r) => !preRename.includes(r));
609
+ actions.push(`removed ${preRename.length} pre-rename permission rules`);
610
+ }
611
+ for (const tool of MCP_TOOLS) {
612
+ const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
613
+ if (!allow.includes(rule)) allow.push(rule);
614
+ }
615
+ settings.permissions.allow = allow;
594
616
 
595
- if (statusline && !settings.statusLine) {
596
- settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
597
- actions.push('statusline: installed');
598
- } else if (statusline) {
599
- actions.push('statusline: skipped (one already configured)');
600
- }
617
+ if (statusline && !settings.statusLine) {
618
+ settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
619
+ actions.push('statusline: installed');
620
+ } else if (statusline) {
621
+ actions.push('statusline: skipped (one already configured)');
622
+ }
601
623
 
602
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
624
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
625
+ } catch (error) {
626
+ // Both install entries branch on `ok === false` for this integration and
627
+ // neither could ever see it: a settings path that could not be backed up or
628
+ // written threw straight out of both of them. Failing to register is the
629
+ // integration failing, so it gets the shape its callers already handle.
630
+ return { ok: false, code: 'CLAUDE_SETTINGS_UNWRITABLE', error: String(error?.message ?? error), actions };
631
+ }
603
632
  actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
604
633
 
605
634
  // Materialize the install through the claude CLI: registers the marketplace
@@ -612,7 +641,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
612
641
  const marketplaceCurrent = registered?.source?.source === 'directory'
613
642
  && normalizedPath(registered.source.path) === normalizedPath(root)
614
643
  && normalizedPath(registered.installLocation) === normalizedPath(root);
615
- if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === 'user'
644
+ if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
616
645
  && sameManagedClaudeFiles(root, entry.installPath))) {
617
646
  actions.push('cli: skipped (registered marketplace and snapshot already current)');
618
647
  return { ok: true, actions };
@@ -621,11 +650,22 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
621
650
  actions.push('cli: skipped (non-default home; test mode)');
622
651
  return { ok: true, actions };
623
652
  }
624
- // Set only when the CLI attempt threw, and only a timeout then justifies
625
- // waiting for the snapshot: see claudeSnapshotSettleMs.
653
+ // The CLI is best-effort, but a host that does not have it must not be told to
654
+ // run it. The checkout entry already gates on this; doing it here too keeps the
655
+ // two entries describing the same machine the same way.
656
+ const { execSync, spawnSync } = await import('node:child_process');
657
+ const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
658
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
659
+ }).status === 0;
660
+ if (!claudePresent) {
661
+ actions.push('cli: skipped (claude not found)');
662
+ return { ok: true, detected: false, actions };
663
+ }
664
+ // Set only when a CLI step threw; only a timeout then justifies waiting for the
665
+ // snapshot, and `marketplace add` / `uninstall` can time out too — they are
666
+ // caught for other reasons and would otherwise hide it.
626
667
  let installError = null;
627
668
  try {
628
- const { execSync } = await import('node:child_process');
629
669
  // 300s, not 120: the install below runs after the uninstall, so it does a real
630
670
  // copy of the plugin tree rather than the no-op an already-installed plugin
631
671
  // gets. Measured on this machine: `marketplace add` 3s, `uninstall` 3s,
@@ -633,18 +673,19 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
633
673
  // and left Claude Code without the plugin the same run had just removed.
634
674
  const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
635
675
  try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
636
- catch { actions.push('cli: marketplace add skipped (already registered)'); }
676
+ catch (err) { if (err?.code === 'ETIMEDOUT') installError = err; actions.push('cli: marketplace add skipped (already registered)'); }
637
677
  // `plugin install` on an already-installed plugin is a no-op and leaves a
638
678
  // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
639
679
  // always re-copies the current code.
640
- try { run(`claude plugin uninstall ${PLUGIN_KEY}`); } catch {}
680
+ try { run(`claude plugin uninstall ${PLUGIN_KEY}`); }
681
+ catch (err) { if (err?.code === 'ETIMEDOUT') installError = err; }
641
682
  // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
642
683
  // it. Try without it first, fall back to the legacy flag.
643
684
  try {
644
- run(`claude plugin install ${PLUGIN_KEY} --scope user`);
685
+ run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE}`);
645
686
  } catch (errNoFlag) {
646
687
  if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
647
- run(`claude plugin install ${PLUGIN_KEY} --scope user -y`);
688
+ run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE} -y`);
648
689
  }
649
690
  actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
650
691
  } catch (err) {
@@ -657,7 +698,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
657
698
  // or failed leaves the snapshot exactly as stale as it was, and it is the
658
699
  // snapshot that `installStatus` reads. Saying so here is what lets the caller
659
700
  // stop printing a checkmark for a state it never verified.
660
- if (claudeSnapshotReady(home, root)) return { ok: true, actions };
701
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
661
702
  // The CLI can outlive the ceiling above. The install is a real copy of the
662
703
  // plugin tree — 163s measured on an idle machine, and ~6 minutes measured
663
704
  // during an activation, where the record landed well after any ceiling — and a
@@ -669,7 +710,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
669
710
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
670
711
  while (Date.now() < settleDeadline) {
671
712
  await wait(CLAUDE_SNAPSHOT_POLL_MS);
672
- if (claudeSnapshotReady(home, root)) {
713
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) {
673
714
  return { ok: true, actions: [...actions, 'cli: snapshot confirmed current after the ceiling'] };
674
715
  }
675
716
  }
@@ -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.0.9';
39
+ export const RUNTIME_VERSION = '2.0.10';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([