claude-dev-env 2.15.0 → 2.15.1

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.
Files changed (28) hide show
  1. package/_shared/advisor/scripts/tier_model_ids.py +6 -2
  2. package/bin/install.mjs +35 -32
  3. package/bin/install.test.mjs +42 -8
  4. package/package.json +2 -2
  5. package/scripts/AGENTS.md +0 -7
  6. package/scripts/claude_chain_runner.py +49 -2
  7. package/scripts/invoke_code_review.py +48 -51
  8. package/scripts/resolve_worker_spawn.py +42 -47
  9. package/scripts/test_claude_chain_runner.py +28 -0
  10. package/scripts/test_dispatcher_profile_import.py +184 -0
  11. package/scripts/test_resolve_worker_spawn.py +119 -1
  12. package/scripts/test_validate_instruction_pairs.py +30 -0
  13. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
  14. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
  15. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
  16. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
  17. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
  18. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
  19. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
  20. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
  21. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
  22. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
  23. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
  24. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
  25. package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
  26. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
  27. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
  28. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
@@ -30,8 +30,12 @@ from collections.abc import Mapping
30
30
  from pathlib import Path
31
31
 
32
32
  _config_directory = str(Path(__file__).resolve().parent / "config")
33
- if _config_directory not in sys.path:
34
- sys.path.insert(0, _config_directory)
33
+ sys.path[:] = [
34
+ each_existing_entry
35
+ for each_existing_entry in sys.path
36
+ if each_existing_entry != _config_directory
37
+ ]
38
+ sys.path[:0] = [_config_directory]
35
39
 
36
40
  from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
37
41
  ALL_CLI_MODEL_ID_BY_TIER,
package/bin/install.mjs CHANGED
@@ -114,6 +114,35 @@ const RETIRED_SKILL_REASON_LABEL = 'retired';
114
114
  const STALE_FILE_REASON_LABEL = 'stale';
115
115
  const MANIFEST_MANAGED_PERMISSIONS_KEY = 'managedPermissions';
116
116
 
117
+ /**
118
+ * Managed-home-relative directories this package leaves to whoever deployed
119
+ * them: it writes nothing inside them and prunes nothing out of them.
120
+ *
121
+ * `scripts/profile-isolation-launchers` is the live CLI profile launcher. Its
122
+ * executable modules are deployed from a separate source, so a payload covering
123
+ * only part of that tree left config and code out of step. This package stopped
124
+ * shipping its part, and the stale-file prune reads a path the package no longer
125
+ * writes as stale, which would move the live files aside on the next install.
126
+ * Naming the directory here keeps both halves of the tree with their owner.
127
+ */
128
+ const RETAINED_UNMANAGED_RELATIVE_PATHS = [
129
+ 'scripts/profile-isolation-launchers',
130
+ ];
131
+
132
+ /**
133
+ * Report whether a path a prior manifest recorded sits inside a directory this
134
+ * package leaves to another owner.
135
+ *
136
+ * @param {string} candidatePath The absolute path the prior manifest recorded.
137
+ * @param {string} managedHomeDirectory The managed home the relative names resolve against.
138
+ * @returns {boolean} True when the path sits inside a retained unmanaged directory.
139
+ */
140
+ function isRetainedUnmanagedPath(candidatePath, managedHomeDirectory) {
141
+ return RETAINED_UNMANAGED_RELATIVE_PATHS.some(
142
+ relativePath => isInsideDirectory(candidatePath, join(managedHomeDirectory, relativePath)),
143
+ );
144
+ }
145
+
117
146
  export const CORE_INCLUDE_DIRECTORIES = [
118
147
  'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
119
148
  ];
@@ -832,6 +861,7 @@ export function pruneStaleInstalledFiles(
832
861
  const stalePath = resolve(priorFile);
833
862
  if (!isInsideDirectory(stalePath, resolvedRoot)) continue;
834
863
  if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
864
+ if (isRetainedUnmanagedPath(stalePath, managedHomeDirectory)) continue;
835
865
  if (!isMovableStaleFile(stalePath)) continue;
836
866
  const backupRelativePath = relative(resolvedRoot, stalePath);
837
867
  const didMove = moveIntoRunBackup(
@@ -2590,13 +2620,15 @@ function realPathOrSelf(filesystemPath) {
2590
2620
  }
2591
2621
 
2592
2622
  /**
2593
- * Load profile id → directoryName from the A1 launcher contract when present.
2594
- * Falls back to identity mapping when the file is missing, unreadable, or empty.
2623
+ * Load profile id → directoryName for the install targets this package resolves.
2624
+ *
2625
+ * Each profile's directory carries the profile's own name, so the map is an
2626
+ * identity over the ids a multi-target install accepts.
2595
2627
  *
2596
2628
  * @returns {Record<string, string>}
2597
2629
  */
2598
2630
  function loadDirectoryNameByProfileId() {
2599
- const fallbackDirectoryNameByProfileId = {
2631
+ return {
2600
2632
  main: 'main',
2601
2633
  editor: 'editor',
2602
2634
  mel: 'mel',
@@ -2604,35 +2636,6 @@ function loadDirectoryNameByProfileId() {
2604
2636
  master: 'master',
2605
2637
  kimi: 'kimi',
2606
2638
  };
2607
- const manifestPath = join(
2608
- PACKAGE_ROOT,
2609
- 'scripts',
2610
- 'profile-isolation-launchers',
2611
- 'config',
2612
- 'profiles.manifest.json',
2613
- );
2614
- if (!existsSync(manifestPath)) {
2615
- return fallbackDirectoryNameByProfileId;
2616
- }
2617
- try {
2618
- const document = JSON.parse(readFileSync(manifestPath, 'utf8'));
2619
- /** @type {Record<string, string>} */
2620
- const directoryNameByProfileId = {};
2621
- const profiles = document && typeof document === 'object' ? document.profiles : null;
2622
- if (profiles && typeof profiles === 'object') {
2623
- for (const [eachProfileId, eachProfile] of Object.entries(profiles)) {
2624
- if (eachProfile && typeof eachProfile === 'object' && typeof eachProfile.directoryName === 'string') {
2625
- directoryNameByProfileId[eachProfileId] = eachProfile.directoryName;
2626
- }
2627
- }
2628
- }
2629
- if (Object.keys(directoryNameByProfileId).length === 0) {
2630
- return fallbackDirectoryNameByProfileId;
2631
- }
2632
- return directoryNameByProfileId;
2633
- } catch {
2634
- return fallbackDirectoryNameByProfileId;
2635
- }
2636
2639
  }
2637
2640
 
2638
2641
  /**
@@ -1281,25 +1281,59 @@ const README_BASENAME_PATTERN = /^readme\.md$/i;
1281
1281
 
1282
1282
 
1283
1283
  /**
1284
- * Build a sandbox holding an installed skills root and a run backup root.
1284
+ * Build a sandbox holding one installed managed root and a run backup root.
1285
1285
  *
1286
- * @param {object} installedFiles Forward-slash relative paths under the skills root mapped to contents.
1287
- * @returns {{root: string, skillsRoot: string, backupRoot: string}} The sandbox paths.
1286
+ * @param {object} installedFiles Forward-slash relative paths under the installed root mapped to contents.
1287
+ * @param {string} [managedRootName] The managed top-level directory the files sit under.
1288
+ * @returns {{root: string, skillsRoot: string, installedRoot: string, backupRoot: string}} The sandbox paths.
1288
1289
  */
1289
- function createStalePruneSandbox(installedFiles) {
1290
+ function createStalePruneSandbox(installedFiles, managedRootName = 'skills') {
1290
1291
  const root = mkdtempSync(join(tmpdir(), 'cdev-stale-prune-'));
1291
- const skillsRoot = join(root, 'skills');
1292
+ const installedRoot = join(root, managedRootName);
1292
1293
  const backupRoot = join(root, 'pruned', 'run-timestamp');
1293
- mkdirSync(skillsRoot, { recursive: true });
1294
+ mkdirSync(installedRoot, { recursive: true });
1294
1295
  for (const [relativePath, contents] of Object.entries(installedFiles)) {
1295
- const targetPath = join(skillsRoot, relativePath);
1296
+ const targetPath = join(installedRoot, relativePath);
1296
1297
  mkdirSync(dirname(targetPath), { recursive: true });
1297
1298
  writeFileSync(targetPath, contents);
1298
1299
  }
1299
- return { root, skillsRoot, backupRoot };
1300
+ return { root, skillsRoot: installedRoot, installedRoot, backupRoot };
1300
1301
  }
1301
1302
 
1302
1303
 
1304
+ test('pruneStaleInstalledFiles leaves a retained unmanaged path the package stopped shipping in place', () => {
1305
+ const sandbox = createStalePruneSandbox({
1306
+ 'profile-isolation-launchers/config/mcp-bundles.json': '{"schemaVersion":1}\n',
1307
+ 'sync-to-cursor.py': 'print("shipped")\n',
1308
+ }, 'scripts');
1309
+ try {
1310
+ const shippedFilePath = join(sandbox.installedRoot, 'sync-to-cursor.py');
1311
+ const retainedFilePath = join(
1312
+ sandbox.installedRoot, 'profile-isolation-launchers', 'config', 'mcp-bundles.json',
1313
+ );
1314
+
1315
+ const pruneOutcome = pruneStaleInstalledFiles(
1316
+ [shippedFilePath, retainedFilePath],
1317
+ [shippedFilePath],
1318
+ sandbox.installedRoot,
1319
+ sandbox.backupRoot,
1320
+ { managedHomeDirectory: sandbox.root },
1321
+ );
1322
+
1323
+ assert.equal(pruneOutcome.prunedCount, 0, 'a retained unmanaged path never counts as pruned');
1324
+ assert.deepEqual(pruneOutcome.failedPaths, [], 'skipping a retained path reports no failed move');
1325
+ assert.equal(
1326
+ existsSync(retainedFilePath),
1327
+ true,
1328
+ 'the launcher file a prior install recorded stays where the live launcher reads it',
1329
+ );
1330
+ assert.equal(existsSync(shippedFilePath), true, 'a file this run wrote stays in place');
1331
+ } finally {
1332
+ rmSync(sandbox.root, { recursive: true, force: true });
1333
+ }
1334
+ });
1335
+
1336
+
1303
1337
  /**
1304
1338
  * Run a callable with console.warn captured, returning its value and the warnings.
1305
1339
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "codex-compat": "bin/codex-compat.mjs"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"scripts/profile-isolation-launchers/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
11
+ "test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
12
12
  },
13
13
  "files": [
14
14
  "bin/",
package/scripts/AGENTS.md CHANGED
@@ -37,7 +37,6 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
37
37
  |---|---|
38
38
  | `ci/` | CI-only adapters; `windows-installer-lifecycle.ps1` runs the Node installer 16-check driver under isolated HOME/USERPROFILE/GIT_CONFIG_GLOBAL and writes bounded evidence |
39
39
  | `dev_env_scripts_constants/` | Named constants (`timing.py`, `grok_worker_constants.py`, …) for scripts in this directory, including worker-advisor placeholder launcher/model/effort, four verdict signals, correction cap, and advisor timeout |
40
- | `profile-isolation-launchers/` | Profile-launcher contract plus MCP activation, Windows shortcut source semantics, and version preflight: profiles manifest, shared-path allowlist, profile resolver, `CLAUDE_CONFIG_DIR` authority, lean/full MCP bundle materialization into profile `mcp.json`, pure CLI/Desktop version-compatibility classifier consumed by launcher preflight, read-only shortcut inventory and preview reconcile under `windows/`. Live launcher execution, provenance pin, live shortcut mutation, and L1 deploy stay residual |
41
40
  | `sync_to_cursor/` | Package that builds Cursor `.mdc` files from Claude rules and docs |
42
41
  | `tests/` | pytest suite for the Python scripts and Pester (`*.Tests.ps1`) suite for the PowerShell scripts in this directory |
43
42
 
@@ -49,12 +48,6 @@ Python scripts (pytest):
49
48
  python -m pytest packages/claude-dev-env/scripts/tests/
50
49
  ```
51
50
 
52
- Profile-isolation launcher contract (node:test):
53
-
54
- ```bash
55
- node --test packages/claude-dev-env/scripts/profile-isolation-launchers/**/*.test.mjs
56
- ```
57
-
58
51
  PowerShell scripts (Pester 5+, `*.Tests.ps1`):
59
52
 
60
53
  ```powershell
@@ -48,7 +48,9 @@ import os
48
48
  import subprocess
49
49
  import sys
50
50
  import tempfile
51
- from collections.abc import Callable, Sequence
51
+ import threading
52
+ from collections.abc import Callable, Iterator, Sequence
53
+ from contextlib import contextmanager
52
54
  from dataclasses import dataclass, field
53
55
  from pathlib import Path
54
56
  from types import ModuleType
@@ -327,6 +329,35 @@ class WeeklyUsageAccountReport(Protocol):
327
329
 
328
330
 
329
331
  chain_subprocess_runner = _run_captured_subprocess
332
+ _shared_chain_subprocess_lock = threading.Lock()
333
+
334
+
335
+ def chain_subprocess_runner_lock() -> threading.Lock:
336
+ """Return the lock for adapters that temporarily configure the runner."""
337
+ return _shared_chain_subprocess_lock
338
+
339
+
340
+ @contextmanager
341
+ def override_chain_subprocess_runner(
342
+ replacement_runner: Callable[..., subprocess.CompletedProcess[str]],
343
+ ) -> Iterator[Callable[..., subprocess.CompletedProcess[str]]]:
344
+ """Temporarily replace the chain subprocess seam under its shared lock.
345
+
346
+ Args:
347
+ replacement_runner: Callable used for subprocess invocations during the
348
+ context.
349
+
350
+ Yields:
351
+ The runner that was active before the replacement.
352
+ """
353
+ global chain_subprocess_runner
354
+ with chain_subprocess_runner_lock():
355
+ previous_runner = chain_subprocess_runner
356
+ chain_subprocess_runner = replacement_runner
357
+ try:
358
+ yield previous_runner
359
+ finally:
360
+ chain_subprocess_runner = previous_runner
330
361
 
331
362
 
332
363
  def _load_chain_usage_module() -> ModuleType:
@@ -1138,6 +1169,22 @@ def _persist_served_affinity(
1138
1169
  return
1139
1170
 
1140
1171
 
1172
+ def _run_cli_chain(
1173
+ all_claude_arguments: list[str],
1174
+ *,
1175
+ timeout_seconds: int,
1176
+ stdin_text: str | None,
1177
+ routing_mode: str,
1178
+ ) -> ChainInvocationOutcome:
1179
+ """Run the CLI-selected chain arguments through the public runner."""
1180
+ return run_claude(
1181
+ all_claude_arguments,
1182
+ timeout_seconds=timeout_seconds,
1183
+ stdin_text=stdin_text,
1184
+ routing_mode=routing_mode,
1185
+ )
1186
+
1187
+
1141
1188
  def _build_argument_parser() -> argparse.ArgumentParser:
1142
1189
  parser = argparse.ArgumentParser(
1143
1190
  description="Run a claude invocation through the fallback chain."
@@ -1206,7 +1253,7 @@ def main(all_command_arguments: list[str]) -> int:
1206
1253
  all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
1207
1254
  maybe_stdin_text = _read_piped_stdin_text()
1208
1255
  try:
1209
- chain_outcome = run_claude(
1256
+ chain_outcome = _run_cli_chain(
1210
1257
  all_claude_arguments,
1211
1258
  timeout_seconds=parsed_arguments.timeout_seconds,
1212
1259
  stdin_text=maybe_stdin_text,
@@ -28,20 +28,30 @@ import argparse
28
28
  import json
29
29
  import subprocess
30
30
  import sys
31
- import threading
32
31
  from collections.abc import Callable, Sequence
33
32
  from dataclasses import dataclass
34
33
  from pathlib import Path
35
34
 
36
- if str(Path(__file__).resolve().parent) not in sys.path:
37
- sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ _scripts_directory_path = Path(__file__).resolve().parent
36
+ _scripts_directory = str(_scripts_directory_path)
37
+ sys.path[:] = [
38
+ each_existing_entry
39
+ for each_existing_entry in sys.path
40
+ if each_existing_entry != _scripts_directory
41
+ ]
42
+ sys.path[:0] = [_scripts_directory]
38
43
 
39
44
  _advisor_scripts_path = str(
40
- Path(__file__).resolve().parent.parent / "_shared" / "advisor" / "scripts"
45
+ _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
41
46
  )
42
- if _advisor_scripts_path not in sys.path:
43
- sys.path.insert(0, _advisor_scripts_path)
47
+ sys.path[:] = [
48
+ each_existing_entry
49
+ for each_existing_entry in sys.path
50
+ if each_existing_entry != _advisor_scripts_path
51
+ ]
52
+ sys.path[:0] = [_advisor_scripts_path]
44
53
 
54
+ from tier_model_ids import detect_host_profile # noqa: E402
45
55
  from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
46
56
  HOST_PROFILE_CLAUDE,
47
57
  )
@@ -93,14 +103,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
93
103
  from dev_env_scripts_constants.timing import ( # noqa: E402
94
104
  DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
95
105
  )
96
- from tier_model_ids import detect_host_profile # noqa: E402
97
-
98
- _CHAIN_RUNNER_LOCK = threading.Lock()
99
-
100
-
101
- def _chain_runner_lock() -> threading.Lock:
102
- """Return the module lock that serializes chain-runner stdin/cwd swaps."""
103
- return _CHAIN_RUNNER_LOCK
104
106
 
105
107
 
106
108
  @dataclass(frozen=True)
@@ -302,44 +304,39 @@ def _run_claude_with_empty_stdin(
302
304
  working_directory: Path,
303
305
  ) -> ChainInvocationOutcome:
304
306
  working_directory_path = str(working_directory)
305
- with _CHAIN_RUNNER_LOCK:
306
- previous_runner: TextCapturingSubprocessRunner = (
307
- chain_runner.chain_subprocess_runner
308
- )
309
307
 
310
- def _runner_with_empty_stdin(
311
- all_invocation_tokens: Sequence[str],
312
- *all_positionals: object,
313
- **all_keywords: object,
314
- ) -> subprocess.CompletedProcess[str]:
315
- del all_positionals
316
- maybe_timeout = all_keywords.get("timeout")
317
- timeout_for_run: float | None
318
- if isinstance(maybe_timeout, (int, float)):
319
- timeout_for_run = float(maybe_timeout)
320
- else:
321
- timeout_for_run = None
322
- forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
323
- completed_process: subprocess.CompletedProcess[str] = previous_runner(
324
- all_invocation_tokens,
325
- capture_output=True,
326
- text=True,
327
- timeout=timeout_for_run,
328
- check=False,
329
- stdin=subprocess.DEVNULL,
330
- cwd=working_directory_path,
331
- **forwarded_text_codec,
332
- )
333
- return completed_process
334
-
335
- empty_stdin_runner: TextCapturingSubprocessRunner = _runner_with_empty_stdin
336
- setattr(chain_runner, "chain_subprocess_runner", empty_stdin_runner)
337
- try:
338
- return review_claude_runner(
339
- all_claude_arguments, timeout_seconds=timeout_seconds
340
- )
341
- finally:
342
- setattr(chain_runner, "chain_subprocess_runner", previous_runner)
308
+ def _runner_with_empty_stdin(
309
+ all_invocation_tokens: Sequence[str],
310
+ *all_positionals: object,
311
+ **all_keywords: object,
312
+ ) -> subprocess.CompletedProcess[str]:
313
+ del all_positionals
314
+ maybe_timeout = all_keywords.get("timeout")
315
+ timeout_for_run: float | None
316
+ if isinstance(maybe_timeout, (int, float)):
317
+ timeout_for_run = float(maybe_timeout)
318
+ else:
319
+ timeout_for_run = None
320
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
321
+ completed_process: subprocess.CompletedProcess[str] = previous_runner(
322
+ all_invocation_tokens,
323
+ capture_output=True,
324
+ text=True,
325
+ timeout=timeout_for_run,
326
+ check=False,
327
+ stdin=subprocess.DEVNULL,
328
+ cwd=working_directory_path,
329
+ **forwarded_text_codec,
330
+ )
331
+ return completed_process
332
+
333
+ empty_stdin_runner: TextCapturingSubprocessRunner = _runner_with_empty_stdin
334
+ with chain_runner.override_chain_subprocess_runner(
335
+ empty_stdin_runner
336
+ ) as previous_runner:
337
+ return review_claude_runner(
338
+ all_claude_arguments, timeout_seconds=timeout_seconds
339
+ )
343
340
 
344
341
 
345
342
  def _in_session_outcome() -> CodeReviewOutcome:
@@ -26,21 +26,31 @@ import argparse
26
26
  import json
27
27
  import subprocess
28
28
  import sys
29
- import threading
30
29
  from collections.abc import Callable, Sequence
31
30
  from dataclasses import dataclass
32
31
  from pathlib import Path
33
32
  from typing import IO
34
33
 
35
- if str(Path(__file__).resolve().parent) not in sys.path:
36
- sys.path.insert(0, str(Path(__file__).resolve().parent))
34
+ _scripts_directory_path = Path(__file__).resolve().parent
35
+ _scripts_directory = str(_scripts_directory_path)
36
+ sys.path[:] = [
37
+ each_existing_entry
38
+ for each_existing_entry in sys.path
39
+ if each_existing_entry != _scripts_directory
40
+ ]
41
+ sys.path[:0] = [_scripts_directory]
37
42
 
38
43
  _advisor_scripts_path = str(
39
- Path(__file__).resolve().parent.parent / "_shared" / "advisor" / "scripts"
44
+ _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
40
45
  )
41
- if _advisor_scripts_path not in sys.path:
42
- sys.path.insert(0, _advisor_scripts_path)
46
+ sys.path[:] = [
47
+ each_existing_entry
48
+ for each_existing_entry in sys.path
49
+ if each_existing_entry != _advisor_scripts_path
50
+ ]
51
+ sys.path[:0] = [_advisor_scripts_path]
43
52
 
53
+ from tier_model_ids import detect_host_profile # noqa: E402
44
54
  from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
45
55
  HOST_PROFILE_CLAUDE,
46
56
  )
@@ -94,14 +104,6 @@ from grok_headless_runner import ( # noqa: E402
94
104
  run_headless_worker,
95
105
  )
96
106
  from grok_worker_preflight import PreflightOutcome, run_preflight # noqa: E402
97
- from tier_model_ids import detect_host_profile # noqa: E402
98
-
99
- _HEADLESS_CHAIN_RUNNER_LOCK = threading.Lock()
100
-
101
-
102
- def _headless_chain_runner_lock() -> threading.Lock:
103
- """Return the module lock that serializes headless chain-runner swaps."""
104
- return _HEADLESS_CHAIN_RUNNER_LOCK
105
107
 
106
108
 
107
109
  @dataclass(frozen=True)
@@ -184,41 +186,34 @@ def _run_claude_with_headless_overrides(
184
186
  prompt_stdin: IO[str],
185
187
  ) -> ChainInvocationOutcome:
186
188
  working_directory_path = str(working_directory)
187
- with _HEADLESS_CHAIN_RUNNER_LOCK:
188
- previous_runner: TextCapturingSubprocessRunner = (
189
- chain_runner.chain_subprocess_runner
190
- )
191
189
 
192
- def _runner_with_headless_overrides(
193
- all_invocation_tokens: Sequence[str],
194
- *all_positionals: object,
195
- **all_keywords: object,
196
- ) -> subprocess.CompletedProcess[str]:
197
- del all_positionals
198
- prompt_stdin.seek(0)
199
- forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
200
- completed_process: subprocess.CompletedProcess[str] = previous_runner(
201
- all_invocation_tokens,
202
- capture_output=True,
203
- text=True,
204
- timeout=_timeout_seconds_from_keywords(all_keywords),
205
- check=False,
206
- stdin=prompt_stdin,
207
- cwd=working_directory_path,
208
- **forwarded_text_codec,
209
- )
210
- return completed_process
211
-
212
- headless_runner: TextCapturingSubprocessRunner = (
213
- _runner_with_headless_overrides
190
+ def _runner_with_headless_overrides(
191
+ all_invocation_tokens: Sequence[str],
192
+ *all_positionals: object,
193
+ **all_keywords: object,
194
+ ) -> subprocess.CompletedProcess[str]:
195
+ del all_positionals
196
+ prompt_stdin.seek(0)
197
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
198
+ completed_process: subprocess.CompletedProcess[str] = previous_runner(
199
+ all_invocation_tokens,
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=_timeout_seconds_from_keywords(all_keywords),
203
+ check=False,
204
+ stdin=prompt_stdin,
205
+ cwd=working_directory_path,
206
+ **forwarded_text_codec,
207
+ )
208
+ return completed_process
209
+
210
+ headless_runner: TextCapturingSubprocessRunner = _runner_with_headless_overrides
211
+ with chain_runner.override_chain_subprocess_runner(
212
+ headless_runner
213
+ ) as previous_runner:
214
+ return spawn_claude_runner(
215
+ all_claude_arguments, timeout_seconds=timeout_seconds
214
216
  )
215
- setattr(chain_runner, "chain_subprocess_runner", headless_runner)
216
- try:
217
- return spawn_claude_runner(
218
- all_claude_arguments, timeout_seconds=timeout_seconds
219
- )
220
- finally:
221
- setattr(chain_runner, "chain_subprocess_runner", previous_runner)
222
217
 
223
218
 
224
219
  def _prompt_file_unreadable_outcome(
@@ -17,6 +17,8 @@ import claude_chain_runner as runner # noqa: E402
17
17
  import claude_chain_usage as chain_usage # noqa: E402
18
18
  from claude_chain_runner import ( # noqa: E402
19
19
  ChainEntry,
20
+ chain_subprocess_runner_lock,
21
+ override_chain_subprocess_runner,
20
22
  default_affinity_state_path,
21
23
  extract_resume_session_id,
22
24
  load_affinity_store,
@@ -78,6 +80,32 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
78
80
 
79
81
  _LARGE_CAPTURE_BYTE_COUNT = 400_000
80
82
  _LARGE_CAPTURE_MARKER = "X"
83
+
84
+
85
+ def test_chain_subprocess_runner_lock_is_shared() -> None:
86
+ first_lock = chain_subprocess_runner_lock()
87
+ second_lock = chain_subprocess_runner_lock()
88
+
89
+ assert first_lock is second_lock
90
+
91
+
92
+ def test_override_chain_subprocess_runner_restores_previous_runner() -> None:
93
+ previous_runner = runner.chain_subprocess_runner
94
+
95
+ def replacement_runner(
96
+ all_invocation_tokens: list[str],
97
+ **all_keywords: object,
98
+ ) -> subprocess.CompletedProcess[str]:
99
+ del all_invocation_tokens, all_keywords
100
+ return subprocess.CompletedProcess([], 0, "", "")
101
+
102
+ with override_chain_subprocess_runner(replacement_runner) as active_runner:
103
+ assert active_runner is previous_runner
104
+ assert runner.chain_subprocess_runner is replacement_runner
105
+
106
+ assert runner.chain_subprocess_runner is previous_runner
107
+
108
+
81
109
  _STDIN_ECHO_PAYLOAD = "charter body for spool path"
82
110
  _UNDECODABLE_STDOUT_BYTES = b"ok \x90 end"
83
111
  _DECODED_UNDECODABLE_STDOUT = "ok \ufffd end"