claude-dev-env 7.6.0 → 7.7.0

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 (35) hide show
  1. package/.agents/skills/session-continuity/README.md +19 -9
  2. package/AGENTS.md +48 -71
  3. package/_shared/advisor/advisor-protocol.md +2 -2
  4. package/_shared/advisor/reference/advisor-block.md +1 -1
  5. package/_shared/advisor/reference/astra-rung.md +2 -2
  6. package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
  7. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
  8. package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
  9. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
  10. package/bin/install-constants.mjs +3 -0
  11. package/bin/install-session-continuity.mjs +73 -30
  12. package/bin/install-session-continuity.test.mjs +52 -1
  13. package/bin/install-transaction.mjs +41 -44
  14. package/bin/install.cursor-rules.test.mjs +311 -3
  15. package/bin/install.mjs +226 -95
  16. package/bin/install.test.mjs +57 -0
  17. package/bin/install.transaction.test.mjs +61 -1
  18. package/bin/pstack-model-preferences.codex.json +1 -0
  19. package/bin/pstack.mjs +28 -6
  20. package/bin/pstack.test.mjs +48 -0
  21. package/bin/select-pstack-models.test.mjs +375 -26
  22. package/bin/subagent-model-policy.test.mjs +399 -0
  23. package/docs/codex-compatibility.md +19 -5
  24. package/docs/subagent-model-routing.md +50 -0
  25. package/hooks/hooks.json +10 -0
  26. package/hooks/routing/subagent_model_routing.mjs +81 -0
  27. package/hooks/routing/subagent_model_routing.test.mjs +218 -0
  28. package/package.json +1 -1
  29. package/rules/pstack-models.md +22 -0
  30. package/rules/subagent-model-policy.json +85 -0
  31. package/scripts/resolve_advisor_model_route.mjs +52 -0
  32. package/scripts/select_pstack_models.mjs +203 -38
  33. package/scripts/subagent_model_policy.mjs +620 -0
  34. package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
  35. package/scripts/tests/test_codex_compat_materializer.py +15 -0
package/bin/install.mjs CHANGED
@@ -30,13 +30,17 @@ import {
30
30
  MANAGED_HOOKS_DIRECTORY_NAME,
31
31
  MANAGED_SCRIPTS_DIRECTORY_NAME,
32
32
  SETTINGS_FILE_NAME,
33
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
34
+ DEFAULT_CODEX_DIRECTORY_NAME,
33
35
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
34
36
  CURSOR_SYNC_SCRIPT_FILE_NAME,
35
37
  CURSOR_RULES_DIRECTORY_NAME,
36
38
  PSTACK_MODEL_RULE_FILE_NAME,
39
+ SUBAGENT_MODEL_POLICY_FILE_NAME,
37
40
  PSTACK_SEEDED_MODEL_PREFERENCES_FILE_NAMES,
38
41
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
39
42
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
43
+ CODEX_NATIVE_ROUTING_MATCHER,
40
44
  } from './install-constants.mjs';
41
45
  import {
42
46
  PSTACK_PLUGIN_DIRECTORY_NAME,
@@ -44,6 +48,11 @@ import {
44
48
  refreshPstackPluginManifest,
45
49
  } from '../scripts/refresh_pstack_plugin_skills.mjs';
46
50
  import { installPstack } from './pstack.mjs';
51
+ import {
52
+ configureContinuityHosts,
53
+ continuityHostConfigurationPaths,
54
+ removeContinuityHooks,
55
+ } from './install-session-continuity.mjs';
47
56
  import {
48
57
  resolveInstallRoot,
49
58
  parseExplicitTargetFromArgv,
@@ -509,6 +518,77 @@ function seedPstackPreferences(preferenceFileName) {
509
518
  }
510
519
  }
511
520
 
521
+ function seedSubagentModelPolicy() {
522
+ const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
523
+ const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
524
+ const policySource = join(PACKAGE_ROOT, 'rules', SUBAGENT_MODEL_POLICY_FILE_NAME);
525
+ mkdirSync(policyDirectory, { recursive: true });
526
+ try {
527
+ copyFileSync(policySource, policyPath, filesystemConstants.COPYFILE_EXCL);
528
+ return policyPath;
529
+ } catch (copyError) {
530
+ if (copyError.code === 'EEXIST') return null;
531
+ throw copyError;
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Registers the session-continuity companion in each host configuration this
537
+ * install root resolves. Without this step the companion ships to the agents
538
+ * home but no host ever calls it, so Poteto Mode only activates when someone
539
+ * remembers a second setup command.
540
+ *
541
+ * A competing profile's registration, or an unwritable host configuration, is
542
+ * reported and leaves the rest of the install intact.
543
+ *
544
+ * @returns {string[]} The host configuration paths this run registered.
545
+ */
546
+ function registerSessionContinuityHooks() {
547
+ const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
548
+ const hosts = Object.keys(configurationPaths)
549
+ .filter(host => existsSync(dirname(configurationPaths[host])));
550
+ if (hosts.length === 0) return [];
551
+ try {
552
+ const results = configureContinuityHosts(INSTALL_ROOT_RESOLUTION, hosts);
553
+ for (const result of results) {
554
+ const state = result.changed ? 'registered' : 'already registered';
555
+ console.log(` Session continuity: ${state} in ${result.path}`);
556
+ }
557
+ return results.map(result => result.path);
558
+ } catch (registrationError) {
559
+ console.warn(
560
+ ` Warning: session-continuity hooks were not registered (${registrationError.message}) — `
561
+ + 'run bin/install-session-continuity.mjs once the cause is resolved.',
562
+ );
563
+ return [];
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Removes the companion's registrations from the Codex and Cursor hook files
569
+ * during an uninstall. The Claude settings.json copy is pruned inside the
570
+ * settings block, which owns that file's single write.
571
+ *
572
+ * Each host this installer registered is a host it has to clean, or the host
573
+ * keeps calling a script the uninstall deleted.
574
+ *
575
+ * @returns {string[]} The host configuration paths this run changed.
576
+ */
577
+ function removeSessionContinuityHooksFromOtherHosts() {
578
+ const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
579
+ const changedPaths = [];
580
+ for (const host of ['codex', 'cursor']) {
581
+ const configurationPath = configurationPaths[host];
582
+ if (!existsSync(configurationPath)) continue;
583
+ const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
584
+ if (removeContinuityHooks(configuration) === 0) continue;
585
+ writeFileSync(configurationPath, JSON.stringify(configuration, null, 2) + '\n');
586
+ changedPaths.push(configurationPath);
587
+ console.log(` Session continuity: hook registrations removed from ${configurationPath}`);
588
+ }
589
+ return changedPaths;
590
+ }
591
+
512
592
  /**
513
593
  * Formats an absolute interpreter path as a settings.json hook command prefix:
514
594
  * forward-slash separators, double-quoted when the path contains a space so the
@@ -1153,22 +1233,11 @@ function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirec
1153
1233
  existingEntryNames[existingEntryNames.indexOf(caseOnlyMatchName)] = shippedFileName;
1154
1234
  }
1155
1235
 
1156
- /**
1157
- * Copy every file under a source directory into a destination directory,
1158
- * reporting what the run created and what it updated.
1159
- *
1160
- * A destination entry whose name differs from the shipped name only in letter
1161
- * case is renamed to the shipped name before the copy, so the tree carries the
1162
- * spelling the package ships. The directory listing behind that decision is read
1163
- * once per destination directory and reused for every file the run copies there.
1164
- *
1165
- * @param {string} sourceBase The absolute source directory to copy from.
1166
- * @param {string} destBase The absolute destination directory to copy into.
1167
- * @param {{isCaseInsensitive?: boolean}} options Whether name comparison folds letter case; defaults to this host's filesystem.
1168
- * @returns {{created: number, updated: number, paths: string[]}} The counts and the destination paths written.
1169
- */
1170
1236
  export function copyTree(sourceBase, destBase, options = {}) {
1171
- const files = collectFiles(sourceBase);
1237
+ const excludedFileNames = new Set(options.excludeFileNames ?? []);
1238
+ const files = collectFiles(sourceBase).filter(
1239
+ sourceFile => !excludedFileNames.has(basename(sourceFile)),
1240
+ );
1172
1241
  const stats = { created: 0, updated: 0, paths: [] };
1173
1242
  const entryNamesByDirectory = new Map();
1174
1243
  for (const sourceFile of files) {
@@ -1212,15 +1281,6 @@ function backupHubBeforeOverwrite(destPath, incomingPath, backupName) {
1212
1281
  return backupPath;
1213
1282
  }
1214
1283
 
1215
- /**
1216
- * Hook script paths the installer manages even though hooks.json
1217
- * carries no standalone entry for them. Most were folded into the PreToolUse
1218
- * dispatcher in Stage 1; retired hooks have no current registration.
1219
- * Each path stays in this set so a reinstall from an older settings shape
1220
- * prunes its standalone entry — a folded hook would otherwise double-run
1221
- * alongside the dispatcher, and a retired hook's entry would continue to run
1222
- * from the user's settings.json.
1223
- */
1224
1284
  export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
1225
1285
  'blocking/write_existing_file_blocker.py',
1226
1286
  'blocking/sensitive_file_protector.py',
@@ -1296,25 +1356,18 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1296
1356
  'blocking/session_handoff_blocker.py',
1297
1357
  ]);
1298
1358
 
1299
- /**
1300
- * Builds the set of hook script paths this installer manages, each relative to
1301
- * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
1302
- * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
1303
- * `python3 -c` commands reference the hooks directory without a script tail and
1304
- * contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
1305
- * and the retained path sets so a reinstall from an older settings shape prunes
1306
- * folded and retired entries.
1307
- *
1308
- * @param {{hooks: object}} hooksConfig Parsed hooks.json.
1309
- * @returns {Set<string>} Forward-slash relative script paths under hooks/.
1310
- */
1359
+ export const MOVED_HOOK_RELATIVE_PATHS = new Set([
1360
+ 'blocking/subagent_model_routing.mjs',
1361
+ ]);
1362
+
1311
1363
  export function managedHookScriptRelativePaths(hooksConfig) {
1312
1364
  const relativePaths = new Set([
1313
1365
  ...FOLDED_HOOK_RELATIVE_PATHS,
1314
1366
  ...POST_FOLDED_HOOK_RELATIVE_PATHS,
1315
1367
  ...RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
1368
+ ...MOVED_HOOK_RELATIVE_PATHS,
1316
1369
  ]);
1317
- const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
1370
+ const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g;
1318
1371
  for (const matcherGroups of Object.values(hooksConfig.hooks)) {
1319
1372
  for (const sourceGroup of matcherGroups) {
1320
1373
  for (const hook of sourceGroup.hooks) {
@@ -1395,19 +1448,16 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
1395
1448
  return false;
1396
1449
  }
1397
1450
 
1398
- /**
1399
- * Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
1400
- * at a path boundary: end of string, or an argument separator (whitespace, quote,
1401
- * or semicolon). Anchoring the tail keeps a user hook whose path is the managed
1402
- * tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
1403
- * the managed set, so it is never pruned.
1404
- *
1405
- * @param {string} normalizedCommand Forward-slash-normalized hook command.
1406
- * @param {string} relativePath Managed script path under hooks/.
1407
- * @returns {boolean} True when the managed tail ends at a path boundary.
1408
- */
1409
1451
  function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
1410
- return commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`);
1452
+ if (commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`)) {
1453
+ return true;
1454
+ }
1455
+ return MANAGED_HOOK_ROOT_PATHS.some(eachHooksRoot => (
1456
+ commandHoldsPathAtBoundary(
1457
+ normalizedCommand,
1458
+ `${eachHooksRoot.replace(/\\/g, '/')}/${relativePath}`,
1459
+ )
1460
+ ));
1411
1461
  }
1412
1462
 
1413
1463
  /**
@@ -2098,16 +2148,7 @@ function reportRetiredHookStandIns(allHookRootPaths, standInPaths) {
2098
2148
  }
2099
2149
 
2100
2150
 
2101
- /**
2102
- * Load ~/.claude/settings.json for an in-place merge, or `{}` when absent/empty.
2103
- *
2104
- * Malformed JSON or a non-object root ends the process so install never writes
2105
- * onto a shape the harness cannot read.
2106
- *
2107
- * @param {string} settingsPath Absolute path to settings.json.
2108
- * @returns {object}
2109
- */
2110
- function loadClaudeSettingsObjectOrExit(settingsPath) {
2151
+ function loadClaudeSettingsObject(settingsPath) {
2111
2152
  if (!existsSync(settingsPath)) {
2112
2153
  return {};
2113
2154
  }
@@ -2119,42 +2160,67 @@ function loadClaudeSettingsObjectOrExit(settingsPath) {
2119
2160
  try {
2120
2161
  settings = JSON.parse(raw);
2121
2162
  } catch {
2122
- console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.');
2123
- process.exit(1);
2163
+ throw new Error('settings.json is malformed JSON. Fix it and rerun.');
2124
2164
  }
2125
2165
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
2126
- console.error(' ERROR: settings.json holds a value other than a JSON object. Fix it and rerun.');
2127
- process.exit(1);
2166
+ throw new Error('settings.json holds a value other than a JSON object. Fix it and rerun.');
2128
2167
  }
2129
2168
  return settings;
2130
2169
  }
2131
2170
 
2132
- /**
2133
- * Merge one package source root's hook groups into ~/.claude/settings.json.
2134
- *
2135
- * A settings file holding anything other than a JSON object ends the install with
2136
- * a message naming the file, so the run stops before it writes hook entries onto
2137
- * a shape the harness cannot read.
2138
- *
2139
- * @param {string} hooksSourceRoot The package root whose hooks/hooks.json is merged.
2140
- * @param {string} pythonCommand Interpreter command that replaces python3.
2141
- * @returns {number} Count of matcher groups merged.
2142
- */
2143
- function mergeHooks(hooksSourceRoot, pythonCommand) {
2171
+ function mergeHooksAtPath(
2172
+ hooksSourceRoot,
2173
+ settingsPath,
2174
+ indentation,
2175
+ pythonCommand,
2176
+ selectHooksConfig = hooksConfig => hooksConfig,
2177
+ pluginRootDir = CLAUDE_HOME,
2178
+ ) {
2144
2179
  const hooksSource = resolvePackageManagedDirectory(
2145
2180
  hooksSourceRoot,
2146
2181
  MANAGED_HOOKS_DIRECTORY_NAME,
2147
2182
  );
2148
2183
  const hooksJsonPath = join(hooksSource, 'hooks.json');
2149
2184
  if (!existsSync(hooksJsonPath)) return 0;
2150
- const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
2151
- const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2152
- const settings = loadClaudeSettingsObjectOrExit(settingsPath);
2153
- const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
2154
- writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
2185
+ const hooksConfig = selectHooksConfig(JSON.parse(readFileSync(hooksJsonPath, 'utf8')));
2186
+ const settings = loadClaudeSettingsObject(settingsPath);
2187
+ const groupCount = mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand);
2188
+ writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
2155
2189
  return groupCount;
2156
2190
  }
2157
2191
 
2192
+ function selectCodexNativeHooksConfig(hooksConfig) {
2193
+ const preToolUseGroups = hooksConfig.hooks?.PreToolUse;
2194
+ return {
2195
+ ...hooksConfig,
2196
+ hooks: {
2197
+ PreToolUse: Array.isArray(preToolUseGroups)
2198
+ ? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
2199
+ : [],
2200
+ },
2201
+ };
2202
+ }
2203
+
2204
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
2205
+ return mergeHooksAtPath(
2206
+ hooksSourceRoot,
2207
+ join(CLAUDE_HOME, SETTINGS_FILE_NAME),
2208
+ 4,
2209
+ pythonCommand,
2210
+ );
2211
+ }
2212
+
2213
+ function mergeCodexHooks(hooksSourceRoot, pythonCommand) {
2214
+ return mergeHooksAtPath(
2215
+ hooksSourceRoot,
2216
+ CODEX_HOOKS_CONFIGURATION_PATH,
2217
+ 2,
2218
+ pythonCommand,
2219
+ selectCodexNativeHooksConfig,
2220
+ dirname(CODEX_HOOKS_CONFIGURATION_PATH),
2221
+ );
2222
+ }
2223
+
2158
2224
  function writeManifest(installedFiles, skillNames, managedPermissions = null) {
2159
2225
  const manifest = buildTargetManifestRecord({
2160
2226
  packageName: PACKAGE_NAME,
@@ -2234,7 +2300,7 @@ function mergeManagedPermissions() {
2234
2300
  }
2235
2301
  const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2236
2302
  const settingsExisted = existsSync(settingsPath);
2237
- const settings = loadClaudeSettingsObjectOrExit(settingsPath);
2303
+ const settings = loadClaudeSettingsObject(settingsPath);
2238
2304
  const pruneOutcome = pruneManagedPermissionsFromSettings(settings, allRetiredManagedDenyEntries);
2239
2305
  const mergeOutcome = hasCurrentManagedDenyEntries
2240
2306
  ? mergeManagedPermissionsIntoSettings(settings, allCurrentManagedDenyEntries)
@@ -2536,6 +2602,7 @@ function executeInstallPlan(plan) {
2536
2602
  managedRoot: CLAUDE_HOME,
2537
2603
  manifestFilePath: MANIFEST_FILE,
2538
2604
  settingsPath: plan.settingsPath,
2605
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
2539
2606
  priorManifestFiles: plan.priorManifest.files,
2540
2607
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2541
2608
  });
@@ -2644,7 +2711,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2644
2711
  if (!existsSync(sourceDir)) continue;
2645
2712
  const destDir = join(CLAUDE_HOME, directory);
2646
2713
  if (hasFullAccess) {
2647
- const stats = copyTree(sourceDir, destDir);
2714
+ const stats = copyTree(sourceDir, destDir, directory === 'rules'
2715
+ ? { excludeFileNames: [SUBAGENT_MODEL_POLICY_FILE_NAME] }
2716
+ : {});
2648
2717
  if (!summary[directory]) {
2649
2718
  summary[directory] = stats;
2650
2719
  } else {
@@ -2658,7 +2727,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2658
2727
  let rulesUpdated = 0;
2659
2728
  for (const ruleFile of allowedRules) {
2660
2729
  const sourcePath = join(sourceDir, ruleFile);
2661
- if (!existsSync(sourcePath)) continue;
2730
+ if (!existsSync(sourcePath) || ruleFile === SUBAGENT_MODEL_POLICY_FILE_NAME) continue;
2662
2731
  const destPath = join(destDir, ruleFile);
2663
2732
  mkdirSync(dirname(destPath), { recursive: true });
2664
2733
  const existed = existsSync(destPath);
@@ -2756,6 +2825,11 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2756
2825
  allUserOwnedPreferencePaths.add(seededPreferencePath);
2757
2826
  }
2758
2827
  }
2828
+ const seededPolicyPath = seedSubagentModelPolicy();
2829
+ if (seededPolicyPath) {
2830
+ allInstalledFiles.push(seededPolicyPath);
2831
+ allUserOwnedPreferencePaths.add(seededPolicyPath);
2832
+ }
2759
2833
  syncWrittenPaths(allInstalledFiles);
2760
2834
  }
2761
2835
  let skillsCreated = 0;
@@ -2804,6 +2878,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2804
2878
  let totalHooksCreated = 0;
2805
2879
  let totalHooksUpdated = 0;
2806
2880
  let totalHookGroups = 0;
2881
+ let totalCodexHookGroups = 0;
2807
2882
  for (const sourceRoot of allSourceRoots) {
2808
2883
  const hooksSource = resolvePackageManagedDirectory(
2809
2884
  sourceRoot,
@@ -2829,11 +2904,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2829
2904
  }
2830
2905
  const groupCount = mergeHooks(sourceRoot, pythonCommand);
2831
2906
  totalHookGroups += groupCount;
2907
+ totalCodexHookGroups += mergeCodexHooks(sourceRoot, pythonCommand);
2832
2908
  }
2833
2909
  summary.hookFiles = { created: totalHooksCreated, updated: totalHooksUpdated };
2834
2910
  console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
2835
2911
  summary.hookGroups = totalHookGroups;
2836
- console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
2912
+ summary.codexHookGroups = totalCodexHookGroups;
2913
+ console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
2837
2914
  syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2838
2915
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2839
2916
 
@@ -2922,6 +2999,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2922
2999
  console.log(` \u2713 ${relative(CLAUDE_HOME, agentsHubDest)} (canonical guidance)`);
2923
3000
  }
2924
3001
  const isFullInstall = !selectedGroups;
3002
+ if (isFullInstall && shouldInstallAnyHooks) {
3003
+ summary.sessionContinuity = { configuredPaths: registerSessionContinuityHooks() };
3004
+ }
2925
3005
  const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
2926
3006
  let failedPrunePaths = [];
2927
3007
  let stalePrunedTotal = 0;
@@ -3092,15 +3172,40 @@ function resolveUninstallPlan(isManifestRequired) {
3092
3172
  });
3093
3173
  }
3094
3174
 
3095
- /**
3096
- * Apply one preflighted uninstall plan: remove removable files, prune managed
3097
- * hooks from settings, unset core.hooksPath when it points here, then drop the
3098
- * ownership manifest. Fault phases mirror the install transaction names.
3099
- *
3100
- * @param {ReturnType<typeof buildUninstallPlan>} plan
3101
- * @param {{ throwIfFault?: (phase: string) => void }} [helpers]
3102
- * @returns {number} Count of files this call removed.
3103
- */
3175
+ function pruneManagedHooksFromHostConfiguration(settingsPath, defaultIndentation) {
3176
+ if (!existsSync(settingsPath)) return false;
3177
+ let settingsText;
3178
+ let settings;
3179
+ try {
3180
+ settingsText = readFileSync(settingsPath, 'utf8');
3181
+ settings = JSON.parse(settingsText);
3182
+ } catch (readError) {
3183
+ throw new Error(`host hook configuration is malformed JSON: ${readError.message}`);
3184
+ }
3185
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
3186
+ throw new Error('host hook configuration must hold a JSON object');
3187
+ }
3188
+ if (!settings.hooks || typeof settings.hooks !== 'object') return false;
3189
+ const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
3190
+ managedPackageSourceRoots(),
3191
+ );
3192
+ pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
3193
+ writeFileSync(
3194
+ settingsPath,
3195
+ JSON.stringify(settings, null, hostConfigurationIndent(settingsText) || defaultIndentation) + '\n',
3196
+ );
3197
+ console.log(` Hook entries removed from ${basename(settingsPath)}`);
3198
+ return true;
3199
+ }
3200
+
3201
+ function codexRoutingHookExists() {
3202
+ return existsSync(join(
3203
+ INSTALL_ROOT_RESOLUTION.codexHooksInstallDirectory,
3204
+ 'routing',
3205
+ 'subagent_model_routing.mjs',
3206
+ ));
3207
+ }
3208
+
3104
3209
  function executeUninstallPlan(plan, helpers = {}) {
3105
3210
  const throwIfFault = helpers.throwIfFault || (() => {});
3106
3211
  let removed = 0;
@@ -3139,6 +3244,10 @@ function executeUninstallPlan(plan, helpers = {}) {
3139
3244
  pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
3140
3245
  didSettingsChange = true;
3141
3246
  console.log(' Hook entries removed from settings.json');
3247
+ const removedContinuityCount = removeContinuityHooks(settings);
3248
+ if (removedContinuityCount > 0) {
3249
+ console.log(` Session continuity: ${removedContinuityCount} hook registration(s) removed from settings.json`);
3250
+ }
3142
3251
  }
3143
3252
  const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
3144
3253
  ? plan.managedPermissionDenyEntries
@@ -3156,6 +3265,10 @@ function executeUninstallPlan(plan, helpers = {}) {
3156
3265
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
3157
3266
  }
3158
3267
  }
3268
+ if (!codexRoutingHookExists()) {
3269
+ pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
3270
+ }
3271
+ removeSessionContinuityHooksFromOtherHosts();
3159
3272
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
3160
3273
 
3161
3274
  unsetGlobalGitHooksPathIfOurs();
@@ -3230,6 +3343,7 @@ function uninstall() {
3230
3343
  managedRoot: CLAUDE_HOME,
3231
3344
  manifestFilePath: MANIFEST_FILE,
3232
3345
  settingsPath: plan.settingsPath,
3346
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
3233
3347
  priorManifestFiles: plan.removableFiles,
3234
3348
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3235
3349
  });
@@ -3377,7 +3491,9 @@ function loadDirectoryNameByProfileId() {
3377
3491
  function runInstallForAllTargets(allTargets, childArgv) {
3378
3492
  if (allTargets.length === 1) {
3379
3493
  const onlyTarget = allTargets[0];
3380
- if (onlyTarget.managedRoot !== CLAUDE_HOME || onlyTarget.targetIdentity !== INSTALL_TARGET_IDENTITY) {
3494
+ if (onlyTarget.managedRoot !== CLAUDE_HOME
3495
+ || onlyTarget.targetIdentity !== INSTALL_TARGET_IDENTITY
3496
+ || !isDefaultClaudeTarget(onlyTarget)) {
3381
3497
  const childStatus = spawnInstallChild(onlyTarget, childArgv);
3382
3498
  process.exit(childStatus);
3383
3499
  }
@@ -3404,6 +3520,13 @@ function runInstallForAllTargets(allTargets, childArgv) {
3404
3520
  * @returns {number | null}
3405
3521
  */
3406
3522
  function spawnInstallChild(target, childArgv) {
3523
+ const childEnvironment = { ...process.env };
3524
+ if (!isDefaultClaudeTarget(target)) {
3525
+ childEnvironment[CODEX_HOME_ENVIRONMENT_VARIABLE] = join(
3526
+ target.managedRoot,
3527
+ DEFAULT_CODEX_DIRECTORY_NAME,
3528
+ );
3529
+ }
3407
3530
  const childProcess = spawnSync(
3408
3531
  process.execPath,
3409
3532
  [
@@ -3416,7 +3539,7 @@ function spawnInstallChild(target, childArgv) {
3416
3539
  ],
3417
3540
  {
3418
3541
  stdio: 'inherit',
3419
- env: process.env,
3542
+ env: childEnvironment,
3420
3543
  },
3421
3544
  );
3422
3545
  if (childProcess.error) {
@@ -3426,6 +3549,10 @@ function spawnInstallChild(target, childArgv) {
3426
3549
  return childProcess.status === null ? 1 : childProcess.status;
3427
3550
  }
3428
3551
 
3552
+ function isDefaultClaudeTarget(target) {
3553
+ return resolve(target.managedRoot) === resolve(join(homedir(), '.claude'));
3554
+ }
3555
+
3429
3556
  if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
3430
3557
  const rawArgs = process.argv.slice(2);
3431
3558
  if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
@@ -3471,6 +3598,10 @@ if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
3471
3598
  || (
3472
3599
  targetSelection.mode === 'profiles'
3473
3600
  && allTargets[0].targetIdentity !== INSTALL_TARGET_IDENTITY
3601
+ )
3602
+ || (
3603
+ targetSelection.mode !== 'child-identity'
3604
+ && !isDefaultClaudeTarget(allTargets[0])
3474
3605
  );
3475
3606
 
3476
3607
  if (needsChildHop && !args.includes('--uninstall')) {
@@ -3088,3 +3088,60 @@ test('--no-pstack leaves the pstack store absent', t => {
3088
3088
  assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
3089
3089
  });
3090
3090
  });
3091
+
3092
+ function runContinuityInstaller(homeDirectory, extraArguments) {
3093
+ return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
3094
+ cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
3095
+ encoding: 'utf8',
3096
+ env: {
3097
+ ...process.env,
3098
+ CDE_INSTALL_PSTACK: '0',
3099
+ HOME: homeDirectory,
3100
+ USERPROFILE: homeDirectory,
3101
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3102
+ CODEX_HOME: join(homeDirectory, '.codex'),
3103
+ },
3104
+ });
3105
+ }
3106
+
3107
+ function continuityCommandCount(configurationPath) {
3108
+ if (!existsSync(configurationPath)) return 0;
3109
+ const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
3110
+ const allCommands = Object.values(configuration.hooks || {})
3111
+ .flat()
3112
+ .flatMap(entry => (entry.hooks ? entry.hooks.map(eachHook => eachHook.command) : [entry.command]));
3113
+ return allCommands.filter(
3114
+ eachCommand => typeof eachCommand === 'string'
3115
+ && eachCommand.includes('session-continuity/continuity.mjs'),
3116
+ ).length;
3117
+ }
3118
+
3119
+ test('a full install registers the session-continuity companion in every host, and uninstall takes it back out', t => {
3120
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-continuity-install-'));
3121
+ t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3122
+ const allConfigurationPaths = [
3123
+ join(homeDirectory, '.claude', 'settings.json'),
3124
+ join(homeDirectory, '.codex', 'hooks.json'),
3125
+ join(homeDirectory, '.cursor', 'hooks.json'),
3126
+ ];
3127
+
3128
+ runContinuityInstaller(homeDirectory, []);
3129
+
3130
+ for (const configurationPath of allConfigurationPaths) {
3131
+ assert.equal(
3132
+ continuityCommandCount(configurationPath) > 0,
3133
+ true,
3134
+ `the install registers the companion in ${configurationPath} without a second setup command`,
3135
+ );
3136
+ }
3137
+
3138
+ runContinuityInstaller(homeDirectory, ['--uninstall']);
3139
+
3140
+ for (const configurationPath of allConfigurationPaths) {
3141
+ assert.equal(
3142
+ continuityCommandCount(configurationPath),
3143
+ 0,
3144
+ `uninstall leaves no registration in ${configurationPath} pointing at a removed script`,
3145
+ );
3146
+ }
3147
+ });
@@ -134,6 +134,44 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
134
134
  rmSync(box.root, { recursive: true, force: true });
135
135
  });
136
136
 
137
+ test('capture and restore recover an additional host settings file', () => {
138
+ const box = sandbox();
139
+ const gitConfigPath = join(box.root, '.gitconfig');
140
+ const codexHooksPath = join(box.root, '.codex', 'hooks.json');
141
+ writeFileSync(gitConfigPath, '');
142
+ writeFileWithParents(box.settingsPath, '{"host":"claude"}\n');
143
+ writeFileWithParents(codexHooksPath, '{"host":"codex"}\n');
144
+ const io = {
145
+ env: {
146
+ ...process.env,
147
+ CDE_INSTALL_PSTACK: '0',
148
+ HOME: box.root,
149
+ USERPROFILE: box.root,
150
+ GIT_CONFIG_GLOBAL: gitConfigPath,
151
+ },
152
+ execFileSync,
153
+ };
154
+
155
+ const snapshot = capturePriorInstallSnapshot({
156
+ managedRoot: box.managedRoot,
157
+ manifestFilePath: box.manifestFilePath,
158
+ settingsPath: box.settingsPath,
159
+ additionalSettingsPaths: [codexHooksPath],
160
+ priorManifestFiles: [],
161
+ journalParentDirectory: box.journalParent,
162
+ io,
163
+ });
164
+ writeFileSync(box.settingsPath, '{"host":"changed"}\n');
165
+ writeFileSync(codexHooksPath, '{"host":"changed"}\n');
166
+
167
+ restorePriorInstallSnapshot(snapshot, { io });
168
+
169
+ assert.equal(readFileSync(box.settingsPath, 'utf8'), '{"host":"claude"}\n');
170
+ assert.equal(readFileSync(codexHooksPath, 'utf8'), '{"host":"codex"}\n');
171
+ discardInstallTransactionJournal(snapshot);
172
+ rmSync(box.root, { recursive: true, force: true });
173
+ });
174
+
137
175
  test('runWithInstallTransaction restores prior state on injected fault', () => {
138
176
  const box = sandbox();
139
177
  const gitConfigPath = join(box.root, '.gitconfig');
@@ -312,12 +350,16 @@ test('installer fault after_settings_write restores prior settings and files', (
312
350
  try {
313
351
  const { claudeDirectory, rulesFile } = seedPriorInstall(homeDirectory);
314
352
  const priorSettings = readFileSync(join(claudeDirectory, 'settings.json'), 'utf8');
353
+ const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
354
+ writeFileWithParents(codexHooksPath, '{"hooks":{"PreToolUse":[{"matcher":"custom"}]}}\n');
355
+ const priorCodexHooks = readFileSync(codexHooksPath, 'utf8');
315
356
 
316
357
  const failedRun = runInstaller(homeDirectory, [], {
317
358
  faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
318
359
  });
319
360
  assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
320
361
  assert.equal(readFileSync(join(claudeDirectory, 'settings.json'), 'utf8'), priorSettings);
362
+ assert.equal(readFileSync(codexHooksPath, 'utf8'), priorCodexHooks);
321
363
  assert.equal(readFileSync(rulesFile, 'utf8'), 'prior-body\n');
322
364
  assert.match(
323
365
  readFileSync(join(claudeDirectory, '.claude-dev-env-manifest.json'), 'utf8'),
@@ -328,6 +370,25 @@ test('installer fault after_settings_write restores prior settings and files', (
328
370
  }
329
371
  });
330
372
 
373
+ test('installer fault after_settings_write removes a newly created Codex hooks file', () => {
374
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-absent-codex-hooks-'));
375
+ try {
376
+ const { claudeDirectory } = seedPriorInstall(homeDirectory);
377
+ const codexHooksPath = join(homeDirectory, '.codex', 'hooks.json');
378
+ const failedRun = runInstaller(homeDirectory, [], {
379
+ faultPhase: FAULT_PHASES.AFTER_SETTINGS_WRITE,
380
+ });
381
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
382
+ assert.equal(existsSync(codexHooksPath), false);
383
+ assert.equal(
384
+ existsSync(join(claudeDirectory, '.claude-dev-env-manifest.json')),
385
+ true,
386
+ );
387
+ } finally {
388
+ rmSync(homeDirectory, { recursive: true, force: true });
389
+ }
390
+ });
391
+
331
392
  test('installer fault after_manifest_write restores prior manifest', () => {
332
393
  const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-manifest-'));
333
394
  try {
@@ -404,4 +465,3 @@ test('successful install after prior install leaves one manifest and no fault',
404
465
  rmSync(homeDirectory, { recursive: true, force: true });
405
466
  }
406
467
  });
407
-