@ran-sh/dsh-crew 2.0.12 → 2.0.14

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.12",
3
+ "version": "2.0.14",
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.12",
3
+ "version": "2.0.14",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -2,9 +2,10 @@
2
2
  // render Codex agent roles with real paths. Called from the CLI entry or the
3
3
  // DSH settings page. All edits are backed up and idempotent.
4
4
 
5
- import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync, opendirSync } from 'node:fs';
6
6
  import { createHash } from 'node:crypto';
7
- import { createRequire } from 'node:module';
7
+ import { createRequire, isBuiltin } from 'node:module';
8
+ import { spawnSync } from 'node:child_process';
8
9
  import { dirname, join, resolve, relative } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { homedir } from 'node:os';
@@ -25,6 +26,9 @@ const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
25
26
  // plugin the same run had just removed.
26
27
  const CLAUDE_STEP_TIMEOUT_MS = 300_000;
27
28
  const CLAUDE_INSTALL_TIMEOUT_MS = 900_000;
29
+ // How long a landing kill gets to actually close the child before the step ends
30
+ // and reports the termination as unconfirmed.
31
+ const CLAUDE_KILL_GRACE_MS = 15_000;
28
32
  const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
29
33
  const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
30
34
  // The one scope this installer writes, and therefore the only scope whose record
@@ -182,10 +186,11 @@ function claudePluginRootReady(root) {
182
186
  && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
183
187
  }
184
188
 
185
- export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
189
+ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12, maxEntries = 2048 } = {}) {
186
190
  const files = [];
187
191
  let bytes = 0;
188
192
  let directories = 0;
193
+ let entries = 0;
189
194
  const add = (file, relativePath) => {
190
195
  const info = lstatSync(file);
191
196
  if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
@@ -196,13 +201,29 @@ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 *
196
201
  bytes += content.length;
197
202
  files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
198
203
  };
204
+ const listEntries = (directory) => {
205
+ // Enumerated incrementally, and rejected the moment it goes over budget:
206
+ // `readdirSync` materialises the whole listing first, so a bound applied to
207
+ // its result is a bound applied after the cost has already been paid.
208
+ const found = [];
209
+ const handle = opendirSync(directory);
210
+ try {
211
+ for (let entry = handle.readSync(); entry !== null; entry = handle.readSync()) {
212
+ if (++entries > maxEntries) throw new Error('snapshot entry count exceeded');
213
+ found.push(entry);
214
+ }
215
+ } finally {
216
+ try { handle.closeSync(); } catch { /* already closed */ }
217
+ }
218
+ return found.sort((a, b) => a.name.localeCompare(b.name));
219
+ };
199
220
  const walk = (directory, relativeDirectory, depth = 0) => {
200
221
  if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
201
222
  if (!existsSync(directory)) return;
202
223
  if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
203
224
  const info = lstatSync(directory);
204
225
  if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
205
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
226
+ for (const entry of listEntries(directory)) {
206
227
  const file = join(directory, entry.name);
207
228
  const relativePath = join(relativeDirectory, entry.name);
208
229
  if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
@@ -568,28 +589,91 @@ export function claudeSnapshotSettleMs(err) {
568
589
  /**
569
590
  * Whether the snapshot can actually run, not merely whether its files match.
570
591
  *
571
- * `src/server.mjs` is the MCP server Claude Code launches, and it imports its
572
- * runtime dependencies by bare specifier — so a copy interrupted between `src/`
573
- * and `node_modules/` leaves a snapshot whose compared files are all present and
574
- * whose server cannot start. Resolution is asked from the snapshot's own
575
- * `package.json` against the dependencies it declares, so this stays bounded by
576
- * what the package says it needs rather than by walking installed packages. A
577
- * snapshot carrying no `package.json` declares nothing, and fails the file
578
- * comparison instead.
592
+ * `src/server.mjs` is the MCP server Claude Code launches, so what has to be
593
+ * present is what *it* imports. The manifest is not a substitute: it declares 28
594
+ * dependencies, including meta-packages the server never imports from here, and
595
+ * requiring all of them to resolve read a working install as broken. Resolution
596
+ * follows local modules from the entry, and subpath specifiers are kept as they are —
597
+ * `@modelcontextprotocol/sdk/server/mcp.js` resolves through its package's
598
+ * `exports`, which the bare package name does not.
579
599
  */
580
600
  function claudeSnapshotResolvable(snapshotRoot) {
581
601
  if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
582
- const manifest = readJson(join(snapshotRoot, 'package.json'), null);
583
- const declared = manifest && manifest.dependencies && typeof manifest.dependencies === 'object'
584
- ? Object.keys(manifest.dependencies)
585
- : [];
586
- if (!declared.length) return true;
587
- let fromSnapshot;
588
- try { fromSnapshot = createRequire(join(snapshotRoot, 'package.json')); } catch { return false; }
589
- return declared.every((dependency) => {
590
- try { fromSnapshot.resolve(dependency); return true; } catch { /* try the manifest path below */ }
591
- try { fromSnapshot.resolve(`${dependency}/package.json`); return true; } catch { return false; }
602
+ const root = resolve(snapshotRoot);
603
+ const visited = new Set();
604
+ let bytes = 0;
605
+ const inspect = (entry) => {
606
+ if (visited.has(entry)) return true;
607
+ if (visited.size >= 512) return false;
608
+ const local = relative(root, entry);
609
+ if (local.startsWith('..') || resolve(root, local) !== entry) return false;
610
+ visited.add(entry);
611
+ try {
612
+ const info = lstatSync(entry);
613
+ if (!info.isFile() || info.isSymbolicLink() || bytes + info.size > 8 * 1024 * 1024) return false;
614
+ bytes += info.size;
615
+ if (!/\.[cm]?js$/i.test(entry)) return true;
616
+ const source = readFileSync(entry, 'utf8');
617
+ const fromEntry = createRequire(entry);
618
+ // Consume comments and literals as complete tokens before recognizing declarations.
619
+ const tokens = (source.match(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[A-Za-z_$][\w$]*|[^\s]/g) ?? []).filter((token) => !token.startsWith('/*') && !token.startsWith('//'));
620
+ const specifiers = [];
621
+ const literal = (token) => token && /^['"]/.test(token);
622
+ for (let i = 0; i < tokens.length; i += 1) {
623
+ if (!['import', 'export', 'require'].includes(tokens[i])) continue;
624
+ if (tokens[i - 1] === '.') continue;
625
+ let next = i + 1;
626
+ while (tokens[next]?.startsWith('/*') || tokens[next]?.startsWith('//')) next += 1;
627
+ if (['import', 'require'].includes(tokens[i]) && tokens[next] === '(') {
628
+ if (literal(tokens[next + 1])) specifiers.push(tokens[next + 1].slice(1, -1));
629
+ continue;
630
+ }
631
+ if (tokens[i] === 'require') continue;
632
+ if (literal(tokens[next])) { specifiers.push(tokens[next].slice(1, -1)); continue; }
633
+ // Only import/export declarations can introduce a `from` clause.
634
+ if (tokens[next] === '.' || tokens[next] === '(' || ['const', 'let', 'var', 'function', 'class', 'default', 'async'].includes(tokens[next])) continue;
635
+ for (let j = next; j < tokens.length && tokens[j] !== ';'; j += 1) {
636
+ if (tokens[j] === 'from' && literal(tokens[j + 1])) { specifiers.push(tokens[j + 1].slice(1, -1)); break; }
637
+ }
638
+ }
639
+ return specifiers.every((specifier) => {
640
+ if (isBuiltin(specifier)) return true;
641
+ const target = fromEntry.resolve(specifier);
642
+ if (!existsSync(target)) return false;
643
+ return specifier.startsWith('.') ? inspect(target) : true;
644
+ });
645
+ } catch { return false; }
646
+ };
647
+ return inspect(join(root, 'src', 'server.mjs'));
648
+ }
649
+
650
+ /**
651
+ * The `claude` executable this machine actually has.
652
+ *
653
+ * `where claude` reports every match — the extensionless shim, `claude.cmd`, and
654
+ * `claude.exe` on an install that ships the native binary. Detection and execution
655
+ * are different questions: hardcoding `.cmd` meant a host with only the native
656
+ * executable was detected and then could not be run. A native executable is
657
+ * preferred because it starts without a command processor, so there is no quoting
658
+ * to get wrong.
659
+ */
660
+ export function pickClaudeCommand(candidates, { platform = process.platform } = {}) {
661
+ const list = (Array.isArray(candidates) ? candidates : []).map((line) => String(line).trim()).filter(Boolean);
662
+ if (!list.length) return null;
663
+ if (platform !== 'win32') return list[0];
664
+ // A native executable is preferred because it starts without a command
665
+ // processor, so there is no quoting to get wrong.
666
+ return list.find((candidate) => /\.exe$/i.test(candidate))
667
+ ?? list.find((candidate) => /\.(cmd|bat)$/i.test(candidate))
668
+ ?? list[0];
669
+ }
670
+
671
+ export function resolveClaudeCommand({ platform = process.platform } = {}) {
672
+ const probe = spawnSync(platform === 'win32' ? 'where' : 'which', ['claude'], {
673
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
592
674
  });
675
+ if (probe.status !== 0) return null;
676
+ return pickClaudeCommand(String(probe.stdout ?? '').split(/\r?\n/), { platform });
593
677
  }
594
678
 
595
679
  /**
@@ -602,14 +686,19 @@ function claudeSnapshotResolvable(snapshotRoot) {
602
686
  * argument the processor would act on is refused rather than escaped — a refused
603
687
  * install is recoverable, a silently relocated path is not.
604
688
  */
605
- export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
689
+ export function claudeCliInvocation(args, { platform = process.platform, environment = process.env, executable = null } = {}) {
606
690
  const argv = args.map((arg) => String(arg));
607
- if (platform !== 'win32') return { command: 'claude', args: argv };
608
- const unsafe = argv.find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
691
+ if (platform !== 'win32') return { command: executable ?? 'claude', args: argv };
692
+ const target = executable ?? 'claude.cmd';
693
+ // A native executable starts directly: no command processor, so no quoting rules
694
+ // to be wrong about.
695
+ if (/\.exe$/i.test(target)) return { command: target, args: argv };
696
+ const unsafe = [target, ...argv].find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
609
697
  if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
698
+ const quotedTarget = /\s/.test(target) ? `"${target}"` : target;
610
699
  return {
611
700
  command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
612
- args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
701
+ args: ['/d', '/s', '/c', `"${[quotedTarget, ...argv.map((arg) => `"${arg}"`)].join(' ')}"`],
613
702
  windowsVerbatimArguments: true,
614
703
  };
615
704
  }
@@ -623,62 +712,106 @@ export function claudeCliInvocation(args, { platform = process.platform, environ
623
712
  * the plugin the install just registered, so the tree has to go, and the caller
624
713
  * must not return until it has.
625
714
  */
626
- async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
627
- if (!Number.isInteger(pid) || pid <= 0) return;
715
+ async function killClaudeProcessTree(pid, { platform = process.platform, timeoutMs = CLAUDE_KILL_GRACE_MS } = {}) {
716
+ if (!Number.isInteger(pid) || pid <= 0) return false;
628
717
  const { spawn } = await import('node:child_process');
629
718
  if (platform === 'win32') {
630
- await new Promise((done) => {
719
+ return await new Promise((done) => {
631
720
  let settled = false;
632
- const finish = () => { if (!settled) { settled = true; done(); } };
721
+ const finish = (value) => { if (!settled) { settled = true; clearTimeout(timer); done(value); } };
722
+ const timer = setTimeout(() => { killer.kill(); finish(false); }, timeoutMs);
633
723
  const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
634
- killer.on('error', finish);
635
- killer.on('close', finish);
724
+ killer.on('error', () => finish(false));
725
+ killer.on('close', (code) => finish(code === 0));
636
726
  });
637
- return;
638
727
  }
639
- try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
728
+ try { process.kill(-pid, 'SIGKILL'); return true; } catch { /* fall through to the single pid */ }
729
+ try { process.kill(pid, 'SIGKILL'); return true; } catch { return false; }
640
730
  }
641
731
 
642
732
  /**
643
733
  * Run one CLI step to completion, or end it and its descendants on timeout.
644
734
  *
645
- * Resolves only once nothing from this step can still be running, so the next
646
- * step cannot be raced by the previous one. A timeout is reported in the shape
647
- * `execSync` uses, so the settle rule reads it the same way.
735
+ * Resolves only once nothing from this step can still be running, so the next step
736
+ * cannot be raced by the previous one — but it does resolve. A termination that
737
+ * does not land is reported as unconfirmed rather than waited on forever, and a
738
+ * timeout carries the shape `execSync` uses so the settle rule reads it the same
739
+ * way.
648
740
  */
649
- async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS } = {}) {
741
+ export async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS, executable = null, killGraceMs = CLAUDE_KILL_GRACE_MS, terminate = killClaudeProcessTree } = {}) {
650
742
  let invocation;
651
- try { invocation = claudeCliInvocation(args); }
652
- catch (error) { return { ok: false, timedOut: false, status: null, error }; }
743
+ try { invocation = claudeCliInvocation(args, { executable }); }
744
+ catch (error) { return { ok: false, timedOut: false, refused: true, terminated: null, status: null, detail: '', error }; }
653
745
  const { spawn } = await import('node:child_process');
654
- return await new Promise((resolve) => {
655
- const child = spawn(invocation.command, invocation.args, {
656
- stdio: ['ignore', 'pipe', 'pipe'],
657
- windowsHide: true,
658
- windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
659
- detached: process.platform !== 'win32',
660
- });
661
- let timedOut = false;
662
- let kill = null;
663
- let stderr = '';
664
- let settled = false;
665
- const finish = () => {
666
- if (settled) return;
667
- settled = true;
668
- clearTimeout(timer);
669
- const detail = stderr.slice(-400);
670
- // Never report a step finished while its tree may still be writing.
671
- const done = () => resolve(timedOut
672
- ? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
673
- : { ok: code === 0, timedOut: false, status: code, detail, error: null });
674
- if (kill) kill.then(done, done); else done();
675
- };
676
- let code = null;
677
- const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
678
- child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
679
- child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
680
- child.on('close', (value) => { code = value; finish(); });
746
+ const child = spawn(invocation.command, invocation.args, {
747
+ stdio: ['ignore', 'pipe', 'pipe'],
748
+ windowsHide: true,
749
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
750
+ detached: process.platform !== 'win32',
751
+ });
752
+
753
+ // Both pipes are drained. An unread pipe fills — 64 KiB is enough — and the
754
+ // child then blocks on write, so a step that prints a lot of output never exits
755
+ // and is killed at the ceiling for being talkative rather than for being stuck.
756
+ let output = '';
757
+ const drain = (chunk) => { output = (output + String(chunk)).slice(-400); };
758
+ child.stdout?.on('data', drain);
759
+ child.stderr?.on('data', drain);
760
+
761
+ const exited = new Promise((resolve) => {
762
+ child.on('close', (code) => resolve({ code, error: null }));
763
+ child.on('error', (error) => resolve({ code: null, error }));
681
764
  });
765
+ const bounded = async (promise, ms) => {
766
+ let timer;
767
+ try { return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]); }
768
+ finally { clearTimeout(timer); }
769
+ };
770
+
771
+ let timedOut = false;
772
+ let raiseCeiling;
773
+ const ceiling = new Promise((resolve) => { raiseCeiling = resolve; });
774
+ const timer = setTimeout(() => { timedOut = true; raiseCeiling(); }, timeoutMs);
775
+
776
+ const outcome = await Promise.race([exited.then(() => 'exited'), ceiling.then(() => 'ceiling')]);
777
+ clearTimeout(timer);
778
+
779
+ if (outcome === 'ceiling') {
780
+ // The kill is bounded too: a `taskkill` that itself hangs must not leave the
781
+ // step pending any more than one that returns non-zero.
782
+ const killLanded = await bounded(Promise.resolve().then(() => terminate(child.pid, { timeoutMs: killGraceMs })).catch(() => false), killGraceMs);
783
+ // Give a landing kill its moment to actually close the child, and take the
784
+ // close as proof if it arrives. Never wait on a termination that did not land:
785
+ // the step has to end either way, and saying the tree is unconfirmed is the
786
+ // honest result, not a pending promise.
787
+ const closed = await bounded(exited.then(() => true), killGraceMs);
788
+ const terminated = killLanded === true && closed === true;
789
+ if (!closed) {
790
+ child.stdout?.destroy();
791
+ child.stderr?.destroy();
792
+ child.unref();
793
+ }
794
+ return {
795
+ ok: false,
796
+ timedOut: true,
797
+ refused: false,
798
+ terminated,
799
+ status: null,
800
+ detail: output,
801
+ error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }),
802
+ };
803
+ }
804
+
805
+ const { code, error } = await exited;
806
+ return {
807
+ ok: code === 0 && !error,
808
+ timedOut: false,
809
+ refused: false,
810
+ terminated: null,
811
+ status: code ?? null,
812
+ detail: error ? `${output}${String(error?.message ?? error)}`.slice(-400) : output,
813
+ error: error ?? null,
814
+ };
682
815
  }
683
816
 
684
817
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
@@ -781,8 +914,12 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
781
914
  const marketplaceCurrent = registered?.source?.source === 'directory'
782
915
  && normalizedPath(registered.source.path) === normalizedPath(root)
783
916
  && normalizedPath(registered.installLocation) === normalizedPath(root);
917
+ // The same predicate the post-attempt check uses, dependency resolution
918
+ // included: a fast path that skipped it reported "already current" for a
919
+ // snapshot the status surface reads as not ready.
784
920
  if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
785
- && sameManagedClaudeFiles(root, entry.installPath))) {
921
+ && sameManagedClaudeFiles(root, entry.installPath)
922
+ && claudeSnapshotResolvable(entry.installPath))) {
786
923
  actions.push('cli: skipped (registered marketplace and snapshot already current)');
787
924
  return { ok: true, actions };
788
925
  }
@@ -793,14 +930,16 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
793
930
  // The CLI is best-effort, but a host that does not have it must not be told to
794
931
  // run it. The checkout entry already gates on this; doing it here too keeps the
795
932
  // two entries describing the same machine the same way.
796
- const { spawnSync } = await import('node:child_process');
797
- const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
798
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
799
- }).status === 0;
800
- if (!claudePresent) {
933
+ const claudeCommand = resolveClaudeCommand();
934
+ if (!claudeCommand) {
801
935
  actions.push('cli: skipped (claude not found)');
802
936
  return { ok: true, detected: false, actions };
803
937
  }
938
+ return refreshClaudePlugin({ home, root, actions, claudeCommand });
939
+ }
940
+
941
+ // Keep CLI transaction ordering testable without changing the operator's home.
942
+ export async function refreshClaudePlugin({ home, root, actions = [], claudeCommand, runStep = runClaudeStep }) {
804
943
  // Each step runs to completion — including ending the whole process tree on a
805
944
  // timeout — before the next one starts, and the settle window is the widest any
806
945
  // step asks for. Two things this replaces: an `uninstall` left running past its
@@ -815,25 +954,44 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
815
954
  let settleMs = 0;
816
955
  const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
817
956
 
818
- const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
957
+ const marketplace = await runStep(['plugin', 'marketplace', 'add', root], { executable: claudeCommand });
819
958
  noteStep(marketplace);
820
- actions.push(marketplace.ok
821
- ? 'cli: marketplace registered'
822
- : `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
959
+ if (!marketplace.ok) {
960
+ // Registering is what says where the plugin comes from. Removing the current
961
+ // one after it failed leaves the machine with neither, and that is exactly the
962
+ // shape a refused path produces — where nothing was even attempted. The
963
+ // existing integration is left exactly as it was.
964
+ actions.push(`cli: plugin refresh skipped — marketplace registration failed${marketplace.timedOut ? ' (timed out)' : ''}`);
965
+ return {
966
+ ok: true,
967
+ degraded: true,
968
+ code: 'CLAUDE_MARKETPLACE_UNAVAILABLE',
969
+ reason: `marketplace registration failed; the installed plugin was left untouched. Run: claude plugin marketplace add ${root}`,
970
+ actions,
971
+ };
972
+ }
973
+ actions.push('cli: marketplace registered');
823
974
 
824
975
  // `plugin install` on an already-installed plugin is a no-op and leaves a
825
976
  // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
826
977
  // always re-copies the current code.
827
- noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
978
+ const uninstall = await runStep(['plugin', 'uninstall', PLUGIN_KEY], { executable: claudeCommand });
979
+ noteStep(uninstall);
980
+ if (uninstall.timedOut && !uninstall.terminated) {
981
+ return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Uninstall termination could not be confirmed; plugin installation was not started.', actions };
982
+ }
828
983
 
829
984
  // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
830
985
  // Try without it first, fall back to the legacy flag. This is the step that
831
986
  // copies, so it carries the ceiling sized for the copy.
832
- let install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS });
987
+ let install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
833
988
  if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
834
- install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y'], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS });
989
+ install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y'], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
835
990
  }
836
991
  noteStep(install);
992
+ if (install.timedOut && !install.terminated) {
993
+ return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Install termination could not be confirmed; snapshot readiness was not accepted.', actions };
994
+ }
837
995
  actions.push(install.ok
838
996
  ? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
839
997
  : `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
@@ -844,13 +1002,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
844
1002
  // snapshot that `installStatus` reads. Saying so here is what lets the caller
845
1003
  // stop printing a checkmark for a state it never verified.
846
1004
  if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
847
- // The CLI can outlive the ceiling above. The install is a real copy of the
848
- // plugin tree — 163s measured on an idle machine, and ~6 minutes measured
849
- // during an activation, where the record landed well after any ceiling — and a
850
- // timed-out child on Windows is not in the shell's process tree, so a copy that
851
- // is still running keeps writing while this function has already given up on
852
- // it. Wait for the snapshot to settle before reporting it missing, or the
853
- // update says "not loaded" about a plugin that is loading.
1005
+ // Reconcile snapshot records after a timed-out attempt, without starting another CLI step.
854
1006
  const settleDeadline = Date.now() + settleMs;
855
1007
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
856
1008
  while (Date.now() < settleDeadline) {
@@ -1181,9 +1181,15 @@ export function defaultPayloadSmoke(dir, { nodePath = process.execPath, runner =
1181
1181
  // Keyword boundaries reject identifiers containing the keywords
1182
1182
  // ('legacy-import') and member calls (.from); captures stay on one line and
1183
1183
  // bounded — real specifiers never span lines.
1184
- const IMPORT_SPECIFIER_RE = /(?<![\w.\-])(?:from|import|require)\b\s*\(?\s*["']([^"'\n]{1,200})["']/g;
1185
-
1186
- function collectExternalSpecifiers(dir) {
1184
+ //
1185
+ // A quote before the keyword is rejected too. Writing the keyword as a string —
1186
+ // `['import', 'export', 'require'].includes(word)` — is ordinary JavaScript, and
1187
+ // without this the closing quote of `'import'` was taken for the opening quote of
1188
+ // a specifier: it captured the text between two keywords and the payload failed to
1189
+ // stage on code that imports nothing at all.
1190
+ const IMPORT_SPECIFIER_RE = /(?<![\w.\-'"`])(?:from|import|require)\b\s*\(?\s*["']([^"'\n]{1,200})["']/g;
1191
+
1192
+ export function collectExternalSpecifiers(dir) {
1187
1193
  const specifiers = new Set();
1188
1194
  const walk = (root) => {
1189
1195
  if (!existsSync(root)) return;
@@ -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.12';
39
+ export const RUNTIME_VERSION = '2.0.14';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([