codex-copilot-dx 0.6.5 → 0.6.6

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.
package/src/models.mjs CHANGED
@@ -95,11 +95,6 @@ function numericLimit(value, fallback) {
95
95
  return Number.isFinite(value) && value > 0 ? value : fallback;
96
96
  }
97
97
 
98
- function hasMessagesEndpoint(model) {
99
- const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
100
- return endpoints.includes("/v1/messages") || endpoints.includes("/chat/completions");
101
- }
102
-
103
98
  function hasOpenAIEndpoint(model) {
104
99
  const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
105
100
  return endpoints.includes("/responses") || endpoints.includes("/v1/responses") || endpoints.includes("/chat/completions");
@@ -140,13 +135,22 @@ function uniqueIds(models, predicate) {
140
135
  return ids;
141
136
  }
142
137
 
143
- function isClaudeCopilotModel(model) {
138
+ export function isClaudeCopilotCatalogEntry(model) {
144
139
  const id = String(model?.id || "").trim();
145
- const vendor = String(model?.vendor || "").toLowerCase();
146
- return id
147
- && (id.startsWith("claude-") || vendor === "anthropic")
140
+ const vendor = String(model?.vendor || model?.owned_by || "").trim().toLowerCase();
141
+ return Boolean(id) && (id.toLowerCase().startsWith("claude-") || vendor === "anthropic");
142
+ }
143
+
144
+ export function isClaudeCopilotModel(model) {
145
+ const policy = String(model?.policy?.state || "").trim().toLowerCase();
146
+ const declaredOwners = [model?.vendor, model?.owned_by]
147
+ .map((value) => String(value || "").trim().toLowerCase())
148
+ .filter(Boolean);
149
+ return isClaudeCopilotCatalogEntry(model)
150
+ && declaredOwners.every((owner) => owner === "anthropic")
148
151
  && model?.model_picker_enabled !== false
149
- && hasMessagesEndpoint(model);
152
+ && (!policy || policy === "enabled")
153
+ && modelSupportsChatCompletions(model);
150
154
  }
151
155
 
152
156
  export function gptModelIdsFromCopilotModels(models) {
@@ -150,6 +150,15 @@ function safeClientRuntimeStatus(client) {
150
150
  };
151
151
  }
152
152
 
153
+ function safeProfileCurrent(checkProfileCurrent) {
154
+ if (typeof checkProfileCurrent !== "function") return false;
155
+ try {
156
+ return checkProfileCurrent() === true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
153
162
  export function runtimeStatusPayload({
154
163
  metrics,
155
164
  streamPerformance,
@@ -161,6 +170,7 @@ export function runtimeStatusPayload({
161
170
  codexModelRegistry,
162
171
  claudeModelRegistry,
163
172
  claudeMode = "inherited",
173
+ isClaudeProfileCurrent,
164
174
  } = {}) {
165
175
  const memory = process.memoryUsage();
166
176
  const resolvedClaudeMode = claudeMode === "isolated" ? "isolated" : "inherited";
@@ -172,6 +182,8 @@ export function runtimeStatusPayload({
172
182
  const claudeModels = resolvedClaudeMode === "isolated"
173
183
  ? modelRegistryStatus(claudeModelRegistry)
174
184
  : codexModels;
185
+ const claudeProfileCurrent = resolvedClaudeMode === "isolated"
186
+ && safeProfileCurrent(isClaudeProfileCurrent);
175
187
  return {
176
188
  ...adapterHealthPayload(),
177
189
  uptime_ms: Math.round(process.uptime() * 1000),
@@ -192,7 +204,12 @@ export function runtimeStatusPayload({
192
204
  models: codexModels,
193
205
  profiles: {
194
206
  codex: { mode: "legacy", client: codexRuntime, models: codexModels },
195
- claude: { mode: resolvedClaudeMode, client: claudeRuntime, models: claudeModels },
207
+ claude: {
208
+ mode: resolvedClaudeMode,
209
+ profile_current: claudeProfileCurrent,
210
+ client: claudeRuntime,
211
+ models: claudeModels,
212
+ },
196
213
  },
197
214
  routing: profileRouting({ claudeMode: resolvedClaudeMode }),
198
215
  limits: {
@@ -396,6 +396,19 @@ function backupName(recipe) {
396
396
  return `${recipe.id}-${recipe.sourceAsarSha256.slice(0, 12)}`;
397
397
  }
398
398
 
399
+ export function pmStudioBackupRoot(home = os.homedir()) {
400
+ return path.join(home, ".local", "share", "codex-copilot-dx", "pm-studio-backups");
401
+ }
402
+
403
+ export function pmStudioPatchManifestPath({
404
+ home = os.homedir(),
405
+ backupRoot = pmStudioBackupRoot(home),
406
+ recipe,
407
+ } = {}) {
408
+ if (!recipe) throw new TypeError("PM Studio patch recipe is required");
409
+ return path.join(backupRoot, backupName(recipe), "manifest.json");
410
+ }
411
+
399
412
  function backupManifest(recipe, createdAt) {
400
413
  return {
401
414
  schema_version: 1,
@@ -512,13 +525,24 @@ function writePatchedBinaryRecord({ backup, inspection, operations }) {
512
525
  backup.manifest = manifest;
513
526
  }
514
527
 
515
- function assertPatchedBinaryRecord({ inspection, manifestPath }) {
528
+ export function assertPatchedBinaryRecord({ inspection, manifestPath, recipe }) {
516
529
  let manifest;
517
530
  try {
518
531
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
519
532
  } catch {
520
533
  throw setupError("PM_STUDIO_PATCH_RECORD_INVALID", "PM Studio patched-binary record is missing or invalid");
521
534
  }
535
+ if (recipe && (manifest?.schema_version !== 1
536
+ || manifest?.kind !== "ccdx-pm-studio-backup"
537
+ || manifest?.recipe_id !== recipe.id
538
+ || manifest?.app?.bundle_identifier !== recipe.bundleIdentifier
539
+ || manifest?.app?.version !== recipe.version
540
+ || manifest?.app?.build !== recipe.build
541
+ || manifest?.patched?.asar_sha256 !== recipe.patchedAsarSha256
542
+ || manifest?.patched?.asar_header_sha256 !== recipe.patchedHeaderSha256
543
+ || !integrityMatches(manifest?.patched?.electron_asar_integrity, "SHA256", recipe.patchedHeaderSha256))) {
544
+ throw setupError("PM_STUDIO_PATCH_RECORD_INVALID", "PM Studio patched-binary record does not match the installed patch recipe");
545
+ }
522
546
  const binaries = manifest?.patched?.binaries;
523
547
  if (!binaries
524
548
  || binaries.main_executable_sha256 !== inspection.executableIntegrity.executableSha256
@@ -593,7 +617,7 @@ export async function runPmStudioSetup({
593
617
  commandName = "ccdx",
594
618
  appPath = DEFAULT_PM_STUDIO_APP_PATH,
595
619
  home = os.homedir(),
596
- backupRoot = path.join(home, ".local", "share", "codex-copilot-dx", "pm-studio-backups"),
620
+ backupRoot = pmStudioBackupRoot(home),
597
621
  recipes = PM_STUDIO_RECIPES,
598
622
  operations: operationOverrides = {},
599
623
  logger = (line) => console.log(line),
@@ -629,7 +653,7 @@ export async function runPmStudioSetup({
629
653
  throw setupError("PM_STUDIO_BACKUP_INVALID", "Patched PM Studio has no matching verified backup; no files were changed");
630
654
  }
631
655
  const backup = validateBackup({ backupDir: finalBackupDir, recipe, operations });
632
- assertPatchedBinaryRecord({ inspection: source, manifestPath: backup.manifestPath });
656
+ assertPatchedBinaryRecord({ inspection: source, manifestPath: backup.manifestPath, recipe });
633
657
  emit(`[OK] PM Studio ${recipe.version} build ${recipe.build} is already patched; no backup or signing was repeated.`);
634
658
  emit(`[OK] Verified backup: ${backup.backupAppPath}`);
635
659
  emit(`[OK] Backup manifest: ${backup.manifestPath}`);
@@ -700,7 +724,7 @@ export async function runPmStudioSetup({
700
724
  installed.issues);
701
725
  }
702
726
  try {
703
- assertPatchedBinaryRecord({ inspection: installed, manifestPath: backup.manifestPath });
727
+ assertPatchedBinaryRecord({ inspection: installed, manifestPath: backup.manifestPath, recipe });
704
728
  } catch (error) {
705
729
  throw setupError("PM_STUDIO_INSTALL_VERIFY_FAILED",
706
730
  `Installed PM Studio differs from the verified staging record; do not launch it. ${recoveryMessage(backup)}`,
@@ -7,16 +7,27 @@ import {
7
7
  } from "./auth-profile.mjs";
8
8
  import { PM_STUDIO_RECIPES } from "./pm-studio-asar.mjs";
9
9
  import {
10
+ assertPatchedBinaryRecord,
10
11
  createPmStudioSetupOperations,
11
12
  DEFAULT_PM_STUDIO_APP_PATH,
12
13
  inspectPmStudioApp,
13
14
  PM_STUDIO_CLAUDE_AUTH_COMMAND,
15
+ pmStudioBackupRoot,
16
+ pmStudioPatchManifestPath,
14
17
  } from "./pm-studio-setup.mjs";
18
+ import { readAdapterStatus } from "./cli-status.mjs";
15
19
  import { checkRunningAdapter } from "./running-adapter.mjs";
16
20
  import { status } from "./status.mjs";
17
21
 
18
22
  const PM_RELAY_HOST = "127.0.0.1";
19
23
  const PM_RELAY_PORT = 2026;
24
+ const PM_RELAY_CAPABILITY = "pm_studio_relay_v1";
25
+ const PM_RELAY_ROUTE_NAMES = Object.freeze([
26
+ "pm_models",
27
+ "pm_chat_completions",
28
+ "pm_responses",
29
+ "pm_embeddings",
30
+ ]);
20
31
 
21
32
  function selectRecipe(metadata, recipes) {
22
33
  return recipes.find((recipe) => recipe.version === String(metadata.version)
@@ -42,15 +53,52 @@ function inspectClaudeProfile(home, readCredentials) {
42
53
  }
43
54
  }
44
55
 
56
+ function inspectAdapterRuntime(adapter, runtimeStatus) {
57
+ const data = runtimeStatus?.data;
58
+ const health = adapter?.data;
59
+ const issues = [];
60
+ if (!data || typeof data !== "object") {
61
+ return { ok: false, baseUrl: runtimeStatus?.baseUrl || adapter?.baseUrl, data: null, issues: ["runtime status payload is missing"] };
62
+ }
63
+ if (data.version !== health?.version
64
+ || data.protocol_version !== health?.protocol_version
65
+ || data.instance_id !== health?.instance_id
66
+ || data.pid !== health?.pid) {
67
+ issues.push("health and runtime status do not describe the same adapter instance");
68
+ }
69
+ const capabilities = new Set(Array.isArray(data.capabilities) ? data.capabilities : []);
70
+ if (!capabilities.has(PM_RELAY_CAPABILITY)) issues.push("PM Studio relay capability is missing");
71
+ if (data.profiles?.claude?.mode !== "isolated") issues.push("Claude profile mode is not isolated");
72
+ if (data.profiles?.claude?.profile_current !== true) {
73
+ issues.push("running Claude credentials do not match the current isolated profile");
74
+ }
75
+ if (data.routing?.responses !== "codex") issues.push("/v1/responses is not routed to Codex");
76
+ if (data.routing?.messages !== "claude") issues.push("/v1/messages is not routed to Claude");
77
+ const routes = data.requests?.by_route;
78
+ for (const routeName of PM_RELAY_ROUTE_NAMES) {
79
+ if (!routes || typeof routes !== "object" || !Object.hasOwn(routes, routeName)) {
80
+ issues.push(`runtime status does not expose ${routeName}`);
81
+ }
82
+ }
83
+ return {
84
+ ...runtimeStatus,
85
+ ok: issues.length === 0,
86
+ issues,
87
+ };
88
+ }
89
+
45
90
  export async function inspectPmStudioStatus({
46
91
  appPath = DEFAULT_PM_STUDIO_APP_PATH,
47
92
  home = os.homedir(),
93
+ backupRoot = pmStudioBackupRoot(home),
48
94
  recipes = PM_STUDIO_RECIPES,
49
95
  operationOverrides = {},
50
96
  existsSync = fs.existsSync,
51
97
  inspectApp = inspectPmStudioApp,
98
+ verifyPatchRecord = assertPatchedBinaryRecord,
52
99
  readClaudeCredentials = readAuthProfileCredentials,
53
100
  checkRunningAdapterFn = checkRunningAdapter,
101
+ readAdapterStatusFn = readAdapterStatus,
54
102
  } = {}) {
55
103
  const operations = createPmStudioSetupOperations(operationOverrides);
56
104
  let app;
@@ -69,9 +117,24 @@ export async function inspectPmStudioStatus({
69
117
  bundleIdentifier: String(metadata.bundleIdentifier),
70
118
  };
71
119
  const recipe = selectRecipe(normalized, recipes);
72
- app = recipe
73
- ? { ...inspectApp({ appPath, recipe, operations }), recipe: recipe.id }
74
- : { state: "unsupported", metadata: normalized, issues: [], recipe: "" };
120
+ if (recipe) {
121
+ app = { ...inspectApp({ appPath, recipe, operations }), recipe: recipe.id };
122
+ if (app.state === "patched") {
123
+ const manifestPath = pmStudioPatchManifestPath({ home, backupRoot, recipe });
124
+ try {
125
+ verifyPatchRecord({ inspection: app, manifestPath, recipe });
126
+ app.patchRecord = { valid: true, manifestPath, reason: "" };
127
+ } catch (error) {
128
+ const reason = safeMessage(error);
129
+ app.patchRecipeMatched = true;
130
+ app.patchRecord = { valid: false, manifestPath, reason };
131
+ app.state = "drift";
132
+ app.issues = [...(Array.isArray(app.issues) ? app.issues : []), reason];
133
+ }
134
+ }
135
+ } else {
136
+ app = { state: "unsupported", metadata: normalized, issues: [], recipe: "" };
137
+ }
75
138
  } catch (error) {
76
139
  app = { state: "error", metadata: null, issues: [safeMessage(error)], recipe: "" };
77
140
  }
@@ -84,16 +147,40 @@ export async function inspectPmStudioStatus({
84
147
  } catch (error) {
85
148
  adapter = { ok: false, error };
86
149
  }
87
- const operational = app.state === "patched" && claude.valid && adapter?.ok === true;
88
- return { ok: operational, appPath, app, claude, adapter };
150
+ let runtime = { ok: false, baseUrl: adapter?.baseUrl, data: null, issues: ["adapter health check failed"] };
151
+ if (adapter?.ok === true) {
152
+ try {
153
+ runtime = inspectAdapterRuntime(adapter, await readAdapterStatusFn({
154
+ host: PM_RELAY_HOST,
155
+ port: PM_RELAY_PORT,
156
+ }));
157
+ } catch (error) {
158
+ runtime = { ok: false, baseUrl: adapter.baseUrl, data: null, issues: [safeMessage(error)] };
159
+ }
160
+ }
161
+ const operational = app.state === "patched"
162
+ && app.patchRecord?.valid === true
163
+ && claude.valid
164
+ && adapter?.ok === true
165
+ && runtime.ok === true;
166
+ return { ok: operational, appPath, app, claude, adapter, runtime };
89
167
  }
90
168
 
91
169
  function appStatusLine(result, commandName) {
92
170
  const { app } = result;
93
171
  const version = app.metadata ? `${app.metadata.version} build ${app.metadata.build}` : "";
94
- if (app.state === "patched") return status("ok", `PM Studio ${version} is patched and verified`);
172
+ if (app.state === "patched" && app.patchRecord?.valid === true) {
173
+ return status("ok", `PM Studio ${version} is patched and verified against its installed patch record`);
174
+ }
175
+ if (app.state === "patched") {
176
+ const reason = app.patchRecord?.reason || "installed patch record is missing";
177
+ return status("err", `PM Studio ${version} matches the patch recipe, but its installed patch record is not verified: ${reason}`);
178
+ }
95
179
  if (app.state === "clean") return status("warn", `PM Studio ${version} is supported but not patched; run ${commandName} pms setup`);
96
180
  if (app.state === "unsupported") return status("err", `PM Studio ${version} has no exact patch recipe; no files will be changed`);
181
+ if (app.state === "drift" && app.patchRecipeMatched) {
182
+ return status("err", `PM Studio ${version} matches the patch recipe, but its installed patch record is not verified: ${app.issues.join("; ")}`);
183
+ }
97
184
  if (app.state === "drift") return status("err", `PM Studio ${version} does not match the clean or patched recipe: ${app.issues.join("; ")}`);
98
185
  if (app.state === "not_installed") return status("warn", `PM Studio is not installed at ${result.appPath}`);
99
186
  return status("err", `PM Studio could not be inspected: ${app.issues.join("; ")}`);
@@ -111,14 +198,21 @@ export function formatPmStudioStatus(result, { commandName = "ccdx" } = {}) {
111
198
  const reason = result.claude.configured && result.claude.reason ? ` (${result.claude.reason})` : "";
112
199
  lines.push(status("err", `Isolated Claude profile is not ready${reason}; run ${PM_STUDIO_CLAUDE_AUTH_COMMAND}`));
113
200
  }
114
- if (result.adapter?.ok) {
115
- lines.push(status("ok", `PM relay is available at ${result.adapter.baseUrl}`));
201
+ if (result.adapter?.ok && result.runtime?.ok) {
202
+ lines.push(status("ok", `PM relay and isolated Claude routing are verified at ${result.adapter.baseUrl}`));
203
+ } else if (result.adapter?.ok) {
204
+ const reason = result.runtime?.issues?.join("; ") || "runtime status is unavailable";
205
+ lines.push(status("err", `The adapter is running at ${result.adapter.baseUrl}, but PM relay routing is not ready: ${reason}; stop it and run ${commandName} start`));
116
206
  } else if (result.adapter?.incompatible) {
117
207
  lines.push(status("err", `The adapter at ${result.adapter.baseUrl} is incompatible; stop it and run ${commandName} start`));
118
208
  } else {
119
209
  lines.push(status("warn", `PM relay is not running at http://${PM_RELAY_HOST}:${PM_RELAY_PORT}; run ${commandName} start`));
120
210
  }
121
- lines.push(status("info", "Routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile"));
211
+ if (result.runtime?.ok) {
212
+ lines.push(status("info", "Routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile"));
213
+ } else {
214
+ lines.push(status("info", "Expected routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile"));
215
+ }
122
216
  return lines.join("\n");
123
217
  }
124
218
 
@@ -1,22 +1,13 @@
1
+ import {
2
+ isClaudeCopilotCatalogEntry,
3
+ isClaudeCopilotModel,
4
+ } from "./models.mjs";
5
+
1
6
  function catalogData(catalog) {
2
7
  if (Array.isArray(catalog)) return catalog;
3
8
  return Array.isArray(catalog?.data) ? catalog.data : [];
4
9
  }
5
10
 
6
- function isClaudeCatalogEntry(model) {
7
- const id = String(model?.id || "").trim();
8
- const vendor = String(model?.vendor || "").trim().toLowerCase();
9
- return Boolean(id) && (vendor === "anthropic" || id.toLowerCase().startsWith("claude-"));
10
- }
11
-
12
- function isAllowedClaudeModel(model) {
13
- return isClaudeCatalogEntry(model)
14
- && String(model?.vendor || "").trim().toLowerCase() === "anthropic"
15
- && model?.model_picker_enabled !== false
16
- && Array.isArray(model?.supported_endpoints)
17
- && model.supported_endpoints.includes("/chat/completions");
18
- }
19
-
20
11
  export function profileRouting({ claudeMode = "inherited", claudeConfigured = false } = {}) {
21
12
  const isolated = claudeMode === "isolated" || claudeConfigured === true;
22
13
  return Object.freeze({ responses: "codex", messages: isolated ? "claude" : "codex" });
@@ -44,9 +35,9 @@ export function createPmStudioModelRouter({ getCatalog, isClaudeEnabled } = {})
44
35
  const known = new Set();
45
36
  for (const model of catalogData(catalog)) {
46
37
  const id = String(model?.id || "").trim();
47
- if (!id || !isClaudeCatalogEntry(model)) continue;
38
+ if (!id || !isClaudeCopilotCatalogEntry(model)) continue;
48
39
  known.add(id);
49
- if (enabled && isAllowedClaudeModel(model) && !allowed.has(id)) allowed.set(id, model);
40
+ if (enabled && isClaudeCopilotModel(model) && !allowed.has(id)) allowed.set(id, model);
50
41
  }
51
42
  cachedCatalog = catalog;
52
43
  cachedEnabled = enabled;
@@ -46,6 +46,15 @@ export function createProfileRuntime({
46
46
  };
47
47
  const credentials = readCredentials();
48
48
  const claudeProfile = safeProfileStatus(credentials);
49
+ const claudeCredentialFingerprint = credentials.valid
50
+ ? githubTokenFingerprint(credentials.token)
51
+ : "";
52
+ const isClaudeProfileCurrent = () => {
53
+ if (!claudeCredentialFingerprint) return false;
54
+ const current = readCredentials();
55
+ return current.valid === true
56
+ && githubTokenFingerprint(current.token) === claudeCredentialFingerprint;
57
+ };
49
58
  let codexCredentialFingerprint = "";
50
59
  try {
51
60
  const codexCredentials = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
@@ -65,6 +74,7 @@ export function createProfileRuntime({
65
74
  claudeProfile,
66
75
  codexCredentialFingerprint,
67
76
  claudeCredentialFingerprint: "",
77
+ isClaudeProfileCurrent,
68
78
  });
69
79
  }
70
80
 
@@ -86,8 +96,7 @@ export function createProfileRuntime({
86
96
  claudeMode: "isolated",
87
97
  claudeProfile,
88
98
  codexCredentialFingerprint,
89
- claudeCredentialFingerprint: credentials.valid
90
- ? githubTokenFingerprint(credentials.token)
91
- : "",
99
+ claudeCredentialFingerprint,
100
+ isClaudeProfileCurrent,
92
101
  });
93
102
  }
@@ -1,4 +1,5 @@
1
1
  import { httpError } from "./http-transport.mjs";
2
+ import { clearResponsesToolOutputPartsCache } from "./responses-content.mjs";
2
3
  import { loadRuntimeConfig, parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
3
4
 
4
5
  const DEFAULT_MAX_BYTES = RUNTIME_DEFAULTS.responseHistoryMaxBytes;
@@ -201,17 +202,26 @@ export function responseHistoryMaterializedBytes(responseId) {
201
202
  return chain.reduce((bytes, entry) => bytes + entry.bytes, 0);
202
203
  }
203
204
 
204
- export function materializeResponseHistory(responseId) {
205
+ export function materializeResponseHistory(responseId, { assertActive } = {}) {
206
+ assertActive?.();
205
207
  const { chain, rootId } = responseHistoryChain(responseId);
206
208
  const items = [];
207
- for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
209
+ for (let index = 0; index < chain.length; index += 1) {
210
+ if ((index & 63) === 0) assertActive?.();
211
+ const entry = chain[chain.length - index - 1];
212
+ items.push(...entry.inputItems, ...entry.outputItems);
213
+ }
214
+ assertActive?.();
208
215
  const materialized = cloneJson(items);
216
+ assertActive?.();
209
217
  touchTree(rootId);
210
218
  return materialized;
211
219
  }
212
220
 
213
221
  export function rememberResponseHistoryNode({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
214
222
  if (!id || !Array.isArray(inputItems)) return;
223
+ clearResponsesToolOutputPartsCache(inputItems);
224
+ clearResponsesToolOutputPartsCache(outputItems);
215
225
  const bytes = jsonByteLength([inputItems, outputItems]);
216
226
  if (bytes > maxBytes) {
217
227
  if (histories.has(id)) removeSubtree(id);