minecodex 0.1.9 → 0.1.11

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.
@@ -0,0 +1,27 @@
1
+ # Hide Upsell Banner
2
+
3
+ 隐藏 Codex 桌面应用在输入区上方渲染的
4
+ “You're out of Codex and Work usage”用量耗尽横幅(upsell banner)。
5
+
6
+ 这是一个 `page-script` 类型的轻量功能:不启动 HTTP 服务、没有 Sidebar 或
7
+ Toolbar 入口,由 `CodexRuntimeHost` 在渲染进程内注入一段脚本。
8
+
9
+ 功能范围:
10
+
11
+ - 仅隐藏横幅的 GUI 显示(`aside` 卡片,`display: none`);
12
+ - 不拦截、不篡改额度查询接口(`account/rateLimits/read` 与 app-server
13
+ JSON-RPC 流量保持原样);
14
+ - 横幅重新渲染时由 `MutationObserver` 持续处理,不会闪烁后残留;
15
+ - 注入运行时随 RuntimeHost 卸载自动清理(恢复元素样式、断开 observer)。
16
+
17
+ ## 验证
18
+
19
+ ```bash
20
+ npm test
21
+ ```
22
+
23
+ ## 适配说明
24
+
25
+ 这是非官方 CDP / DOM Adapter 的功能声明。Codex 私有 DOM 的适配实现统一留在
26
+ `CodexRuntimeHost`;本功能只声明横幅的识别规则(卡片类名 + 文案特征)与
27
+ 隐藏动作。
@@ -0,0 +1,24 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "hide-upsell-banner",
4
+ "version": "0.1.11",
5
+ "label": {
6
+ "en": "Hide Upsell Banner",
7
+ "zh-CN": "隐藏用量横幅"
8
+ },
9
+ "description": {
10
+ "en": "Hide the rate-limit upsell banner in the composer while leaving usage checks untouched.",
11
+ "zh-CN": "隐藏输入区上方的用量耗尽横幅,不拦截额度查询功能。"
12
+ },
13
+ "entry": {
14
+ "kind": "page-script",
15
+ "script": "src/hide-upsell-banner.mjs"
16
+ },
17
+ "icon": {
18
+ "viewBox": "0 0 24 24",
19
+ "markup": "<rect width=\"18\" height=\"12\" x=\"3\" y=\"6\" rx=\"2.5\"></rect><path d=\"m5 5 14 14\"></path>"
20
+ },
21
+ "placement": {
22
+ "order": 40
23
+ }
24
+ }
@@ -0,0 +1,72 @@
1
+ // page-script feature body.
2
+ // Injected by CodexRuntimeHost into the Codex renderer through
3
+ // `new Function("signal", "window", "document", "MutationObserver", source)`,
4
+ // so this file is a function body: it runs once with those bindings in scope
5
+ // and may return a cleanup function. It hides the "You're out of Codex and
6
+ // Work usage" upsell banner that the desktop app renders above the composer
7
+ // when the account rate-limit snapshot marks the plan as exhausted. It never
8
+ // touches the rate-limit query path: `account/rateLimits/read` and the
9
+ // app-server JSON-RPC traffic stay intact.
10
+ const HIDDEN_MARKER = "data-codex-upsell-banner-hidden";
11
+
12
+ function isUpsellBanner(element) {
13
+ if (!(element instanceof window.HTMLElement)) return false;
14
+ const text = (element.innerText ?? "").trim();
15
+ if (!text) return false;
16
+ const isCard = element.classList.contains("rounded-2xl")
17
+ && element.classList.contains("border")
18
+ && element.classList.contains("bg-token-main-surface-primary");
19
+ if (!isCard) return false;
20
+ const titleMatch = /out of Codex and Work usage/i.test(text)
21
+ || /out of Codex messages/i.test(text)
22
+ || /Codex message usage limit reached/i.test(text);
23
+ const actionMatch = /Upgrade to Pro|Add Credits/i.test(text);
24
+ return titleMatch && actionMatch;
25
+ }
26
+
27
+ function hideBanner(element) {
28
+ if (element.hasAttribute(HIDDEN_MARKER)) return;
29
+ element.setAttribute(HIDDEN_MARKER, "");
30
+ element.style.display = "none";
31
+ }
32
+
33
+ function hideVisibleBanners(root = document) {
34
+ if (root.matches?.("aside") && isUpsellBanner(root)) hideBanner(root);
35
+ for (const element of root.querySelectorAll("aside")) {
36
+ if (isUpsellBanner(element)) hideBanner(element);
37
+ }
38
+ }
39
+
40
+ let rescanQueued = false;
41
+ function rescanBanners() {
42
+ // React can render the banner in stages: an empty <aside> shell first, then
43
+ // text and style updates on the same element. A childList-only observer
44
+ // misses those later mutations, so every mutation triggers a full rescan
45
+ // instead of only inspecting freshly added nodes.
46
+ if (rescanQueued) return;
47
+ rescanQueued = true;
48
+ queueMicrotask(() => {
49
+ rescanQueued = false;
50
+ hideVisibleBanners(document);
51
+ });
52
+ }
53
+
54
+ hideVisibleBanners();
55
+ const observer = new MutationObserver(() => {
56
+ rescanBanners();
57
+ });
58
+ observer.observe(document.documentElement, {
59
+ childList: true,
60
+ subtree: true,
61
+ attributes: true,
62
+ attributeFilter: ["style", "class", "hidden"],
63
+ characterData: true,
64
+ });
65
+
66
+ return () => {
67
+ observer.disconnect();
68
+ for (const element of document.querySelectorAll(`[${HIDDEN_MARKER}]`)) {
69
+ element.removeAttribute(HIDDEN_MARKER);
70
+ element.style.display = "";
71
+ }
72
+ };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "images",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "label": {
6
6
  "en": "Images",
7
7
  "zh-CN": "图片"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "model-slider",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "label": {
6
6
  "en": "Model Slider",
7
7
  "zh-CN": "模型滑条"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "notes",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "label": {
6
6
  "en": "Notes",
7
7
  "zh-CN": "笔记"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,6 +29,8 @@
29
29
  "features/images/src",
30
30
  "features/images/web",
31
31
  "features/images/codex-feature.json",
32
+ "features/hide-upsell-banner/src",
33
+ "features/hide-upsell-banner/codex-feature.json",
32
34
  "apps/dashboard/app.js",
33
35
  "apps/dashboard/favicon.svg",
34
36
  "apps/dashboard/index.html",
@@ -40,7 +42,7 @@
40
42
  },
41
43
  "scripts": {
42
44
  "start": "node packages/cli/bin/mcx.mjs serve",
43
- "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs apps/dashboard/test/*.test.mjs",
45
+ "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs apps/dashboard/test/*.test.mjs",
44
46
  "test:cli": "node --test packages/cli/test/*.test.mjs",
45
47
  "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
46
48
  "check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
@@ -2,13 +2,14 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images"]);
5
+ export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner"]);
6
6
  export const DEFAULT_CONFIG = Object.freeze({
7
7
  schemaVersion: 1,
8
8
  features: Object.freeze({
9
9
  "model-slider": true,
10
10
  notes: true,
11
11
  images: true,
12
+ "hide-upsell-banner": true,
12
13
  }),
13
14
  });
14
15
 
@@ -204,6 +204,13 @@ async function iconResponse(id, paths, response) {
204
204
  response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
205
205
  response.end(svg);
206
206
  return;
207
+ } else if (id === "hide-upsell-banner") {
208
+ const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
209
+ const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
210
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
211
+ response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
212
+ response.end(svg);
213
+ return;
207
214
  } else {
208
215
  json(response, 404, { error: "NOT_FOUND" });
209
216
  return;
@@ -105,6 +105,14 @@ export function createInjectionSource(features, {
105
105
  } = {}) {
106
106
  function install(config) {
107
107
  if (window.top !== window) return;
108
+ if (!document.documentElement) {
109
+ // The document bootstrap runs before the root element exists. Defer the
110
+ // whole install until the DOM is ready instead of crashing on a null
111
+ // documentElement (which previously left the runtime permanently absent
112
+ // after a renderer reload).
113
+ document.addEventListener("DOMContentLoaded", () => install(config), { once: true });
114
+ return;
115
+ }
108
116
  const currentRuntime = window.__codexPersonalRuntime;
109
117
  if (currentRuntime?.version === config.version && currentRuntime?.sessionId === config.sessionId) {
110
118
  currentRuntime.ensure();
@@ -247,6 +255,7 @@ export function createInjectionSource(features, {
247
255
  const modelSelectorFeature = features.find(
248
256
  (feature) => feature.entry?.kind === "composer-model-selector",
249
257
  ) ?? null;
258
+ const pageScriptFeatures = features.filter((feature) => feature.entry?.kind === "page-script");
250
259
  const pageSurfaces = new Map();
251
260
  const pinnedSurfaces = new Map();
252
261
  const surfaceRecords = new Map();
@@ -269,6 +278,7 @@ export function createInjectionSource(features, {
269
278
  let shiftedThreadOriginal = null;
270
279
  let mainContentObserver = null;
271
280
  const domObservers = [];
281
+ const pageScriptCleanups = [];
272
282
  const pendingHostActions = new Map();
273
283
  let activeModalRecordKey = null;
274
284
  let activePromptPreview = null;
@@ -367,6 +377,24 @@ export function createInjectionSource(features, {
367
377
  void loadNativeFastIcon();
368
378
  }
369
379
 
380
+ for (const feature of pageScriptFeatures) {
381
+ const source = feature.pageScript?.source ?? "";
382
+ if (!source.trim()) continue;
383
+ try {
384
+ const installer = new Function(
385
+ "signal",
386
+ "window",
387
+ "document",
388
+ "MutationObserver",
389
+ `"use strict";\n${source}`,
390
+ );
391
+ const cleanup = installer(lifetime.signal, window, document, MutationObserver);
392
+ if (typeof cleanup === "function") pageScriptCleanups.push(cleanup);
393
+ } catch (error) {
394
+ console.warn(`[codex-personal] page-script feature failed: ${feature.id}`, error);
395
+ }
396
+ }
397
+
370
398
  function findEntry(featureId) {
371
399
  return Array.from(document.querySelectorAll(`[${entryMarker}]`)).find(
372
400
  (entry) => entry.getAttribute(entryMarker) === featureId,
@@ -3451,6 +3479,11 @@ export function createInjectionSource(features, {
3451
3479
  resolveHostAction,
3452
3480
  dispose: () => {
3453
3481
  lifetime.abort();
3482
+ for (const cleanup of pageScriptCleanups.splice(0)) {
3483
+ try { cleanup(); } catch (error) {
3484
+ console.warn("[codex-personal] page-script cleanup failed", error);
3485
+ }
3486
+ }
3454
3487
  if (ensureFrame != null) cancelAnimationFrame(ensureFrame);
3455
3488
  if (ensureTimer != null) clearTimeout(ensureTimer);
3456
3489
  for (const observer of domObservers) observer.disconnect();
@@ -3487,6 +3520,7 @@ export function createInjectionSource(features, {
3487
3520
  detailTabs: feature.detailTabs ?? [],
3488
3521
  hostActions: feature.hostActions ?? [],
3489
3522
  modelSelector: feature.modelSelector ?? null,
3523
+ pageScript: feature.pageScript ?? null,
3490
3524
  }));
3491
3525
  return `(${install.toString()})(${JSON.stringify({
3492
3526
  version: RUNTIME_VERSION,
@@ -13,6 +13,8 @@ const HOST_ACTIONS = new Set([
13
13
  "resolve-file-paths",
14
14
  ]);
15
15
 
16
+ const PAGE_SCRIPT_ENTRY_KINDS = new Set(["page-script"]);
17
+
16
18
  export const FEATURE_PROTOCOL_VERSION = 1;
17
19
  export const FEATURE_HEALTH_TIMEOUT_MS = 750;
18
20
  export const FEATURE_STARTUP_TIMEOUT_MS = 1_000;
@@ -127,6 +129,23 @@ async function normalizeModelSelector(modelSelector, projectDir) {
127
129
  };
128
130
  }
129
131
 
132
+ async function normalizePageScript(manifest, projectDir) {
133
+ const resource = manifest.entry?.script ?? manifest.pageScript?.resource ?? manifest.script;
134
+ if (!resource) {
135
+ throw new Error("page-script entries require entry.script or script.resource");
136
+ }
137
+ const scriptPath = path.resolve(projectDir, resource);
138
+ const relative = path.relative(projectDir, scriptPath);
139
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
140
+ throw new Error("page-script resource must stay inside the feature project");
141
+ }
142
+ const source = await readFile(scriptPath, "utf8");
143
+ if (!source.trim()) {
144
+ throw new Error("page-script resource must be a non-empty script file");
145
+ }
146
+ return { resource, source };
147
+ }
148
+
130
149
  async function normalizeFeature(manifest, projectDir) {
131
150
  if (manifest.schemaVersion !== 1) throw new Error("schemaVersion must be 1");
132
151
  if (!manifest.id || !manifest.label) {
@@ -135,11 +154,12 @@ async function normalizeFeature(manifest, projectDir) {
135
154
 
136
155
  const label = normalizeLocalizedText(manifest.label, "label");
137
156
  const entryKind = manifest.entry?.kind ?? "sidebar";
138
- if (!new Set(["sidebar", "conversation-toolbar", "composer-model-selector"]).has(entryKind)) {
139
- throw new Error("entry.kind must be sidebar, conversation-toolbar, or composer-model-selector");
157
+ if (!new Set(["sidebar", "conversation-toolbar", "composer-model-selector", ...PAGE_SCRIPT_ENTRY_KINDS]).has(entryKind)) {
158
+ throw new Error("entry.kind must be sidebar, conversation-toolbar, composer-model-selector, or page-script");
140
159
  }
141
160
  const isModelSelector = entryKind === "composer-model-selector";
142
- if (!isModelSelector && !manifest.surfaceUrl) {
161
+ const isPageScript = PAGE_SCRIPT_ENTRY_KINDS.has(entryKind);
162
+ if (!isModelSelector && !isPageScript && !manifest.surfaceUrl) {
143
163
  throw new Error("surfaceUrl is required for rendered features");
144
164
  }
145
165
  if (entryKind === "conversation-toolbar" && !manifest.entry?.ariaLabel) {
@@ -222,6 +242,9 @@ async function normalizeFeature(manifest, projectDir) {
222
242
  modelSelector: isModelSelector
223
243
  ? await normalizeModelSelector(manifest.modelSelector, projectDir)
224
244
  : null,
245
+ pageScript: isPageScript
246
+ ? await normalizePageScript(manifest, projectDir)
247
+ : null,
225
248
  placement: {
226
249
  after: manifest.placement?.after ?? "sites",
227
250
  order: Number(manifest.placement?.order ?? 100),