@ran-sh/dsh-crew 2.0.11 → 2.0.13

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.11",
3
+ "version": "2.0.13",
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.11",
3
+ "version": "2.0.13",
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
7
  import { createRequire } 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';
@@ -18,10 +19,16 @@ const MARKETPLACE_NAME = 'dsh-crew';
18
19
  const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
19
20
  // A Claude Code plugin refresh is a real copy of the plugin tree, and its cost
20
21
  // tracks machine load: 163s measured idle, ~6 minutes measured during an
21
- // activation. The ceiling bounds the shell's patience, and the settle window
22
- // keeps watching afterwards, because a timed-out child on Windows is not in the
23
- // shell's process tree and goes on writing once the shell has given up.
24
- const CLAUDE_PLUGIN_TIMEOUT_MS = 300_000;
22
+ // activation. The install is the step that copies, so it gets the ceiling that
23
+ // has to fit that; `marketplace add` and `uninstall` measure ~3s each and keep a
24
+ // short one. Sizing the install ceiling below the copy is not a slow update, it is
25
+ // a broken integration: the tree is killed and Claude Code is left without the
26
+ // plugin the same run had just removed.
27
+ const CLAUDE_STEP_TIMEOUT_MS = 300_000;
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;
25
32
  const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
26
33
  const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
27
34
  // The one scope this installer writes, and therefore the only scope whose record
@@ -179,10 +186,11 @@ function claudePluginRootReady(root) {
179
186
  && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
180
187
  }
181
188
 
182
- 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 } = {}) {
183
190
  const files = [];
184
191
  let bytes = 0;
185
192
  let directories = 0;
193
+ let entries = 0;
186
194
  const add = (file, relativePath) => {
187
195
  const info = lstatSync(file);
188
196
  if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
@@ -193,13 +201,29 @@ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 *
193
201
  bytes += content.length;
194
202
  files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
195
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
+ };
196
220
  const walk = (directory, relativeDirectory, depth = 0) => {
197
221
  if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
198
222
  if (!existsSync(directory)) return;
199
223
  if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
200
224
  const info = lstatSync(directory);
201
225
  if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
202
- for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
226
+ for (const entry of listEntries(directory)) {
203
227
  const file = join(directory, entry.name);
204
228
  const relativePath = join(relativeDirectory, entry.name);
205
229
  if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
@@ -565,30 +589,60 @@ export function claudeSnapshotSettleMs(err) {
565
589
  /**
566
590
  * Whether the snapshot can actually run, not merely whether its files match.
567
591
  *
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.
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
+ * is asked from the entry itself, 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.
576
599
  */
577
600
  function claudeSnapshotResolvable(snapshotRoot) {
578
601
  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; }
602
+ const entry = join(snapshotRoot, 'src', 'server.mjs');
603
+ if (!existsSync(entry)) return false;
604
+ let source;
605
+ try { source = readFileSync(entry, 'utf8'); } catch { return false; }
606
+ const specifiers = [...source.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)]
607
+ .map((match) => match[1])
608
+ .filter((specifier) => !specifier.startsWith('.') && !specifier.startsWith('node:'));
609
+ if (!specifiers.length) return true;
610
+ let fromEntry;
611
+ try { fromEntry = createRequire(entry); } catch { return false; }
612
+ return specifiers.every((specifier) => {
613
+ try { return existsSync(fromEntry.resolve(specifier)); } catch { return false; }
589
614
  });
590
615
  }
591
616
 
617
+ /**
618
+ * The `claude` executable this machine actually has.
619
+ *
620
+ * `where claude` reports every match — the extensionless shim, `claude.cmd`, and
621
+ * `claude.exe` on an install that ships the native binary. Detection and execution
622
+ * are different questions: hardcoding `.cmd` meant a host with only the native
623
+ * executable was detected and then could not be run. A native executable is
624
+ * preferred because it starts without a command processor, so there is no quoting
625
+ * to get wrong.
626
+ */
627
+ export function pickClaudeCommand(candidates, { platform = process.platform } = {}) {
628
+ const list = (Array.isArray(candidates) ? candidates : []).map((line) => String(line).trim()).filter(Boolean);
629
+ if (!list.length) return null;
630
+ if (platform !== 'win32') return list[0];
631
+ // A native executable is preferred because it starts without a command
632
+ // processor, so there is no quoting to get wrong.
633
+ return list.find((candidate) => /\.exe$/i.test(candidate))
634
+ ?? list.find((candidate) => /\.(cmd|bat)$/i.test(candidate))
635
+ ?? list[0];
636
+ }
637
+
638
+ export function resolveClaudeCommand({ platform = process.platform } = {}) {
639
+ const probe = spawnSync(platform === 'win32' ? 'where' : 'which', ['claude'], {
640
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
641
+ });
642
+ if (probe.status !== 0) return null;
643
+ return pickClaudeCommand(String(probe.stdout ?? '').split(/\r?\n/), { platform });
644
+ }
645
+
592
646
  /**
593
647
  * How to run the `claude` CLI, as an executable plus an argument array.
594
648
  *
@@ -599,14 +653,19 @@ function claudeSnapshotResolvable(snapshotRoot) {
599
653
  * argument the processor would act on is refused rather than escaped — a refused
600
654
  * install is recoverable, a silently relocated path is not.
601
655
  */
602
- export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
656
+ export function claudeCliInvocation(args, { platform = process.platform, environment = process.env, executable = null } = {}) {
603
657
  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));
658
+ if (platform !== 'win32') return { command: executable ?? 'claude', args: argv };
659
+ const target = executable ?? 'claude.cmd';
660
+ // A native executable starts directly: no command processor, so no quoting rules
661
+ // to be wrong about.
662
+ if (/\.exe$/i.test(target)) return { command: target, args: argv };
663
+ const unsafe = [target, ...argv].find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
606
664
  if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
665
+ const quotedTarget = /\s/.test(target) ? `"${target}"` : target;
607
666
  return {
608
667
  command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
609
- args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
668
+ args: ['/d', '/s', '/c', `"${[quotedTarget, ...argv.map((arg) => `"${arg}"`)].join(' ')}"`],
610
669
  windowsVerbatimArguments: true,
611
670
  };
612
671
  }
@@ -620,62 +679,106 @@ export function claudeCliInvocation(args, { platform = process.platform, environ
620
679
  * the plugin the install just registered, so the tree has to go, and the caller
621
680
  * must not return until it has.
622
681
  */
623
- async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
624
- if (!Number.isInteger(pid) || pid <= 0) return;
682
+ async function killClaudeProcessTree(pid, { platform = process.platform, timeoutMs = CLAUDE_KILL_GRACE_MS } = {}) {
683
+ if (!Number.isInteger(pid) || pid <= 0) return false;
625
684
  const { spawn } = await import('node:child_process');
626
685
  if (platform === 'win32') {
627
- await new Promise((done) => {
686
+ return await new Promise((done) => {
628
687
  let settled = false;
629
- const finish = () => { if (!settled) { settled = true; done(); } };
688
+ const finish = (value) => { if (!settled) { settled = true; clearTimeout(timer); done(value); } };
689
+ const timer = setTimeout(() => { killer.kill(); finish(false); }, timeoutMs);
630
690
  const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
631
- killer.on('error', finish);
632
- killer.on('close', finish);
691
+ killer.on('error', () => finish(false));
692
+ killer.on('close', (code) => finish(code === 0));
633
693
  });
634
- return;
635
694
  }
636
- try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
695
+ try { process.kill(-pid, 'SIGKILL'); return true; } catch { /* fall through to the single pid */ }
696
+ try { process.kill(pid, 'SIGKILL'); return true; } catch { return false; }
637
697
  }
638
698
 
639
699
  /**
640
700
  * Run one CLI step to completion, or end it and its descendants on timeout.
641
701
  *
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.
702
+ * Resolves only once nothing from this step can still be running, so the next step
703
+ * cannot be raced by the previous one — but it does resolve. A termination that
704
+ * does not land is reported as unconfirmed rather than waited on forever, and a
705
+ * timeout carries the shape `execSync` uses so the settle rule reads it the same
706
+ * way.
645
707
  */
646
- async function runClaudeStep(args, { timeoutMs = CLAUDE_PLUGIN_TIMEOUT_MS } = {}) {
708
+ export async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS, executable = null, killGraceMs = CLAUDE_KILL_GRACE_MS, terminate = killClaudeProcessTree } = {}) {
647
709
  let invocation;
648
- try { invocation = claudeCliInvocation(args); }
649
- catch (error) { return { ok: false, timedOut: false, status: null, error }; }
710
+ try { invocation = claudeCliInvocation(args, { executable }); }
711
+ catch (error) { return { ok: false, timedOut: false, refused: true, terminated: null, status: null, detail: '', error }; }
650
712
  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(); });
713
+ const child = spawn(invocation.command, invocation.args, {
714
+ stdio: ['ignore', 'pipe', 'pipe'],
715
+ windowsHide: true,
716
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
717
+ detached: process.platform !== 'win32',
678
718
  });
719
+
720
+ // Both pipes are drained. An unread pipe fills — 64 KiB is enough — and the
721
+ // child then blocks on write, so a step that prints a lot of output never exits
722
+ // and is killed at the ceiling for being talkative rather than for being stuck.
723
+ let output = '';
724
+ const drain = (chunk) => { output = (output + String(chunk)).slice(-400); };
725
+ child.stdout?.on('data', drain);
726
+ child.stderr?.on('data', drain);
727
+
728
+ const exited = new Promise((resolve) => {
729
+ child.on('close', (code) => resolve({ code, error: null }));
730
+ child.on('error', (error) => resolve({ code: null, error }));
731
+ });
732
+ const bounded = async (promise, ms) => {
733
+ let timer;
734
+ try { return await Promise.race([promise, new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]); }
735
+ finally { clearTimeout(timer); }
736
+ };
737
+
738
+ let timedOut = false;
739
+ let raiseCeiling;
740
+ const ceiling = new Promise((resolve) => { raiseCeiling = resolve; });
741
+ const timer = setTimeout(() => { timedOut = true; raiseCeiling(); }, timeoutMs);
742
+
743
+ const outcome = await Promise.race([exited.then(() => 'exited'), ceiling.then(() => 'ceiling')]);
744
+ clearTimeout(timer);
745
+
746
+ if (outcome === 'ceiling') {
747
+ // The kill is bounded too: a `taskkill` that itself hangs must not leave the
748
+ // step pending any more than one that returns non-zero.
749
+ const killLanded = await bounded(Promise.resolve().then(() => terminate(child.pid, { timeoutMs: killGraceMs })).catch(() => false), killGraceMs);
750
+ // Give a landing kill its moment to actually close the child, and take the
751
+ // close as proof if it arrives. Never wait on a termination that did not land:
752
+ // the step has to end either way, and saying the tree is unconfirmed is the
753
+ // honest result, not a pending promise.
754
+ const closed = await bounded(exited.then(() => true), killGraceMs);
755
+ const terminated = killLanded === true && closed === true;
756
+ if (!closed) {
757
+ child.stdout?.destroy();
758
+ child.stderr?.destroy();
759
+ child.unref();
760
+ }
761
+ return {
762
+ ok: false,
763
+ timedOut: true,
764
+ refused: false,
765
+ terminated,
766
+ status: null,
767
+ detail: output,
768
+ error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }),
769
+ };
770
+ }
771
+
772
+ const { code, error } = await exited;
773
+ return {
774
+ ok: code === 0 && !error,
775
+ timedOut: false,
776
+ refused: false,
777
+ terminated: null,
778
+ status: code ?? null,
779
+ detail: error ? `${output}${String(error?.message ?? error)}`.slice(-400) : output,
780
+ error: error ?? null,
781
+ };
679
782
  }
680
783
 
681
784
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
@@ -778,8 +881,12 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
778
881
  const marketplaceCurrent = registered?.source?.source === 'directory'
779
882
  && normalizedPath(registered.source.path) === normalizedPath(root)
780
883
  && normalizedPath(registered.installLocation) === normalizedPath(root);
884
+ // The same predicate the post-attempt check uses, dependency resolution
885
+ // included: a fast path that skipped it reported "already current" for a
886
+ // snapshot the status surface reads as not ready.
781
887
  if (marketplaceCurrent && installedEntries.some((entry) => entry?.scope === CLAUDE_PLUGIN_SCOPE
782
- && sameManagedClaudeFiles(root, entry.installPath))) {
888
+ && sameManagedClaudeFiles(root, entry.installPath)
889
+ && claudeSnapshotResolvable(entry.installPath))) {
783
890
  actions.push('cli: skipped (registered marketplace and snapshot already current)');
784
891
  return { ok: true, actions };
785
892
  }
@@ -790,14 +897,16 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
790
897
  // The CLI is best-effort, but a host that does not have it must not be told to
791
898
  // run it. The checkout entry already gates on this; doing it here too keeps the
792
899
  // 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) {
900
+ const claudeCommand = resolveClaudeCommand();
901
+ if (!claudeCommand) {
798
902
  actions.push('cli: skipped (claude not found)');
799
903
  return { ok: true, detected: false, actions };
800
904
  }
905
+ return refreshClaudePlugin({ home, root, actions, claudeCommand });
906
+ }
907
+
908
+ // Keep CLI transaction ordering testable without changing the operator's home.
909
+ export async function refreshClaudePlugin({ home, root, actions = [], claudeCommand, runStep = runClaudeStep }) {
801
910
  // Each step runs to completion — including ending the whole process tree on a
802
911
  // timeout — before the next one starts, and the settle window is the widest any
803
912
  // step asks for. Two things this replaces: an `uninstall` left running past its
@@ -812,24 +921,44 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
812
921
  let settleMs = 0;
813
922
  const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
814
923
 
815
- const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
924
+ const marketplace = await runStep(['plugin', 'marketplace', 'add', root], { executable: claudeCommand });
816
925
  noteStep(marketplace);
817
- actions.push(marketplace.ok
818
- ? 'cli: marketplace registered'
819
- : `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
926
+ if (!marketplace.ok) {
927
+ // Registering is what says where the plugin comes from. Removing the current
928
+ // one after it failed leaves the machine with neither, and that is exactly the
929
+ // shape a refused path produces — where nothing was even attempted. The
930
+ // existing integration is left exactly as it was.
931
+ actions.push(`cli: plugin refresh skipped — marketplace registration failed${marketplace.timedOut ? ' (timed out)' : ''}`);
932
+ return {
933
+ ok: true,
934
+ degraded: true,
935
+ code: 'CLAUDE_MARKETPLACE_UNAVAILABLE',
936
+ reason: `marketplace registration failed; the installed plugin was left untouched. Run: claude plugin marketplace add ${root}`,
937
+ actions,
938
+ };
939
+ }
940
+ actions.push('cli: marketplace registered');
820
941
 
821
942
  // `plugin install` on an already-installed plugin is a no-op and leaves a
822
943
  // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
823
944
  // always re-copies the current code.
824
- noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
945
+ const uninstall = await runStep(['plugin', 'uninstall', PLUGIN_KEY], { executable: claudeCommand });
946
+ noteStep(uninstall);
947
+ if (uninstall.timedOut && !uninstall.terminated) {
948
+ return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Uninstall termination could not be confirmed; plugin installation was not started.', actions };
949
+ }
825
950
 
826
951
  // 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]);
952
+ // Try without it first, fall back to the legacy flag. This is the step that
953
+ // copies, so it carries the ceiling sized for the copy.
954
+ let install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
829
955
  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']);
956
+ install = await runStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y'], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS, executable: claudeCommand });
831
957
  }
832
958
  noteStep(install);
959
+ if (install.timedOut && !install.terminated) {
960
+ return { ok: false, code: 'CLAUDE_TERMINATION_UNCONFIRMED', reason: 'Install termination could not be confirmed; snapshot readiness was not accepted.', actions };
961
+ }
833
962
  actions.push(install.ok
834
963
  ? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
835
964
  : `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
@@ -840,13 +969,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
840
969
  // snapshot that `installStatus` reads. Saying so here is what lets the caller
841
970
  // stop printing a checkmark for a state it never verified.
842
971
  if (claudeSnapshotReady(home, root, { scope: CLAUDE_PLUGIN_SCOPE })) return { ok: true, actions };
843
- // The CLI can outlive the ceiling above. The install is a real copy of the
844
- // plugin tree — 163s measured on an idle machine, and ~6 minutes measured
845
- // during an activation, where the record landed well after any ceiling — and a
846
- // timed-out child on Windows is not in the shell's process tree, so a copy that
847
- // is still running keeps writing while this function has already given up on
848
- // it. Wait for the snapshot to settle before reporting it missing, or the
849
- // update says "not loaded" about a plugin that is loading.
972
+ // Reconcile snapshot records after a timed-out attempt, without starting another CLI step.
850
973
  const settleDeadline = Date.now() + settleMs;
851
974
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
852
975
  while (Date.now() < settleDeadline) {
@@ -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.11';
39
+ export const RUNTIME_VERSION = '2.0.13';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([