@ran-sh/dsh-crew 2.0.9 → 2.0.11

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.11",
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.11",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
6
6
  import { createHash } from 'node:crypto';
7
+ import { createRequire } from 'node:module';
7
8
  import { dirname, join, resolve, relative } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { homedir } from 'node:os';
@@ -23,6 +24,11 @@ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
23
24
  const CLAUDE_PLUGIN_TIMEOUT_MS = 300_000;
24
25
  const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
25
26
  const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
27
+ // The one scope this installer writes, and therefore the only scope whose record
28
+ // means "the integration is installed". Accepting any scope let a project-scope
29
+ // record stand in for a missing user-scope snapshot, so a failed install read as
30
+ // a current one.
31
+ const CLAUDE_PLUGIN_SCOPE = 'user';
26
32
  const POLICY_START = '<!-- DSH CREW MANAGED POLICY:START -->';
27
33
  const POLICY_END = '<!-- DSH CREW MANAGED POLICY:END -->';
28
34
 
@@ -173,26 +179,31 @@ function claudePluginRootReady(root) {
173
179
  && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
174
180
  }
175
181
 
176
- function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
182
+ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
177
183
  const files = [];
178
184
  let bytes = 0;
185
+ let directories = 0;
179
186
  const add = (file, relativePath) => {
180
187
  const info = lstatSync(file);
181
188
  if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
189
+ // Bound before reading, not after: reading first and rejecting afterwards
190
+ // loads a whole oversized file on the strength of its name.
191
+ if (files.length >= maxFiles || bytes + info.size > maxBytes) throw new Error('snapshot manifest bound exceeded');
182
192
  const content = readFileSync(file);
183
193
  bytes += content.length;
184
- if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
185
194
  files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
186
195
  };
187
- const walk = (directory, relativeDirectory) => {
196
+ const walk = (directory, relativeDirectory, depth = 0) => {
197
+ if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
188
198
  if (!existsSync(directory)) return;
199
+ if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
189
200
  const info = lstatSync(directory);
190
201
  if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
191
202
  for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
192
203
  const file = join(directory, entry.name);
193
204
  const relativePath = join(relativeDirectory, entry.name);
194
205
  if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
195
- if (entry.isDirectory()) walk(file, relativePath);
206
+ if (entry.isDirectory()) walk(file, relativePath, depth + 1);
196
207
  else if (entry.isFile()) add(file, relativePath);
197
208
  else throw new Error('unsupported snapshot entry');
198
209
  }
@@ -200,7 +211,14 @@ function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 *
200
211
  try {
201
212
  add(join(root, '.claude-plugin', 'plugin.json'), join('.claude-plugin', 'plugin.json'));
202
213
  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);
214
+ // What the plugin loads, not just what names it. `worker.cordis.yml` is
215
+ // required before any dispatch (`src/jobs.mjs` throws without it) and the
216
+ // statusline scripts are named by the settings this installer writes, so a
217
+ // snapshot missing either is not this release even when the manifest it came
218
+ // from is byte-identical — which is exactly how a stale snapshot used to read
219
+ // as current.
220
+ if (isFile(join(root, 'worker.cordis.yml'))) add(join(root, 'worker.cordis.yml'), 'worker.cordis.yml');
221
+ for (const directory of ['agents', 'commands', 'skills', 'src', 'statusline']) walk(join(root, directory), directory);
204
222
  return files;
205
223
  } catch { return null; }
206
224
  }
@@ -212,11 +230,13 @@ function sameManagedClaudeFiles(expectedRoot, snapshotRoot) {
212
230
  return expected !== null && snapshot !== null && JSON.stringify(snapshot) === JSON.stringify(expected);
213
231
  }
214
232
 
215
- function claudeSnapshotReady(home, root) {
233
+ function claudeSnapshotReady(home, root, { scope = null } = {}) {
216
234
  const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'), {});
217
235
  const record = installed?.plugins?.[PLUGIN_KEY];
218
236
  const entries = Array.isArray(record) ? record : [record];
219
- return entries.some((entry) => sameManagedClaudeFiles(root, entry?.installPath));
237
+ return entries.some((entry) => (scope === null || entry?.scope === scope)
238
+ && sameManagedClaudeFiles(root, entry?.installPath)
239
+ && claudeSnapshotResolvable(entry?.installPath));
220
240
  }
221
241
 
222
242
  function claudePermissionsReady(settings) {
@@ -399,7 +419,10 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
399
419
  const claudeComponents = {
400
420
  enabled: claudeInstalled,
401
421
  marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
402
- snapshot: claudeSnapshotReady(home, effectiveRoot),
422
+ // User scope only: this installer writes that scope, so it is the one whose
423
+ // record means the integration is installed. Accepting any scope let a
424
+ // project-scope record make a missing user-scope snapshot read as present.
425
+ snapshot: claudeSnapshotReady(home, effectiveRoot, { scope: CLAUDE_PLUGIN_SCOPE }),
403
426
  permissions: claudePermissionsReady(settings),
404
427
  };
405
428
  const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
@@ -512,6 +535,9 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
512
535
  */
513
536
  export function claudeIntegrationLine(result) {
514
537
  if (result?.ok === false) return '✗ Claude Code integration failed';
538
+ if (result?.detected === false) {
539
+ return '- Claude Code not detected; settings registered, CLI step skipped';
540
+ }
515
541
  if (result?.degraded === true) {
516
542
  return `- Claude Code integration registered, but not loaded: ${result.reason ?? 'plugin snapshot not refreshed'}`;
517
543
  }
@@ -530,8 +556,126 @@ export function claudeIntegrationLine(result) {
530
556
  * without Claude Code, to learn nothing it did not already know.
531
557
  */
532
558
  export function claudeSnapshotSettleMs(err) {
533
- const timedOut = err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM';
534
- return timedOut ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
559
+ // Only the shell's own timeout can leave a copy running. Matching on the signal
560
+ // as well spent the whole window on an `ENOBUFS` (output over maxBuffer) child
561
+ // that had already stopped and could never make the snapshot current.
562
+ return err?.code === 'ETIMEDOUT' ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
563
+ }
564
+
565
+ /**
566
+ * Whether the snapshot can actually run, not merely whether its files match.
567
+ *
568
+ * `src/server.mjs` is the MCP server Claude Code launches, and it imports its
569
+ * runtime dependencies by bare specifier — so a copy interrupted between `src/`
570
+ * and `node_modules/` leaves a snapshot whose compared files are all present and
571
+ * whose server cannot start. Resolution is asked from the snapshot's own
572
+ * `package.json` against the dependencies it declares, so this stays bounded by
573
+ * what the package says it needs rather than by walking installed packages. A
574
+ * snapshot carrying no `package.json` declares nothing, and fails the file
575
+ * comparison instead.
576
+ */
577
+ function claudeSnapshotResolvable(snapshotRoot) {
578
+ if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
579
+ const manifest = readJson(join(snapshotRoot, 'package.json'), null);
580
+ const declared = manifest && manifest.dependencies && typeof manifest.dependencies === 'object'
581
+ ? Object.keys(manifest.dependencies)
582
+ : [];
583
+ if (!declared.length) return true;
584
+ let fromSnapshot;
585
+ try { fromSnapshot = createRequire(join(snapshotRoot, 'package.json')); } catch { return false; }
586
+ return declared.every((dependency) => {
587
+ try { fromSnapshot.resolve(dependency); return true; } catch { /* try the manifest path below */ }
588
+ try { fromSnapshot.resolve(`${dependency}/package.json`); return true; } catch { return false; }
589
+ });
590
+ }
591
+
592
+ /**
593
+ * How to run the `claude` CLI, as an executable plus an argument array.
594
+ *
595
+ * Paths go in as arguments and never into a shell string: `JSON.stringify` quotes
596
+ * for JSON, not for a command processor, so a path containing `%NAME%` was
597
+ * expanded before the CLI ever saw it. Windows has to reach the CLI through a
598
+ * command processor because a `.cmd` shim cannot be executed directly, so an
599
+ * argument the processor would act on is refused rather than escaped — a refused
600
+ * install is recoverable, a silently relocated path is not.
601
+ */
602
+ export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
603
+ const argv = args.map((arg) => String(arg));
604
+ if (platform !== 'win32') return { command: 'claude', args: argv };
605
+ const unsafe = argv.find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
606
+ if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
607
+ return {
608
+ command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
609
+ args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
610
+ windowsVerbatimArguments: true,
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Kill a process and everything it started.
616
+ *
617
+ * The killed shell is not the process doing the work: `claude` is a grandchild
618
+ * that Windows does not reach when only its parent is terminated. An `uninstall`
619
+ * left running that way finishes *after* the install that followed it and deletes
620
+ * the plugin the install just registered, so the tree has to go, and the caller
621
+ * must not return until it has.
622
+ */
623
+ async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
624
+ if (!Number.isInteger(pid) || pid <= 0) return;
625
+ const { spawn } = await import('node:child_process');
626
+ if (platform === 'win32') {
627
+ await new Promise((done) => {
628
+ let settled = false;
629
+ const finish = () => { if (!settled) { settled = true; done(); } };
630
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
631
+ killer.on('error', finish);
632
+ killer.on('close', finish);
633
+ });
634
+ return;
635
+ }
636
+ try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
637
+ }
638
+
639
+ /**
640
+ * Run one CLI step to completion, or end it and its descendants on timeout.
641
+ *
642
+ * Resolves only once nothing from this step can still be running, so the next
643
+ * step cannot be raced by the previous one. A timeout is reported in the shape
644
+ * `execSync` uses, so the settle rule reads it the same way.
645
+ */
646
+ async function runClaudeStep(args, { timeoutMs = CLAUDE_PLUGIN_TIMEOUT_MS } = {}) {
647
+ let invocation;
648
+ try { invocation = claudeCliInvocation(args); }
649
+ catch (error) { return { ok: false, timedOut: false, status: null, error }; }
650
+ const { spawn } = await import('node:child_process');
651
+ return await new Promise((resolve) => {
652
+ const child = spawn(invocation.command, invocation.args, {
653
+ stdio: ['ignore', 'pipe', 'pipe'],
654
+ windowsHide: true,
655
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
656
+ detached: process.platform !== 'win32',
657
+ });
658
+ let timedOut = false;
659
+ let kill = null;
660
+ let stderr = '';
661
+ let settled = false;
662
+ const finish = () => {
663
+ if (settled) return;
664
+ settled = true;
665
+ clearTimeout(timer);
666
+ const detail = stderr.slice(-400);
667
+ // Never report a step finished while its tree may still be writing.
668
+ const done = () => resolve(timedOut
669
+ ? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
670
+ : { ok: code === 0, timedOut: false, status: code, detail, error: null });
671
+ if (kill) kill.then(done, done); else done();
672
+ };
673
+ let code = null;
674
+ const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
675
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
676
+ child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
677
+ child.on('close', (value) => { code = value; finish(); });
678
+ });
535
679
  }
536
680
 
537
681
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
@@ -548,58 +692,80 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
548
692
 
549
693
  // 2. settings.json: marketplace + enabledPlugins + permissions allowlist.
550
694
  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;
695
+ try {
696
+ mkdirSync(dirname(settingsFile), { recursive: true });
697
+ const bak = backup(settingsFile);
698
+ if (bak) actions.push(`backup: ${bak}`);
699
+ const settingsPresent = existsSync(settingsFile);
700
+ const parsed = readJson(settingsFile, null);
701
+ if (parsed === null && settingsPresent) {
702
+ // Present but unparseable is not "no settings yet". Rebuilding it as an
703
+ // empty configuration is exactly how an operator's settings disappear, with
704
+ // a backup left for them to restore by hand — the file is theirs, and an
705
+ // unreadable one is not permission to replace it.
706
+ return {
707
+ ok: false,
708
+ code: 'CLAUDE_SETTINGS_UNREADABLE',
709
+ error: `${settingsFile} is present but not valid JSON`,
710
+ actions,
711
+ };
712
+ }
713
+ const settings = parsed ?? {};
714
+
715
+ // Both fields are records (see json.schemastore.org/claude-code-settings.json).
716
+ // Older versions of this installer wrote arrays, which Claude Code ignores
717
+ // with a warning — migrate those in place.
718
+ const markets = (settings.extraKnownMarketplaces && !Array.isArray(settings.extraKnownMarketplaces)
719
+ && typeof settings.extraKnownMarketplaces === 'object') ? settings.extraKnownMarketplaces : {};
720
+ if (Array.isArray(settings.extraKnownMarketplaces)) actions.push('migrated legacy extraKnownMarketplaces array');
721
+ markets[MARKETPLACE_NAME] = { source: { source: 'directory', path: mpDir } };
722
+ if (markets['dsh-workers']) {
723
+ delete markets['dsh-workers'];
724
+ actions.push('removed pre-rename dsh-workers marketplace entry');
725
+ }
726
+ settings.extraKnownMarketplaces = markets;
568
727
 
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;
728
+ const enabled = (settings.enabledPlugins && !Array.isArray(settings.enabledPlugins)
729
+ && typeof settings.enabledPlugins === 'object') ? settings.enabledPlugins : {};
730
+ if (Array.isArray(settings.enabledPlugins)) {
731
+ for (const key of settings.enabledPlugins) if (typeof key === 'string') enabled[key] = true;
732
+ actions.push('migrated legacy enabledPlugins array');
733
+ }
734
+ enabled[PLUGIN_KEY] = true;
735
+ if (enabled['dsh-workers@dsh-workers']) {
736
+ delete enabled['dsh-workers@dsh-workers'];
737
+ actions.push('removed pre-rename dsh-workers plugin entry');
738
+ }
739
+ settings.enabledPlugins = enabled;
740
+
741
+ settings.permissions = settings.permissions ?? {};
742
+ let allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
743
+ const preRename = allow.filter((r) => typeof r === 'string' && r.startsWith('mcp__plugin_dsh-workers_'));
744
+ if (preRename.length) {
745
+ allow = allow.filter((r) => !preRename.includes(r));
746
+ actions.push(`removed ${preRename.length} pre-rename permission rules`);
747
+ }
748
+ for (const tool of MCP_TOOLS) {
749
+ const rule = `mcp__plugin_dsh-crew_dsh-crew__${tool}`;
750
+ if (!allow.includes(rule)) allow.push(rule);
751
+ }
752
+ settings.permissions.allow = allow;
594
753
 
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
- }
754
+ if (statusline && !settings.statusLine) {
755
+ settings.statusLine = { type: 'command', command: `bash ${join(root, 'statusline', 'statusline.sh')}` };
756
+ actions.push('statusline: installed');
757
+ } else if (statusline) {
758
+ actions.push('statusline: skipped (one already configured)');
759
+ }
601
760
 
602
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
761
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
762
+ } catch (error) {
763
+ // Both install entries branch on `ok === false` for this integration and
764
+ // neither could ever see it: a settings path that could not be backed up or
765
+ // written threw straight out of both of them. Failing to register is the
766
+ // integration failing, so it gets the shape its callers already handle.
767
+ return { ok: false, code: 'CLAUDE_SETTINGS_UNWRITABLE', error: String(error?.message ?? error), actions };
768
+ }
603
769
  actions.push(`settings: registered ${PLUGIN_KEY} + ${MCP_TOOLS.length} permission rules`);
604
770
 
605
771
  // Materialize the install through the claude CLI: registers the marketplace
@@ -612,7 +778,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
612
778
  const marketplaceCurrent = registered?.source?.source === 'directory'
613
779
  && normalizedPath(registered.source.path) === normalizedPath(root)
614
780
  && normalizedPath(registered.installLocation) === normalizedPath(root);
615
- if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === 'user'
781
+ if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
616
782
  && sameManagedClaudeFiles(root, entry.installPath))) {
617
783
  actions.push('cli: skipped (registered marketplace and snapshot already current)');
618
784
  return { ok: true, actions };
@@ -621,43 +787,59 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
621
787
  actions.push('cli: skipped (non-default home; test mode)');
622
788
  return { ok: true, actions };
623
789
  }
624
- // Set only when the CLI attempt threw, and only a timeout then justifies
625
- // waiting for the snapshot: see claudeSnapshotSettleMs.
626
- let installError = null;
627
- try {
628
- const { execSync } = await import('node:child_process');
629
- // 300s, not 120: the install below runs after the uninstall, so it does a real
630
- // copy of the plugin tree rather than the no-op an already-installed plugin
631
- // gets. Measured on this machine: `marketplace add` 3s, `uninstall` 3s,
632
- // `install` 163s (no-op install: 6s). The old ceiling killed the copy partway
633
- // and left Claude Code without the plugin the same run had just removed.
634
- const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
635
- try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
636
- catch { actions.push('cli: marketplace add skipped (already registered)'); }
637
- // `plugin install` on an already-installed plugin is a no-op and leaves a
638
- // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
639
- // always re-copies the current code.
640
- try { run(`claude plugin uninstall ${PLUGIN_KEY}`); } catch {}
641
- // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
642
- // it. Try without it first, fall back to the legacy flag.
643
- try {
644
- run(`claude plugin install ${PLUGIN_KEY} --scope user`);
645
- } catch (errNoFlag) {
646
- if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
647
- run(`claude plugin install ${PLUGIN_KEY} --scope user -y`);
648
- }
649
- actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
650
- } catch (err) {
651
- installError = err;
652
- actions.push(`cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY} (${String(err?.message ?? err).slice(0, 120)})`);
653
- }
790
+ // The CLI is best-effort, but a host that does not have it must not be told to
791
+ // run it. The checkout entry already gates on this; doing it here too keeps the
792
+ // two entries describing the same machine the same way.
793
+ const { spawnSync } = await import('node:child_process');
794
+ const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
795
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
796
+ }).status === 0;
797
+ if (!claudePresent) {
798
+ actions.push('cli: skipped (claude not found)');
799
+ return { ok: true, detected: false, actions };
800
+ }
801
+ // Each step runs to completion — including ending the whole process tree on a
802
+ // timeout — before the next one starts, and the settle window is the widest any
803
+ // step asks for. Two things this replaces: an `uninstall` left running past its
804
+ // timeout finished *after* the install that followed and deleted the plugin the
805
+ // install had just registered, and a later step's ordinary failure used to erase
806
+ // an earlier step's timeout from the evidence.
807
+ // 300s, not 120: the install runs after the uninstall, so it does a real copy of
808
+ // the plugin tree rather than the no-op an already-installed plugin gets.
809
+ // Measured on this machine: `marketplace add` 3s, `uninstall` 3s, `install` 163s
810
+ // (no-op install: 6s). The old ceiling killed the copy partway and left Claude
811
+ // Code without the plugin the same run had just removed.
812
+ let settleMs = 0;
813
+ const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
814
+
815
+ const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
816
+ noteStep(marketplace);
817
+ actions.push(marketplace.ok
818
+ ? 'cli: marketplace registered'
819
+ : `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
820
+
821
+ // `plugin install` on an already-installed plugin is a no-op and leaves a
822
+ // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
823
+ // always re-copies the current code.
824
+ noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
825
+
826
+ // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
827
+ // Try without it first, fall back to the legacy flag.
828
+ let install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE]);
829
+ if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
830
+ install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y']);
831
+ }
832
+ noteStep(install);
833
+ actions.push(install.ok
834
+ ? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
835
+ : `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
654
836
  // Report the state that resulted, not the step that was attempted. The CLI is
655
837
  // best-effort — a machine without `claude` is a supported install, and its
656
838
  // settings alone are correct — but a CLI that is present and slow, timed out,
657
839
  // or failed leaves the snapshot exactly as stale as it was, and it is the
658
840
  // snapshot that `installStatus` reads. Saying so here is what lets the caller
659
841
  // stop printing a checkmark for a state it never verified.
660
- if (claudeSnapshotReady(home, root)) return { ok: true, actions };
842
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
661
843
  // The CLI can outlive the ceiling above. The install is a real copy of the
662
844
  // plugin tree — 163s measured on an idle machine, and ~6 minutes measured
663
845
  // during an activation, where the record landed well after any ceiling — and a
@@ -665,11 +847,11 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
665
847
  // is still running keeps writing while this function has already given up on
666
848
  // it. Wait for the snapshot to settle before reporting it missing, or the
667
849
  // update says "not loaded" about a plugin that is loading.
668
- const settleDeadline = Date.now() + claudeSnapshotSettleMs(installError);
850
+ const settleDeadline = Date.now() + settleMs;
669
851
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
670
852
  while (Date.now() < settleDeadline) {
671
853
  await wait(CLAUDE_SNAPSHOT_POLL_MS);
672
- if (claudeSnapshotReady(home, root)) {
854
+ if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) {
673
855
  return { ok: true, actions: [...actions, 'cli: snapshot confirmed current after the ceiling'] };
674
856
  }
675
857
  }
@@ -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.11';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -437,8 +437,16 @@ function Get-HealthState {
437
437
  $diskReadable = $true
438
438
  }
439
439
  $cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
440
+ # Read through PSObject: StrictMode is on, so a response that simply omits the
441
+ # property would throw and be swallowed as "not ready" rather than being
442
+ # reported absent.
443
+ $runtimeId = $null
444
+ if ($null -ne $runtime) {
445
+ $runtimeIdProperty = $runtime.PSObject.Properties['runtime_id']
446
+ if ($null -ne $runtimeIdProperty -and $null -ne $runtimeIdProperty.Value) { $runtimeId = [string] $runtimeIdProperty.Value }
447
+ }
440
448
  if ($response.ok -eq $true -and $version -and $cohortMatches) {
441
- return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
449
+ return [pscustomobject]@{ Ready = $true; Version = [string] $version; RuntimeId = $runtimeId; Error = $null }
442
450
  }
443
451
  if (-not $diskReadable) {
444
452
  return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
@@ -551,6 +559,92 @@ function Get-FreshSupervisorHeartbeat {
551
559
  return $null
552
560
  }
553
561
 
562
+ # ---- Persisted ownership of the live Hub -----------------------------------
563
+ # A watcher can exit while the Hub it started keeps serving. Nothing else on the
564
+ # machine can tell a later watcher that such a listener is Crew's: port health
565
+ # says a process answers, not whose it is, and adopting on health alone would put
566
+ # a stranger's listener within reach of Stop-OwnedListener. So the identity is
567
+ # written down when it is established, and re-proven field by field — PID, start
568
+ # time, port, profile, Crew home and live runtime_id — before any later watcher
569
+ # adopts it. A record that merely exists is not authority; it may name a Hub from
570
+ # a previous cohort, another profile, or a PID the system has since recycled.
571
+
572
+ $crewOwnedServiceFile = Join-Path $crewSupervisorRoot 'owned-service.json'
573
+
574
+ function Write-OwnedServiceRecord {
575
+ param([pscustomobject] $Service, [string] $RuntimeId = $null)
576
+ if (-not $Service.CrewOwned -or -not $Service.RootPid -or -not $Service.RootStartedAtUtcTicks) { return }
577
+ $record = @{
578
+ schema_version = 1
579
+ profile = [string] $Service.Profile
580
+ home = [string] $Service.Home
581
+ port = [int] $Service.Port
582
+ root_pid = [int] $Service.RootPid
583
+ root_started_at_utc_ticks = [long] $Service.RootStartedAtUtcTicks
584
+ listener_pid = if ($Service.ListenerPid) { [int] $Service.ListenerPid } else { $null }
585
+ listener_started_at_utc_ticks = if ($Service.ListenerStartedAtUtcTicks) { [long] $Service.ListenerStartedAtUtcTicks } else { $null }
586
+ runtime_id = $RuntimeId
587
+ recorded_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
588
+ } | ConvertTo-Json -Compress
589
+ try {
590
+ if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
591
+ $temp = Join-Path $crewSupervisorRoot ("owned-service.{0}.tmp" -f $PID)
592
+ Write-Utf8NoBom -Path $temp -Content $record
593
+ Move-Item -LiteralPath $temp -Destination $crewOwnedServiceFile -Force
594
+ } catch { /* ownership record is best-effort, like the heartbeat */ }
595
+ }
596
+
597
+ function Clear-OwnedServiceRecord {
598
+ try {
599
+ if (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf) { Remove-Item -LiteralPath $crewOwnedServiceFile -Force }
600
+ } catch { }
601
+ }
602
+
603
+ function Get-OwnedServiceRecord {
604
+ try {
605
+ if (-not (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf)) { return $null }
606
+ $record = Get-Content -LiteralPath $crewOwnedServiceFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
607
+ if ($record.schema_version -ne 1) { return $null }
608
+ return $record
609
+ } catch {
610
+ return $null
611
+ }
612
+ }
613
+
614
+ function Restore-OwnedServiceRecord {
615
+ param([pscustomobject] $Service, [pscustomobject] $Health = $null)
616
+ if (-not $Service.CrewOwned) { return $false }
617
+ $record = Get-OwnedServiceRecord
618
+ if (-not $record) { return $false }
619
+ if ([string] $record.profile -ne [string] $Service.Profile) { return $false }
620
+ if ([string] $record.home -ne [string] $Service.Home) { return $false }
621
+ if ([int] $record.port -ne [int] $Service.Port) { return $false }
622
+ if (-not $record.root_pid -or -not $record.root_started_at_utc_ticks) { return $false }
623
+ if (-not $record.listener_pid -or -not $record.listener_started_at_utc_ticks) { return $false }
624
+ # Without this, an interrupted write could leave a record whose listener half is
625
+ # simply absent, and "absent" must never read as "the Hub this record names".
626
+ if ([string]::IsNullOrWhiteSpace([string] $record.runtime_id)) { return $false }
627
+ try {
628
+ $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
629
+ } catch {
630
+ return $false
631
+ }
632
+ # Start times come from Get-Process, since Win32_Process carries none: the table
633
+ # only has to establish that the PID exists before the ticks are compared.
634
+ if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.root_pid) -ExpectedStartTicks ([long] $record.root_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
635
+ if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.listener_pid) -ExpectedStartTicks ([long] $record.listener_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
636
+ $port = Get-PortState $Service.Port
637
+ if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $record.listener_pid) { return $false }
638
+ $live = if ($Health) { $Health } else { Get-HealthState $Service }
639
+ if (-not $live.Ready) { return $false }
640
+ if ([string] $live.RuntimeId -ne [string] $record.runtime_id) { return $false }
641
+ $Service.RootPid = [int] $record.root_pid
642
+ $Service.RootStartedAtUtcTicks = [long] $record.root_started_at_utc_ticks
643
+ $Service.ListenerPid = [int] $record.listener_pid
644
+ $Service.ListenerStartedAtUtcTicks = [long] $record.listener_started_at_utc_ticks
645
+ return $true
646
+ }
647
+
554
648
  function Ensure-CrewSupervisorRunning {
555
649
  param([int] $TimeoutSeconds = 90)
556
650
  $watcher = $null
@@ -967,7 +1061,7 @@ function Get-TrackedProcessTree {
967
1061
  }
968
1062
 
969
1063
  function Set-TrackedListenerIdentity {
970
- param([pscustomobject] $Service)
1064
+ param([pscustomobject] $Service, [string] $RuntimeId = $null)
971
1065
  $port = Get-PortState $Service.Port
972
1066
  if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
973
1067
  $tree = @(Get-TrackedProcessTree -Service $Service)
@@ -976,6 +1070,7 @@ function Set-TrackedListenerIdentity {
976
1070
  $listener = Get-Process -Id $port.Pid -ErrorAction Stop
977
1071
  $Service.ListenerPid = [int] $port.Pid
978
1072
  $Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
1073
+ Write-OwnedServiceRecord -Service $Service -RuntimeId $RuntimeId
979
1074
  return $true
980
1075
  } catch {
981
1076
  return $false
@@ -1057,6 +1152,10 @@ function Start-CrewService {
1057
1152
  $Service.ListenerStartedAtUtcTicks = $null
1058
1153
  $Service.ConsecutiveFailures = 0
1059
1154
  $Service.State = 'starting'
1155
+ # Recorded before the Hub is healthy on purpose: the record is written again
1156
+ # with the listener identity once it is, and only that later write is
1157
+ # adoptable, because this one carries no runtime_id to prove itself against.
1158
+ Write-OwnedServiceRecord -Service $Service
1060
1159
  Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
1061
1160
  } finally {
1062
1161
  $env:DSH_HOME = $previousHome
@@ -1070,7 +1169,7 @@ function Wait-CrewServices {
1070
1169
  $health = Get-HealthState $service
1071
1170
  $service.LastError = $health.Error
1072
1171
  if ($health.Ready) {
1073
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1172
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
1074
1173
  throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1075
1174
  }
1076
1175
  if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
@@ -1118,7 +1217,16 @@ function Ensure-CrewServices {
1118
1217
  $health = Get-HealthState $service
1119
1218
  if ($health.Ready) {
1120
1219
  $wasReady = $service.State -eq 'ready'
1121
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1220
+ if ($service.CrewOwned -and -not $service.ListenerPid) {
1221
+ # A watcher that exited leaves its Hub serving with nothing in memory to
1222
+ # say whose it is. Re-prove the persisted identity before touching it;
1223
+ # this is the only path by which a later watcher may adopt a live Hub,
1224
+ # and it never adopts on port health alone.
1225
+ if (Restore-OwnedServiceRecord -Service $service -Health $health) {
1226
+ Write-LaunchLog ('{0} on {1} recovered from the persisted ownership record; listener PID={2}.' -f $service.Name, $service.Port, $service.ListenerPid)
1227
+ }
1228
+ }
1229
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
1122
1230
  throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1123
1231
  }
1124
1232
  if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
@@ -1157,6 +1265,7 @@ function Ensure-CrewServices {
1157
1265
  $service.ListenerPid = $null
1158
1266
  $service.ListenerStartedAtUtcTicks = $null
1159
1267
  $service.ConsecutiveFailures = 0
1268
+ Clear-OwnedServiceRecord
1160
1269
  $port = Get-PortState $service.Port
1161
1270
  }
1162
1271
  }