@ran-sh/dsh-crew 2.0.8 → 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.8",
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.8",
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,12 +528,33 @@ 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
  }
518
537
  return '✓ Claude Code integration';
519
538
  }
520
539
 
540
+ /**
541
+ * How long to keep watching the plugin snapshot after a failed CLI attempt.
542
+ *
543
+ * Zero unless that attempt timed out. The install is a real copy of the plugin
544
+ * tree, and a timed-out child on Windows is not in the shell's process tree, so
545
+ * it can go on writing after the shell has given up — that is the only case with
546
+ * something to wait for. A `claude` that is not installed exits with status 1 and
547
+ * never started a copy, and a CLI that failed on its own terms has already
548
+ * stopped; waiting on either cost a fixed 180s on every update of a machine
549
+ * without Claude Code, to learn nothing it did not already know.
550
+ */
551
+ export function claudeSnapshotSettleMs(err) {
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;
556
+ }
557
+
521
558
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
522
559
  const actions = [];
523
560
 
@@ -532,58 +569,66 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
532
569
 
533
570
  // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
534
571
  const settingsFile = join(home, '.claude', 'settings.json');
535
- mkdirSync(dirname(settingsFile), { recursive: true });
536
- const bak = backup(settingsFile);
537
- if (bak) actions.push(`backup: ${bak}`);
538
- const settings = readJson(settingsFile, {});
539
-
540
- // Both fields are records (see json.schemastore.org/claude-code-settings.json).
541
- // Older versions of this installer wrote arrays, which Claude Code ignores
542
- // with a warning — migrate those in place.
543
- const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
544
- && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
545
- if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
546
- markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
547
- if (markets['dsh-workers']) {
548
- delete markets['dsh-workers'];
549
- actions.push('removed pre-rename dsh-workers marketplace entry');
550
- }
551
- 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;
552
590
 
553
- const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
554
- && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
555
- if (Array.isArray(settings.enabledPlugins)) {
556
- for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
557
- actions.push('migrated legacy enabledPlugins array');
558
- }
559
- enabled[PLUGIN_KEY] = true;
560
- if (enabled['dsh-workers@dsh-workers']) {
561
- delete enabled['dsh-workers@dsh-workers'];
562
- actions.push('removed pre-rename dsh-workers plugin entry');
563
- }
564
- settings.enabledPlugins = enabled;
565
-
566
- settings.permissions = settings.permissions ?? {};
567
- let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
568
- const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
569
- if (preRename.length) {
570
- allow = allow.filter((r) => !preRename.includes(r));
571
- actions.push(`removed ${preRename.length} pre-rename permission rules`);
572
- }
573
- for (const tool of MCP_TOOLS) {
574
- const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
575
- if (!allow.includes(rule)) allow.push(rule);
576
- }
577
- 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;
578
616
 
579
- if (statusline && !settings.statusLine) {
580
- settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
581
- actions.push('statusline: installed');
582
- } else if (statusline) {
583
- actions.push('statusline: skipped (one already configured)');
584
- }
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
+ }
585
623
 
586
- 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
+ }
587
632
  actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
588
633
 
589
634
  // Materialize the install through the claude CLI: registers the marketplace
@@ -596,7 +641,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
596
641
  const marketplaceCurrent = registered?.source?.source === 'directory'
597
642
  && normalizedPath(registered.source.path) === normalizedPath(root)
598
643
  && normalizedPath(registered.installLocation) === normalizedPath(root);
599
- if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === 'user'
644
+ if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
600
645
  && sameManagedClaudeFiles(root, entry.installPath))) {
601
646
  actions.push('cli: skipped (registered marketplace and snapshot already current)');
602
647
  return { ok: true, actions };
@@ -605,8 +650,22 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
605
650
  actions.push('cli: skipped (non-default home; test mode)');
606
651
  return { ok: true, actions };
607
652
  }
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.
667
+ let installError = null;
608
668
  try {
609
- const { execSync } = await import('node:child_process');
610
669
  // 300s, not 120: the install below runs after the uninstall, so it does a real
611
670
  // copy of the plugin tree rather than the no-op an already-installed plugin
612
671
  // gets. Measured on this machine: `marketplace add` 3s, `uninstall` 3s,
@@ -614,21 +673,23 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
614
673
  // and left Claude Code without the plugin the same run had just removed.
615
674
  const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
616
675
  try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
617
- 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)'); }
618
677
  // `plugin install` on an already-installed plugin is a no-op and leaves a
619
678
  // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
620
679
  // always re-copies the current code.
621
- 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; }
622
682
  // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
623
683
  // it. Try without it first, fall back to the legacy flag.
624
684
  try {
625
- run(`claude plugin install ${PLUGIN_KEY} --scope user`);
685
+ run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE}`);
626
686
  } catch (errNoFlag) {
627
687
  if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
628
- run(`claude plugin install ${PLUGIN_KEY} --scope user -y`);
688
+ run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE} -y`);
629
689
  }
630
690
  actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
631
691
  } catch (err) {
692
+ installError = err;
632
693
  actions.push(`cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY} (${String(err?.message ?? err).slice(0, 120)})`);
633
694
  }
634
695
  // Report the state that resulted, not the step that was attempted. The CLI is
@@ -637,7 +698,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
637
698
  // or failed leaves the snapshot exactly as stale as it was, and it is the
638
699
  // snapshot that `installStatus` reads. Saying so here is what lets the caller
639
700
  // stop printing a checkmark for a state it never verified.
640
- if (claudeSnapshotReady(home, root)) return { ok: true, actions };
701
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
641
702
  // The CLI can outlive the ceiling above. The install is a real copy of the
642
703
  // plugin tree — 163s measured on an idle machine, and ~6 minutes measured
643
704
  // during an activation, where the record landed well after any ceiling — and a
@@ -645,11 +706,11 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
645
706
  // is still running keeps writing while this function has already given up on
646
707
  // it. Wait for the snapshot to settle before reporting it missing, or the
647
708
  // update says "not loaded" about a plugin that is loading.
648
- const settleDeadline = Date.now() + CLAUDE_SNAPSHOT_SETTLE_MS;
709
+ const settleDeadline = Date.now() + claudeSnapshotSettleMs(installError);
649
710
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
650
711
  while (Date.now() < settleDeadline) {
651
712
  await wait(CLAUDE_SNAPSHOT_POLL_MS);
652
- if (claudeSnapshotReady(home, root)) {
713
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) {
653
714
  return { ok: true, actions: [...actions, 'cli: snapshot confirmed current after the ceiling'] };
654
715
  }
655
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.8';
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([