minecodex 1.2.2 → 2.0.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 (87) hide show
  1. package/README.md +9 -3
  2. package/features/images/README.md +16 -98
  3. package/features/images/codex-feature.json +6 -23
  4. package/features/images/src/library-prompt-page.mjs +239 -0
  5. package/features/model-slider/THIRD_PARTY_NOTICES.md +1 -1
  6. package/features/notes/DESIGN.md +66 -0
  7. package/features/notes/README.md +18 -99
  8. package/features/notes/assets/icons/book-open-nav.svg +15 -0
  9. package/features/notes/assets/icons/book-open.svg +16 -0
  10. package/features/notes/assets/native-emojis.json +1 -0
  11. package/features/notes/assets/native-prompt-icons.json +28 -0
  12. package/features/notes/codex-feature.json +12 -70
  13. package/features/notes/src/main.mjs +2 -2
  14. package/features/notes/src/product.mjs +287 -613
  15. package/features/notes/src/prompt-schema.mjs +117 -0
  16. package/features/notes/src/quick-prompts-page.mjs +1559 -0
  17. package/features/notes/src/quick-prompts-server.mjs +167 -0
  18. package/features/session-status/DESIGN.md +11 -3
  19. package/features/session-status/README.md +8 -4
  20. package/features/session-status/src/session-status.mjs +272 -25
  21. package/features/sidebar-focus/DESIGN.md +16 -0
  22. package/features/sidebar-focus/codex-feature.json +20 -0
  23. package/features/sidebar-focus/src/sidebar-focus.mjs +134 -0
  24. package/features/sidebar-focus/vendor/lucide-LICENSE +43 -0
  25. package/features/unread-inbox/DESIGN.md +36 -0
  26. package/features/unread-inbox/codex-feature.json +20 -0
  27. package/features/unread-inbox/src/unread-inbox.mjs +446 -0
  28. package/features/usage-meter/DESIGN.md +49 -0
  29. package/features/usage-meter/README.md +18 -0
  30. package/features/usage-meter/codex-feature.json +13 -0
  31. package/features/usage-meter/src/usage-meter.mjs +200 -0
  32. package/package.json +17 -5
  33. package/packages/cli/src/config.mjs +4 -1
  34. package/packages/cli/src/control-server.mjs +2 -2
  35. package/packages/runtime-host/src/codex-injection.mjs +38 -3
  36. package/packages/runtime-host/src/codex-runtime.mjs +41 -20
  37. package/packages/runtime-host/src/feature-registry.mjs +1 -0
  38. package/packages/runtime-host/src/session-status-reader.mjs +40 -16
  39. package/features/notes/assets/icons/circle.svg +0 -15
  40. package/features/notes/assets/icons/ellipsis.svg +0 -17
  41. package/features/notes/assets/icons/external-link.svg +0 -17
  42. package/features/notes/assets/icons/list-todo.svg +0 -19
  43. package/features/notes/assets/icons/message-square-quote.svg +0 -17
  44. package/features/notes/assets/icons/paperclip.svg +0 -15
  45. package/features/notes/assets/icons/star-off.svg +0 -17
  46. package/features/notes/assets/icons/star.svg +0 -15
  47. package/features/notes/assets/icons/sticky-note.svg +0 -16
  48. package/features/notes/assets/icons/trash-2.svg +0 -19
  49. package/features/notes/src/http-server.mjs +0 -580
  50. package/features/notes/web/app.js +0 -1202
  51. package/features/notes/web/codex-artifact-document.svg +0 -1
  52. package/features/notes/web/codex-attachment.svg +0 -1
  53. package/features/notes/web/codex-build.svg +0 -1
  54. package/features/notes/web/codex-code.svg +0 -1
  55. package/features/notes/web/codex-cplusplus.svg +0 -1
  56. package/features/notes/web/codex-css.svg +0 -1
  57. package/features/notes/web/codex-document.svg +0 -1
  58. package/features/notes/web/codex-file.svg +0 -1
  59. package/features/notes/web/codex-folder.svg +0 -1
  60. package/features/notes/web/codex-hashes.svg +0 -1
  61. package/features/notes/web/codex-html.svg +0 -1
  62. package/features/notes/web/codex-image.svg +0 -1
  63. package/features/notes/web/codex-java.svg +0 -1
  64. package/features/notes/web/codex-javascript.svg +0 -1
  65. package/features/notes/web/codex-json.svg +0 -1
  66. package/features/notes/web/codex-notebook.svg +0 -1
  67. package/features/notes/web/codex-pdf.svg +0 -1
  68. package/features/notes/web/codex-pencil.svg +0 -3
  69. package/features/notes/web/codex-php.svg +0 -1
  70. package/features/notes/web/codex-plus.svg +0 -3
  71. package/features/notes/web/codex-presentation.svg +0 -1
  72. package/features/notes/web/codex-python.svg +0 -1
  73. package/features/notes/web/codex-react.svg +0 -1
  74. package/features/notes/web/codex-rust.svg +0 -1
  75. package/features/notes/web/codex-shell.svg +0 -1
  76. package/features/notes/web/codex-skill.svg +0 -1
  77. package/features/notes/web/codex-spreadsheet.svg +0 -1
  78. package/features/notes/web/codex-task.svg +0 -3
  79. package/features/notes/web/codex-terminal.svg +0 -1
  80. package/features/notes/web/codex-toml.svg +0 -1
  81. package/features/notes/web/codex-typescript.svg +0 -1
  82. package/features/notes/web/codex-view-all.svg +0 -3
  83. package/features/notes/web/file-icons.mjs +0 -68
  84. package/features/notes/web/i18n.mjs +0 -237
  85. package/features/notes/web/index.html +0 -15
  86. package/features/notes/web/styles.css +0 -1155
  87. package/features/notes/web/todo-drag.mjs +0 -18
@@ -0,0 +1,200 @@
1
+ // 订阅原生额度缓存;不另行轮询,也不复制账号信息到插件服务。
2
+ const MARKER = "data-minecodex-usage-meter";
3
+ const TIP_ID = "minecodex-usage-tooltip";
4
+ const STYLE_ID = "minecodex-usage-style";
5
+ let record = null;
6
+ let client = null;
7
+ let unsubscribe = null;
8
+ let queued = false;
9
+ let hoverTimer = null;
10
+
11
+ function remainingWindows(data) {
12
+ const windows = [data?.rate_limit?.primary_window, data?.rate_limit?.secondary_window];
13
+ const remaining = seconds => {
14
+ const window = windows.find(window => window?.limit_window_seconds === seconds);
15
+ return typeof window?.used_percent === "number" && Number.isFinite(window.used_percent)
16
+ ? Math.round(Math.max(0, Math.min(100, 100 - window.used_percent))) : null;
17
+ };
18
+ const weekly = remaining(604800), fiveHour = remaining(18000);
19
+ return weekly == null ? null : { weekly, fiveHour, primary: fiveHour ?? weekly };
20
+ }
21
+
22
+ function nativeContext(rail) {
23
+ const key = Object.keys(rail).find(key => key.startsWith("__reactFiber$"));
24
+ let fiber = key ? rail[key] : null;
25
+ const result = { auth: null, client: null, router: null };
26
+ for (let depth = 0; fiber && depth < 200; depth++, fiber = fiber.return) {
27
+ const props = fiber.memoizedProps;
28
+ if (!props) continue;
29
+ for (const value of [props.auth, props.value]) {
30
+ if (!result.auth && value && typeof value === "object" && "authMethod" in value && "userId" in value && "accountId" in value) result.auth = value;
31
+ }
32
+ for (const value of [props.queryClient, props.client, props.value]) {
33
+ if (!result.client && typeof value?.getQueryCache === "function" && typeof value?.getQueryData === "function") result.client = value;
34
+ }
35
+ const router = props.router ?? props.value?.router;
36
+ if (!result.router && typeof router?.navigate === "function") result.router = router;
37
+ if (result.auth && result.client && result.router) break;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ function currentWindows(context) {
43
+ const { auth, client } = context;
44
+ if (auth?.isLoading || !auth?.userId || !auth?.accountId || !["chatgpt", "chatgptAuthTokens"].includes(auth.authMethod)) return null;
45
+ const data = client?.getQueryData(["rate-limit-status"]);
46
+ // 账号与缓存必须同时一致;旧账号的最后一次响应不能留在导航栏。
47
+ if (!data || data.user_id !== auth.userId || data.account_id !== auth.accountId) return null;
48
+ return remainingWindows(data);
49
+ }
50
+
51
+ const style = document.createElement("style");
52
+ style.id = STYLE_ID;
53
+ style.textContent = `
54
+ [${MARKER}] { position:relative; display:flex; align-items:center; justify-content:center; flex:none; -webkit-app-region:no-drag; }
55
+ [${MARKER}] .mcx-usage-ring { display:block; width:24px; height:24px; overflow:visible; }
56
+ [${MARKER}] .mcx-usage-number { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:inherit; font-family:var(--font-sans-default); font-size:calc(var(--text-xs) * 2 / 3); line-height:1; font-weight:500; letter-spacing:0; font-variant-numeric:tabular-nums; pointer-events:none; }
57
+ [${MARKER}] [data-window="weekly"] { color:inherit; }
58
+ [${MARKER}] [data-window="fiveHour"] { color:var(--color-text-secondary); }
59
+ #${TIP_ID} { position:fixed; z-index:2147483000; width:max-content; max-width:calc(100vw - 16px); pointer-events:none; user-select:none; padding:5px 12px; border:1px solid var(--color-border-tooltip); border-radius:var(--radius-2xl); background:var(--color-background-tooltip); color:var(--color-text-tooltip); box-shadow:var(--shadow-tooltip); font-family:var(--font-sans-default); font-size:var(--text-sm); line-height:18px; font-weight:var(--tooltip-compact-font-weight); letter-spacing:var(--tracking-tooltip); }
60
+ #${TIP_ID}[hidden] { display:none; }
61
+ #${TIP_ID} dl { display:grid; grid-template-columns:auto auto; gap:2px 16px; margin:0; }
62
+ #${TIP_ID} dt { text-align:left; }
63
+ #${TIP_ID} dd { margin:0; text-align:right; font-variant-numeric:tabular-nums; }
64
+ `;
65
+ document.head.append(style);
66
+
67
+ function svgNode(name, attributes) {
68
+ const node = document.createElementNS("http://www.w3.org/2000/svg", name);
69
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
70
+ return node;
71
+ }
72
+
73
+ function draw(windows) {
74
+ const svg = svgNode("svg", { viewBox: "0 0 32 32", class: "mcx-usage-ring", "aria-hidden": "true" });
75
+ for (const [key, radius] of [["weekly", 43 / 3], ["fiveHour", 10.5]]) {
76
+ if (windows[key] == null) continue;
77
+ const group = svgNode("g", { "data-window": key, fill: "none", stroke: "currentColor", "stroke-width": 10 / 3 });
78
+ group.append(svgNode("circle", { cx: 16, cy: 16, r: radius, opacity: 0.18 }));
79
+ if (windows[key] > 0) group.append(svgNode("circle", {
80
+ cx: 16, cy: 16, r: radius, pathLength: 100,
81
+ "stroke-dasharray": `${windows[key]} 100`, "stroke-linecap": "round", transform: "rotate(-90 16 16)",
82
+ }));
83
+ svg.append(group);
84
+ }
85
+ const number = document.createElement("span");
86
+ number.className = "mcx-usage-number";
87
+ number.textContent = String(windows.primary);
88
+ number.setAttribute("data-dual", String(windows.fiveHour != null));
89
+ number.setAttribute("data-digits", String(number.textContent.length));
90
+ record.button.replaceChildren(svg, number);
91
+ const list = document.createElement("dl");
92
+ const rows = windows.fiveHour == null ? [] : [["5h Limit", windows.fiveHour]];
93
+ rows.push(["Weekly Limit", windows.weekly]);
94
+ for (const [label, percent] of rows) {
95
+ const term = document.createElement("dt"), value = document.createElement("dd");
96
+ term.textContent = label;
97
+ value.textContent = `${percent}%`;
98
+ list.append(term, value);
99
+ }
100
+ record.tooltip.replaceChildren(list);
101
+ record.button.setAttribute("aria-label", rows.map(([label, percent]) => `${label} ${percent}%`).join(", "));
102
+ record.button.setAttribute("aria-description", "Open Usage settings");
103
+ if (!record.tooltip.hidden) positionTooltip();
104
+ }
105
+
106
+ function positionTooltip() {
107
+ if (!record) return;
108
+ const rect = record.button.getBoundingClientRect();
109
+ const tooltip = record.tooltip;
110
+ // 按物理视口定位,兼容原生 app-shell 的 CSS zoom。
111
+ tooltip.style.left = `${Math.max(8, Math.min(rect.right + 2, window.innerWidth - tooltip.offsetWidth - 8))}px`;
112
+ tooltip.style.top = `${Math.max(8, Math.min(rect.top + (rect.height - tooltip.offsetHeight) / 2, window.innerHeight - tooltip.offsetHeight - 8))}px`;
113
+ }
114
+
115
+ function showTooltip() {
116
+ clearTimeout(hoverTimer);
117
+ if (!record) return;
118
+ record.tooltip.hidden = false;
119
+ record.button.setAttribute("aria-describedby", TIP_ID);
120
+ positionTooltip();
121
+ }
122
+ function hideTooltip() {
123
+ clearTimeout(hoverTimer);
124
+ if (!record) return;
125
+ record.tooltip.hidden = true;
126
+ record.button.removeAttribute("aria-describedby");
127
+ }
128
+ function removeControl() {
129
+ hideTooltip();
130
+ record?.button.remove();
131
+ record?.tooltip.remove();
132
+ record = null;
133
+ }
134
+
135
+ function mount(help, rail) {
136
+ // 只克隆按钮的外观属性,不携带原生 Help 的菜单行为或 id。
137
+ const button = help.cloneNode(false);
138
+ for (const attribute of [...button.attributes]) {
139
+ if (attribute.name.startsWith("aria-") || attribute.name === "id" || attribute.name === "data-state") button.removeAttribute(attribute.name);
140
+ }
141
+ button.setAttribute(MARKER, "");
142
+ const tooltip = document.createElement("div");
143
+ tooltip.id = TIP_ID;
144
+ tooltip.setAttribute("role", "tooltip");
145
+ tooltip.hidden = true;
146
+ const anchor = help.parentElement.classList.contains("contents") ? help.parentElement : help;
147
+ anchor.after(button);
148
+ document.body.append(tooltip);
149
+ record = { button, tooltip, rail, help, signature: null };
150
+ button.addEventListener("pointerenter", () => { hoverTimer = setTimeout(showTooltip, 350); });
151
+ button.addEventListener("pointerleave", hideTooltip);
152
+ button.addEventListener("focus", showTooltip);
153
+ button.addEventListener("blur", hideTooltip);
154
+ button.addEventListener("keydown", event => { if (event.key === "Escape") { event.stopPropagation(); hideTooltip(); } });
155
+ button.addEventListener("click", () => {
156
+ hideTooltip();
157
+ const { router } = nativeContext(rail);
158
+ router?.navigate("/settings/usage?tab=overview", { state: router.state?.location?.state });
159
+ });
160
+ }
161
+
162
+ function sync() {
163
+ queued = false;
164
+ if (signal.aborted) return;
165
+ const rail = [...document.querySelectorAll('nav[data-app-navigation-rail="true"]')].find(rail => rail.getBoundingClientRect().width > 0);
166
+ const help = rail?.querySelector('button[aria-label="Help menu"],button[aria-label="帮助菜单"]');
167
+ const context = rail ? nativeContext(rail) : {};
168
+ if (client !== context.client) {
169
+ unsubscribe?.();
170
+ client = context.client;
171
+ unsubscribe = client?.getQueryCache().subscribe(event => {
172
+ if (event.query?.queryKey?.[0] === "rate-limit-status") schedule();
173
+ });
174
+ }
175
+ const windows = currentWindows(context);
176
+ if (!help || !context.router || !windows) { removeControl(); return; }
177
+ if (record?.help !== help || !record?.button.isConnected) { removeControl(); mount(help, rail); }
178
+ const signature = JSON.stringify(windows);
179
+ if (record.signature !== signature) { record.signature = signature; draw(windows); }
180
+ }
181
+ function schedule() {
182
+ if (queued || signal.aborted) return;
183
+ queued = true;
184
+ queueMicrotask(sync);
185
+ }
186
+ const observer = new MutationObserver(schedule);
187
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-label"] });
188
+ window.addEventListener("resize", positionTooltip);
189
+ window.addEventListener("focus", schedule);
190
+ const cleanup = () => {
191
+ observer.disconnect();
192
+ unsubscribe?.();
193
+ window.removeEventListener("resize", positionTooltip);
194
+ window.removeEventListener("focus", schedule);
195
+ removeControl();
196
+ style.remove();
197
+ };
198
+ signal.addEventListener("abort", cleanup, { once: true });
199
+ sync();
200
+ return cleanup;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "1.2.2",
3
+ "version": "2.0.1",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,9 +23,10 @@
23
23
  "features/model-slider/THIRD_PARTY_NOTICES.md",
24
24
  "features/model-slider/vendor",
25
25
  "features/notes/src",
26
- "features/notes/web",
27
26
  "features/notes/assets",
28
27
  "features/notes/codex-feature.json",
28
+ "features/notes/DESIGN.md",
29
+ "features/notes/README.md",
29
30
  "features/images/src",
30
31
  "features/images/web",
31
32
  "features/images/codex-feature.json",
@@ -41,17 +42,28 @@
41
42
  "apps/dashboard/favicon.svg",
42
43
  "apps/dashboard/index.html",
43
44
  "apps/dashboard/styles.css",
44
- "README.md"
45
+ "README.md",
46
+ "features/usage-meter/src",
47
+ "features/usage-meter/codex-feature.json",
48
+ "features/usage-meter/DESIGN.md",
49
+ "features/usage-meter/README.md",
50
+ "features/unread-inbox/src",
51
+ "features/unread-inbox/codex-feature.json",
52
+ "features/unread-inbox/DESIGN.md",
53
+ "features/sidebar-focus/src",
54
+ "features/sidebar-focus/codex-feature.json",
55
+ "features/sidebar-focus/DESIGN.md",
56
+ "features/sidebar-focus/vendor/lucide-LICENSE"
45
57
  ],
46
58
  "engines": {
47
59
  "node": ">=22.5.0"
48
60
  },
49
61
  "scripts": {
50
62
  "start": "node packages/cli/bin/mcx.mjs serve",
51
- "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 features/session-status/test/*.test.mjs apps/dashboard/test/*.test.mjs",
63
+ "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 features/session-status/test/*.test.mjs features/usage-meter/test/*.test.mjs apps/dashboard/test/*.test.mjs features/unread-inbox/test/*.test.mjs features/sidebar-focus/test/*.test.mjs",
52
64
  "test:cli": "node --test packages/cli/test/*.test.mjs",
53
65
  "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
54
- "check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
66
+ "check": "node scripts/check-syntax.mjs",
55
67
  "build:service": "scripts/build-service-app.zsh",
56
68
  "pack:check": "npm pack --dry-run",
57
69
  "pack:audit": "node scripts/audit-package.mjs",
@@ -2,7 +2,7 @@ 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", "hide-upsell-banner", "session-status"]);
5
+ export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner", "session-status", "usage-meter", "unread-inbox", "sidebar-focus"]);
6
6
  export const DEFAULT_CONFIG = Object.freeze({
7
7
  schemaVersion: 1,
8
8
  features: Object.freeze({
@@ -11,6 +11,9 @@ export const DEFAULT_CONFIG = Object.freeze({
11
11
  images: true,
12
12
  "hide-upsell-banner": true,
13
13
  "session-status": true,
14
+ "usage-meter": true,
15
+ "unread-inbox": true,
16
+ "sidebar-focus": true,
14
17
  }),
15
18
  });
16
19
 
@@ -196,8 +196,8 @@ async function iconResponse(id, paths, response) {
196
196
  if (id === "model-slider") {
197
197
  iconPath = path.join(paths.featuresRoot, id, "assets", "ui", "sparkles.svg");
198
198
  } else if (id === "notes") {
199
- iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "sticky-note.svg");
200
- } else if (["images", "hide-upsell-banner", "session-status"].includes(id)) {
199
+ iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "book-open.svg");
200
+ } else if (["images", "hide-upsell-banner", "session-status", "usage-meter"].includes(id)) {
201
201
  const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
202
202
  const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
203
203
  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>`;
@@ -390,12 +390,14 @@ export function createInjectionSource(features, {
390
390
  "config",
391
391
  `"use strict";\n${source}`,
392
392
  );
393
+ const pageConfig = feature.pageScript.config ??= {};
394
+ pageConfig.requestHostAction = (action, payload) => requestPageHostAction(feature.id, action, payload);
393
395
  const cleanup = installer(
394
396
  lifetime.signal,
395
397
  window,
396
398
  document,
397
399
  MutationObserver,
398
- feature.pageScript.config ?? {},
400
+ pageConfig,
399
401
  );
400
402
  if (typeof cleanup === "function") pageScriptCleanups.push(cleanup);
401
403
  } catch (error) {
@@ -2779,9 +2781,10 @@ export function createInjectionSource(features, {
2779
2781
  }
2780
2782
  // 原生开合的临时 min-width 钉定与弹层水平居中都基于对 Measurement
2781
2783
  // 探针的测量;把探针文本同步为 MCX 显示标签,原生机制即可测得
2782
- // 真实显示宽度(含 110px 名称上限与 160px Content 上限)。
2784
+ // 真实显示宽度。保留 React 持有的子节点,避免切模型时卸载失败。
2783
2785
  const measurement = trigger.querySelector('[class*="_ModelPickerTriggerMeasurement_"]');
2784
- if (measurement && measurement.textContent !== label) measurement.textContent = label;
2786
+ const measurementText = measurement && firstVisibleTextNode(measurement);
2787
+ if (measurementText && measurementText.nodeValue !== label) measurementText.nodeValue = label;
2785
2788
  }
2786
2789
 
2787
2790
  function ensureModelSelector() {
@@ -4525,10 +4528,36 @@ export function createInjectionSource(features, {
4525
4528
  }
4526
4529
  }
4527
4530
 
4531
+ function requestPageHostAction(featureId, action, payload) {
4532
+ if (!featureById.get(featureId)?.hostActions.has(action)) return Promise.reject(new Error("Host action is not registered"));
4533
+ if (typeof globalThis[config.bindingName] !== "function") return Promise.reject(new Error("Host bridge is unavailable"));
4534
+ return new Promise((resolve, reject) => {
4535
+ const requestId = crypto.randomUUID();
4536
+ const timer = setTimeout(() => {
4537
+ pendingHostActions.delete(requestId);
4538
+ reject(new Error("Host request timed out"));
4539
+ }, 10_000);
4540
+ pendingHostActions.set(requestId, { kind: "page-script", resolve, reject, timer });
4541
+ try {
4542
+ globalThis[config.bindingName](JSON.stringify({ token: config.bindingToken, featureId, requestId, action, payload }));
4543
+ } catch (error) {
4544
+ clearTimeout(timer);
4545
+ pendingHostActions.delete(requestId);
4546
+ reject(error);
4547
+ }
4548
+ });
4549
+ }
4550
+
4528
4551
  function resolveHostAction(requestId, response) {
4529
4552
  const pending = pendingHostActions.get(requestId);
4530
4553
  if (!pending) return false;
4531
4554
  pendingHostActions.delete(requestId);
4555
+ if (pending.kind === "page-script") {
4556
+ clearTimeout(pending.timer);
4557
+ if (response.ok) pending.resolve(response.result);
4558
+ else pending.reject(new Error(response.error?.message ?? "Host request failed"));
4559
+ return true;
4560
+ }
4532
4561
  const record = surfaceRecords.get(pending.recordKey);
4533
4562
  if (!record) return false;
4534
4563
  if (pending.kind === "surface-load") {
@@ -4711,6 +4740,12 @@ export function createInjectionSource(features, {
4711
4740
  resolveHostAction,
4712
4741
  dispose: () => {
4713
4742
  lifetime.abort();
4743
+ for (const [id, pending] of pendingHostActions) {
4744
+ if (pending.kind !== "page-script") continue;
4745
+ clearTimeout(pending.timer);
4746
+ pending.reject(new Error("Runtime disposed"));
4747
+ pendingHostActions.delete(id);
4748
+ }
4714
4749
  for (const cleanup of pageScriptCleanups.splice(0)) {
4715
4750
  try { cleanup(); } catch (error) {
4716
4751
  console.warn("[codex-personal] page-script cleanup failed", error);
@@ -47,15 +47,11 @@ export {
47
47
  } from "./codex-injection.mjs";
48
48
 
49
49
  export const SESSION_STATUS_THREAD_ID_EXPRESSION = `(() => {
50
- const composer = document.querySelector('[data-codex-composer="true"][contenteditable="true"]')
51
- ?? document.querySelector('[contenteditable="true"][role="textbox"]');
52
- const card = composer?.closest('[data-codex-composer-root]');
53
- const portal = card?.querySelector('[data-above-composer-portal]');
54
- if (portal) return portal.getAttribute('data-above-composer-conversation-id');
55
- if (card?.getAttribute('data-composer-placement') === 'home') return null;
56
- const selected = document.querySelector('[data-app-action-sidebar-thread-selected="true"]');
57
- return selected?.getAttribute('data-app-action-sidebar-thread-id')
58
- ?? location.pathname + location.search + location.hash;
50
+ return [...new Set([...document.querySelectorAll('[data-codex-composer="true"][contenteditable="true"]')].map(composer => {
51
+ const card = composer.closest('[data-codex-composer-root]');
52
+ if (card?.getAttribute('data-composer-placement') !== 'thread') return null;
53
+ return card.querySelector('[data-above-composer-portal]')?.getAttribute('data-above-composer-conversation-id');
54
+ }).filter(Boolean))];
59
55
  })()`;
60
56
 
61
57
  export class CodexRuntime {
@@ -550,6 +546,29 @@ export class CodexRuntime {
550
546
  });
551
547
  return;
552
548
  }
549
+ if (feature.id === "notes" && request.action === "quick-prompts") {
550
+ try {
551
+ const { route, method = "GET", body } = request.payload ?? {};
552
+ const allowed = (route === "/api/state" && method === "GET")
553
+ || (["/api/prompts", "/api/categories", "/api/reorder"].includes(route) && method === "POST")
554
+ || (/^\/api\/categories\/[a-zA-Z0-9-]+$/.test(route) && ["PATCH", "DELETE"].includes(method))
555
+ || (/^\/api\/prompts\/[a-zA-Z0-9-]+$/.test(route) && ["PATCH", "DELETE"].includes(method))
556
+ || (/^\/api\/prompts\/[a-zA-Z0-9-]+\/use$/.test(route) && method === "POST");
557
+ const origin = pageScriptServiceOrigin(feature);
558
+ if (!allowed || !origin) throw new Error("Prompt request is not allowed");
559
+ const response = await this.fetchImpl(new URL(route, origin), {
560
+ method, headers: { "Content-Type": "application/json", Origin: CODEX_APP_ORIGIN },
561
+ body: body === undefined ? undefined : JSON.stringify(body),
562
+ signal: AbortSignal.timeout(8_000),
563
+ });
564
+ const result = response.status === 204 ? null : await response.json();
565
+ if (!response.ok) throw new Error(result?.error?.message ?? "Prompt request failed");
566
+ await this.resolveHostAction(client, request.requestId, { ok: true, result });
567
+ } catch (error) {
568
+ await this.resolveHostAction(client, request.requestId, { ok: false, error: { code: "PROMPT_REQUEST_FAILED", message: error.message } });
569
+ }
570
+ return;
571
+ }
553
572
  if (request.action === "import-generated-image") {
554
573
  await this.importGeneratedImage(feature, request.payload);
555
574
  return;
@@ -741,17 +760,19 @@ export class CodexRuntime {
741
760
  expression: SESSION_STATUS_THREAD_ID_EXPRESSION,
742
761
  returnByValue: true,
743
762
  }, { timeoutMs: 3_000 });
744
- const threadId = threadIdFromLocation(response.result?.value);
745
- const data = threadId ? await this.sessionStatusReader.read(threadId) : null;
746
- const snapshot = data ?? (threadId ? {
747
- threadId,
748
- retryAfterMs: this.sessionStatusReader.retryAfterMs?.(threadId) ?? 500,
749
- } : null);
750
- if (this.stopping || this.clients.get(targetId) !== client) return;
751
- await client.send("Runtime.evaluate", {
752
- expression: `window.__minecodexSessionStatus?.update(${JSON.stringify(snapshot)}, ${revision})`,
753
- returnByValue: true,
754
- }, { timeoutMs: 3_000 });
763
+ for (const value of response.result?.value ?? []) {
764
+ const threadId = threadIdFromLocation(value);
765
+ const data = threadId ? await this.sessionStatusReader.read(threadId) : null;
766
+ const snapshot = data ?? (threadId ? {
767
+ threadId,
768
+ retryAfterMs: this.sessionStatusReader.retryAfterMs?.(threadId) ?? 500,
769
+ } : null);
770
+ if (this.stopping || this.clients.get(targetId) !== client) return;
771
+ await client.send("Runtime.evaluate", {
772
+ expression: `window.__minecodexSessionStatus?.update(${JSON.stringify(snapshot)}, ${revision})`,
773
+ returnByValue: true,
774
+ }, { timeoutMs: 3_000 });
775
+ }
755
776
  } catch (error) {
756
777
  this.logger.warn?.("Session Status update failed", error.message);
757
778
  if (this.clients.get(targetId) === client) {
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
 
7
7
  const MANIFEST_NAME = "codex-feature.json";
8
8
  const HOST_ACTIONS = new Set([
9
+ "quick-prompts",
9
10
  "insert-text",
10
11
  "attach-file",
11
12
  "open-detail-tab",
@@ -70,9 +70,7 @@ function emptyState(filePath) {
70
70
  contextWindow: null,
71
71
  firstOutputAt: null,
72
72
  pendingToolIntervals: [],
73
- decodeTokens: 0,
74
- decodeMs: 0,
75
- speedSamples: 0,
73
+ speedMeasurements: [],
76
74
  ttftTotalMs: 0,
77
75
  ttftSamples: 0,
78
76
  toolMs: 0,
@@ -110,6 +108,9 @@ export function applySessionStatusEvent(state, event) {
110
108
  const payload = event?.payload ?? {};
111
109
  if (event?.type === "compacted") {
112
110
  state.compactionRecords += 1;
111
+ state.last = null;
112
+ state.firstOutputAt = null;
113
+ state.pendingToolIntervals = [];
113
114
  if (Number.isSafeInteger(payload.window_number) && payload.window_number >= 0) {
114
115
  state.compactionWindow = Math.max(state.compactionWindow, payload.window_number);
115
116
  }
@@ -134,12 +135,21 @@ export function applySessionStatusEvent(state, event) {
134
135
  state.baseInstructionTokens = estimatedTextTokens(payload.base_instructions?.text);
135
136
  }
136
137
  if (event?.type === "event_msg") {
137
- if (payload.type === "context_compacted") state.compactionEvents += 1;
138
+ if (payload.type === "context_compacted") {
139
+ state.compactionEvents += 1;
140
+ state.last = null;
141
+ state.firstOutputAt = null;
142
+ state.pendingToolIntervals = [];
143
+ }
138
144
  if (payload.type === "task_started" && payload.turn_id) {
145
+ state.firstOutputAt = null;
146
+ state.pendingToolIntervals = [];
139
147
  state.turns.add(payload.turn_id);
140
148
  state.runningTurns.add(payload.turn_id);
141
149
  }
142
150
  if (["task_complete", "task_interrupted", "turn_aborted"].includes(payload.type)) {
151
+ state.firstOutputAt = null;
152
+ state.pendingToolIntervals = [];
143
153
  if (payload.turn_id) state.runningTurns.delete(payload.turn_id);
144
154
  else state.runningTurns.clear();
145
155
  }
@@ -158,13 +168,17 @@ export function applySessionStatusEvent(state, event) {
158
168
  if (TOOL_ITEMS.has(itemType) && Number.isFinite(start) && Number.isFinite(end)) {
159
169
  state.toolMs += Math.max(0, end - start);
160
170
  state.pendingToolIntervals.push([start, end]);
171
+ // 工具完成事件可能晚于用量记录落盘,补扣已记录样本中的重叠区间。
172
+ for (const sample of state.speedMeasurements) {
173
+ if (start < sample.end && end > sample.start) sample.tools.push([start, end]);
174
+ }
161
175
  }
162
176
  }
163
177
  if (payload.type === "token_count") {
164
178
  if (!state.hasResponseUsage && validUsage(payload.info?.total_token_usage)) {
165
179
  state.total = payload.info.total_token_usage;
166
180
  }
167
- if (!state.hasResponseUsage && validUsage(payload.info?.last_token_usage)) {
181
+ if (validUsage(payload.info?.last_token_usage)) {
168
182
  state.last = payload.info.last_token_usage;
169
183
  }
170
184
  if (Number.isFinite(payload.info?.model_context_window)) {
@@ -174,18 +188,18 @@ export function applySessionStatusEvent(state, event) {
174
188
  }
175
189
  if (event?.type === "token_usage_record") {
176
190
  const responseId = payload.response_id;
177
- if (responseId && !state.responses.has(responseId)) {
191
+ if (responseId && state.responses.has(responseId)) return;
192
+ if (responseId) {
178
193
  state.responses.add(responseId);
179
194
  const responseEnd = Date.parse(event.timestamp);
180
- const elapsed = state.firstOutputAt == null ? null : responseEnd - state.firstOutputAt
181
- - overlappingToolMs(state.pendingToolIntervals, state.firstOutputAt, responseEnd);
182
195
  const output = payload.usage?.output_tokens;
183
- // 会话事件只给出首个可见条目的时间,故 TPS 是观测估算。
196
+ // 起止点来自会话事件,并非模型首末 token 时间;只能提供观测约值。
184
197
  if (Number.isFinite(output) && output > 0 && state.firstOutputAt != null
185
- && Number.isFinite(elapsed) && elapsed > 0 && elapsed < 600_000) {
186
- state.decodeTokens += output;
187
- state.decodeMs += elapsed;
188
- state.speedSamples += 1;
198
+ && Number.isFinite(responseEnd) && responseEnd > state.firstOutputAt) {
199
+ state.speedMeasurements.push({
200
+ start: state.firstOutputAt, end: responseEnd, output,
201
+ tools: state.pendingToolIntervals.slice(),
202
+ });
189
203
  }
190
204
  }
191
205
  state.firstOutputAt = null;
@@ -200,6 +214,16 @@ export function applySessionStatusEvent(state, event) {
200
214
  }
201
215
 
202
216
  export function sessionStatusSnapshot(state, threadId) {
217
+ let decodeTokens = 0;
218
+ let decodeMs = 0;
219
+ let speedSamples = 0;
220
+ for (const sample of state.speedMeasurements) {
221
+ const elapsed = sample.end - sample.start - overlappingToolMs(sample.tools, sample.start, sample.end);
222
+ if (elapsed <= 0 || elapsed >= 600_000) continue;
223
+ decodeTokens += sample.output;
224
+ decodeMs += elapsed;
225
+ speedSamples += 1;
226
+ }
203
227
  const total = state.total;
204
228
  const input = total?.input_tokens ?? 0;
205
229
  const cached = total?.cached_input_tokens ?? 0;
@@ -218,9 +242,9 @@ export function sessionStatusSnapshot(state, threadId) {
218
242
  uncachedInputTokens: total ? Math.max(0, input - cached - cacheWrite) : null,
219
243
  outputTokens: total?.output_tokens ?? null,
220
244
  cacheHitPercent: input > 0 ? Math.round(cached / input * 100) : null,
221
- observedTps: state.decodeMs > 0 ? Math.round(state.decodeTokens / (state.decodeMs / 1000)) : null,
222
- speedSamples: state.speedSamples,
223
- observedModelMs: state.decodeMs,
245
+ observedTps: decodeMs > 0 ? Math.round(decodeTokens / (decodeMs / 1000)) : null,
246
+ speedSamples,
247
+ observedModelMs: decodeMs,
224
248
  toolMs: state.toolMs,
225
249
  averageFirstTokenMs: state.ttftSamples > 0 ? Math.round(state.ttftTotalMs / state.ttftSamples) : null,
226
250
  contextUsedTokens: contextUsed,
@@ -1,15 +0,0 @@
1
- <!-- @license lucide-static v1.31.0 - ISC -->
2
- <svg
3
- class="lucide lucide-circle"
4
- xmlns="http://www.w3.org/2000/svg"
5
- width="24"
6
- height="24"
7
- viewBox="0 0 24 24"
8
- fill="none"
9
- stroke="currentColor"
10
- stroke-width="2"
11
- stroke-linecap="round"
12
- stroke-linejoin="round"
13
- >
14
- <circle cx="12" cy="12" r="10" />
15
- </svg>
@@ -1,17 +0,0 @@
1
- <!-- @license lucide-static v1.31.0 - ISC -->
2
- <svg
3
- class="lucide lucide-ellipsis"
4
- xmlns="http://www.w3.org/2000/svg"
5
- width="24"
6
- height="24"
7
- viewBox="0 0 24 24"
8
- fill="none"
9
- stroke="currentColor"
10
- stroke-width="2"
11
- stroke-linecap="round"
12
- stroke-linejoin="round"
13
- >
14
- <circle cx="12" cy="12" r="1" />
15
- <circle cx="19" cy="12" r="1" />
16
- <circle cx="5" cy="12" r="1" />
17
- </svg>
@@ -1,17 +0,0 @@
1
- <!-- @license lucide-static v1.31.0 - ISC -->
2
- <svg
3
- class="lucide lucide-external-link"
4
- xmlns="http://www.w3.org/2000/svg"
5
- width="24"
6
- height="24"
7
- viewBox="0 0 24 24"
8
- fill="none"
9
- stroke="currentColor"
10
- stroke-width="2"
11
- stroke-linecap="round"
12
- stroke-linejoin="round"
13
- >
14
- <path d="M15 3h6v6" />
15
- <path d="M10 14 21 3" />
16
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
17
- </svg>
@@ -1,19 +0,0 @@
1
- <!-- @license lucide-static v1.31.0 - ISC -->
2
- <svg
3
- class="lucide lucide-list-todo"
4
- xmlns="http://www.w3.org/2000/svg"
5
- width="24"
6
- height="24"
7
- viewBox="0 0 24 24"
8
- fill="none"
9
- stroke="currentColor"
10
- stroke-width="2"
11
- stroke-linecap="round"
12
- stroke-linejoin="round"
13
- >
14
- <path d="M13 5h8" />
15
- <path d="M13 12h8" />
16
- <path d="M13 19h8" />
17
- <path d="m3 17 2 2 4-4" />
18
- <rect x="3" y="4" width="6" height="6" rx="1" />
19
- </svg>